From 7b79ea073893149551f1920be3c186862fc9af16 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 18 Mar 2026 19:00:07 +0800 Subject: [PATCH 01/10] docs: OpenClaw PowerMem plugin --- README.md | 6 ++++-- README_CN.md | 6 ++++-- README_JP.md | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4b3e19a3..124281a4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@

-*PowerMem integrated with [OpenClaw](https://github.com/openclaw-ai/openclaw): intelligent memory for AI agents. **OpenClaw PowerMem Plugin**: [View Plugin](https://github.com/ob-labs/openclaw-extension-powermem)* +*PowerMem integrated with [OpenClaw](https://github.com/openclaw-ai/openclaw): intelligent memory for AI agents. **OpenClaw PowerMem Plugin**: [View Plugin](https://github.com/ob-labs/memory-powermem)* + +One command to add PowerMem memory to OpenClaw: `openclaw plugins install memory-powermem`. PowerMem with OpenClaw @@ -216,7 +218,7 @@ Add the following configuration to your Claude Desktop config file: The MCP server provides tools for memory management including adding, searching, updating, and deleting memories. For complete MCP documentation and usage examples, see the [MCP Server Documentation](docs/api/0004-mcp.md). ## 🔗 Integrations & Demos -- 🔗 **openclaw Memory Plugin**: Use PowerMem as long-term memory in [openclaw](https://github.com/openclaw/openclaw) via extraction, Ebbinghaus forgetting curve, multi-agent isolation. [View Plugin](https://github.com/ob-labs/openclaw-extension-powermem) +- 🔗 **openclaw Memory Plugin**: Use PowerMem as long-term memory in [openclaw](https://github.com/openclaw/openclaw) via extraction, Ebbinghaus forgetting curve, multi-agent isolation. [View Plugin](https://github.com/ob-labs/memory-powermem) - 🔗 **LangChain Integration**: Build medical support chatbot using LangChain + PowerMem + OceanBase, [View Example](examples/langchain/README.md) - 🔗 **LangGraph Integration**: Build customer service chatbot using LangGraph + PowerMem + OceanBase, [View Example](examples/langgraph/README.md) diff --git a/README_CN.md b/README_CN.md index aa66b276..d733b3e9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -6,7 +6,9 @@

-*PowerMem 与 [OpenClaw](https://github.com/openclaw-ai/openclaw) 集成:为 AI 智能体提供智能记忆。**OpenClaw PowerMem 记忆插件**:[查看插件](https://github.com/ob-labs/openclaw-extension-powermem)* +*PowerMem 与 [OpenClaw](https://github.com/openclaw-ai/openclaw) 集成:为 AI 智能体提供智能记忆。**OpenClaw PowerMem 记忆插件**:[查看插件](https://github.com/ob-labs/memory-powermem)* + +一行命令即可为 OpenClaw 接入 PowerMem 记忆:`openclaw plugins install memory-powermem`。 PowerMem 与 OpenClaw @@ -217,7 +219,7 @@ uvx powermem-mcp streamable-http 8001 MCP Server提供记忆管理工具,包括添加、搜索、更新和删除记忆。完整的 MCP 文档和使用示例,请参阅 [MCP Server文档](docs/api/0004-mcp.md)。 ## 🔗 集成与演示 -- 🔗 **openclaw 外挂记忆插件**:在 [openclaw](https://github.com/openclaw/openclaw) 中通过插件方式使用 PowerMem 长期记忆,支持智能抽取、艾宾浩斯遗忘曲线、多 Agent 隔离。[查看插件](https://github.com/ob-labs/openclaw-extension-powermem) +- 🔗 **openclaw 外挂记忆插件**:在 [openclaw](https://github.com/openclaw/openclaw) 中通过插件方式使用 PowerMem 长期记忆,支持智能抽取、艾宾浩斯遗忘曲线、多 Agent 隔离。[查看插件](https://github.com/ob-labs/memory-powermem) - 🔗 **LangChain 集成**:基于 LangChain + PowerMem + OceanBase 构建医疗支持机器人,[查看示例](examples/langchain/README.md) - 🔗 **LangGraph 集成**:基于 LangGraph + PowerMem + OceanBase 构建客户服务机器人,[查看示例](examples/langgraph/README.md) diff --git a/README_JP.md b/README_JP.md index dc5a14d3..21e0cb1f 100644 --- a/README_JP.md +++ b/README_JP.md @@ -6,7 +6,9 @@

-*PowerMem と [OpenClaw](https://github.com/openclaw-ai/openclaw) の連携:AI エージェント向けインテリジェントメモリ。**OpenClaw PowerMem メモリプラグイン**:[プラグインを見る](https://github.com/ob-labs/openclaw-extension-powermem)* +*PowerMem と [OpenClaw](https://github.com/openclaw-ai/openclaw) の連携:AI エージェント向けインテリジェントメモリ。**OpenClaw PowerMem メモリプラグイン**:[プラグインを見る](https://github.com/ob-labs/memory-powermem)* + +1 コマンドで OpenClaw に PowerMem メモリを追加:`openclaw plugins install memory-powermem`。 PowerMem と OpenClaw @@ -216,7 +218,7 @@ Claude Desktop 設定ファイルに次の設定を追加します: MCP サーバーは、メモリの追加、検索、更新、削除を含むメモリ管理ツールを提供します。完全な MCP ドキュメントと使用例については、[MCP サーバードキュメント](docs/api/0004-mcp.md) を参照してください。 ## 🔗 統合とデモ -- 🔗 **openclaw メモリプラグイン**: [openclaw](https://github.com/openclaw/openclaw) で HTTP API により PowerMem を長期メモリとして利用。インテリジェント抽出、エビングハウス忘却曲線、マルチエージェント分離に対応。[プラグインを参照](https://github.com/ob-labs/openclaw-extension-powermem) +- 🔗 **openclaw メモリプラグイン**: [openclaw](https://github.com/openclaw/openclaw) で HTTP API により PowerMem を長期メモリとして利用。インテリジェント抽出、エビングハウス忘却曲線、マルチエージェント分離に対応。[プラグインを参照](https://github.com/ob-labs/memory-powermem) - 🔗 **LangChain 統合**: LangChain + PowerMem + OceanBase を使用して医療サポートロボットを構築、[例を参照](examples/langchain/README.md) - 🔗 **LangGraph 統合**: LangGraph + PowerMem + OceanBase を使用してカスタマーサービスロボットを構築、[例を参照](examples/langgraph/README.md) From 3f790a9443673c81144c6b6c32ed423256644f06 Mon Sep 17 00:00:00 2001 From: Teingi Date: Wed, 18 Mar 2026 20:43:52 +0800 Subject: [PATCH 02/10] Add IDE integrations so PowerMem can be used from VS Code, Cursor, Claude Code, Codex, Windsurf, and Copilot. --- .github/workflows/build.yml | 4 +- .github/workflows/plugins-build.yml | 135 ++++++++ .github/workflows/plugins-publish-vscode.yml | 64 ++++ .gitignore | 5 + apps/README.md | 16 + .../.claude-plugin/plugin.json | 8 + apps/claude-code-plugin/.mcp.json | 8 + apps/claude-code-plugin/CHANGELOG.md | 5 + apps/claude-code-plugin/README.md | 61 ++++ .../claude-code-plugin/skills/recall/SKILL.md | 8 + .../skills/remember/SKILL.md | 8 + apps/vscode-extension/.vscodeignore | 6 + apps/vscode-extension/README.md | 64 ++++ apps/vscode-extension/media/.gitkeep | 0 apps/vscode-extension/package-lock.json | 58 ++++ apps/vscode-extension/package.json | 74 +++++ apps/vscode-extension/src/api/client.ts | 80 +++++ apps/vscode-extension/src/api/types.ts | 53 +++ .../src/detectors/powermem.ts | 28 ++ apps/vscode-extension/src/extension.ts | 310 ++++++++++++++++++ .../src/panels/DashboardPanel.ts | 82 +++++ apps/vscode-extension/src/utils/health.ts | 16 + apps/vscode-extension/src/writers/claude.ts | 62 ++++ apps/vscode-extension/src/writers/codex.ts | 75 +++++ apps/vscode-extension/src/writers/copilot.ts | 57 ++++ apps/vscode-extension/src/writers/cursor.ts | 74 +++++ apps/vscode-extension/src/writers/windsurf.ts | 44 +++ apps/vscode-extension/tsconfig.json | 14 + 28 files changed, 1418 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/plugins-build.yml create mode 100644 .github/workflows/plugins-publish-vscode.yml create mode 100644 apps/README.md create mode 100644 apps/claude-code-plugin/.claude-plugin/plugin.json create mode 100644 apps/claude-code-plugin/.mcp.json create mode 100644 apps/claude-code-plugin/CHANGELOG.md create mode 100644 apps/claude-code-plugin/README.md create mode 100644 apps/claude-code-plugin/skills/recall/SKILL.md create mode 100644 apps/claude-code-plugin/skills/remember/SKILL.md create mode 100644 apps/vscode-extension/.vscodeignore create mode 100644 apps/vscode-extension/README.md create mode 100644 apps/vscode-extension/media/.gitkeep create mode 100644 apps/vscode-extension/package-lock.json create mode 100644 apps/vscode-extension/package.json create mode 100644 apps/vscode-extension/src/api/client.ts create mode 100644 apps/vscode-extension/src/api/types.ts create mode 100644 apps/vscode-extension/src/detectors/powermem.ts create mode 100644 apps/vscode-extension/src/extension.ts create mode 100644 apps/vscode-extension/src/panels/DashboardPanel.ts create mode 100644 apps/vscode-extension/src/utils/health.ts create mode 100644 apps/vscode-extension/src/writers/claude.ts create mode 100644 apps/vscode-extension/src/writers/codex.ts create mode 100644 apps/vscode-extension/src/writers/copilot.ts create mode 100644 apps/vscode-extension/src/writers/cursor.ts create mode 100644 apps/vscode-extension/src/writers/windsurf.ts create mode 100644 apps/vscode-extension/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ccf7e25..7f4a4efa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ name: Build Package +# Main PowerMem release (Python packages). Does not run on plugin-only tags (plugins-v*). on: push: branches: [main, develop] @@ -140,7 +141,8 @@ jobs: build-and-release: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + # Only release on version tags v*; do not run for plugin-only tags (plugins-v*) + if: startsWith(github.ref, 'refs/tags/v') && !startsWith(github.ref, 'refs/tags/plugins-') needs: combine-artifacts permissions: contents: write diff --git a/.github/workflows/plugins-build.yml b/.github/workflows/plugins-build.yml new file mode 100644 index 00000000..37762c59 --- /dev/null +++ b/.github/workflows/plugins-build.yml @@ -0,0 +1,135 @@ +name: Plugins Build and Release + +on: + push: + branches: [main, develop] + paths: + - 'apps/**' + - '.github/workflows/plugins-build.yml' + tags: + - 'plugins-v*' + pull_request: + branches: [main, develop] + paths: + - 'apps/**' + - '.github/workflows/plugins-build.yml' + workflow_dispatch: + inputs: + create_release: + description: 'Create a GitHub Release with plugin assets (only if not tag)' + required: false + default: false + type: boolean + +env: + NODE_VERSION: '20' + +jobs: + build-vscode-extension: + name: Build VS Code Extension + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/vscode-extension + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/vscode-extension/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Compile + run: npm run compile + + - name: Install vsce + run: npm install -g @vscode/vsce + + - name: Package .vsix + run: vsce package --no-dependencies + id: vsce + + - name: Upload VS Code extension (.vsix) + uses: actions/upload-artifact@v4 + with: + name: powermem-vscode-vsix + path: apps/vscode-extension/*.vsix + retention-days: 30 + + package-claude-plugin: + name: Package Claude Code Plugin + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create plugin zip + run: | + cd apps/claude-code-plugin + zip -r ../../powermem-claude-code-plugin.zip . -x "*.git*" -x ".DS_Store" + + - name: Upload Claude Code plugin (zip) + uses: actions/upload-artifact@v4 + with: + name: powermem-claude-code-plugin-zip + path: powermem-claude-code-plugin.zip + retention-days: 30 + release-plugins: + name: Release Plugin Assets + runs-on: ubuntu-latest + needs: [build-vscode-extension, package-claude-plugin] + if: startsWith(github.ref, 'refs/tags/plugins-') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true') + permissions: + contents: write + + steps: + - name: Download VS Code extension + uses: actions/download-artifact@v4 + with: + name: powermem-vscode-vsix + path: vsix + + - name: Download Claude Code plugin + uses: actions/download-artifact@v4 + with: + name: powermem-claude-code-plugin-zip + path: zip + + - name: Get version from tag or default + id: ver + run: | + if [[ "${{ github.ref }}" == refs/tags/plugins-* ]]; then + echo "version=${GITHUB_REF#refs/tags/plugins-}" >> $GITHUB_OUTPUT + else + echo "version=dev-$(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('plugins-{0}', steps.ver.outputs.version) }} + name: Plugins ${{ steps.ver.outputs.version }} + body: | + ## PowerMem IDE Plugins + + - **PowerMem for VS Code** (`.vsix`): Install in VS Code/Cursor via Install from VSIX, or publish to marketplace. + - **PowerMem Claude Code Plugin** (`.zip`): Use with `claude --plugin-dir /path/to/unzipped-folder`. + + See [apps/README.md](https://github.com/${{ github.repository }}/blob/main/apps/README.md) and [apps/TESTING.md](https://github.com/${{ github.repository }}/blob/main/apps/TESTING.md). + files: | + vsix/*.vsix + zip/powermem-claude-code-plugin.zip + draft: false + prerelease: ${{ contains(github.ref, 'refs/tags/') == false }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/plugins-publish-vscode.yml b/.github/workflows/plugins-publish-vscode.yml new file mode 100644 index 00000000..4433c5ea --- /dev/null +++ b/.github/workflows/plugins-publish-vscode.yml @@ -0,0 +1,64 @@ +# Publish VS Code extension to Visual Studio Marketplace / Open VSX (manual trigger only). +# Configure in repo Settings -> Secrets and variables -> Actions: +# - VSCE_PAT: Personal Access Token for Visual Studio Marketplace +# (https://dev.azure.com -> User settings -> Personal access tokens; scope must include Marketplace) +# - OVSX_PAT: Token for Open VSX (optional; create at https://open-vsx.org after sign-in) + +name: Publish VS Code Extension + +on: + workflow_dispatch: + inputs: + publish_vsce: + description: 'Publish to Visual Studio Marketplace' + required: false + default: true + type: boolean + publish_ovsx: + description: 'Publish to Open VSX' + required: false + default: false + type: boolean + +jobs: + publish: + name: Publish to Marketplace(s) + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: apps/vscode-extension/package-lock.json + + - name: Build .vsix + run: | + cd apps/vscode-extension + npm ci + npm run compile + npx @vscode/vsce package --no-dependencies + mkdir -p ../.vsix + cp *.vsix ../.vsix/ + + - name: Publish to Visual Studio Marketplace + if: github.event.inputs.publish_vsce == 'true' + env: + VSCE_TOKEN: ${{ secrets.VSCE_PAT }} + run: | + cd apps/vscode-extension + npx @vscode/vsce publish --pat $VSCE_TOKEN -i ../.vsix/*.vsix + + - name: Publish to Open VSX + if: github.event.inputs.publish_ovsx == 'true' + env: + OVSX_TOKEN: ${{ secrets.OVSX_PAT }} + run: | + cd apps/vscode-extension + npx @vscode/vsce publish --pat $OVSX_TOKEN -i ../.vsix/*.vsix --registryUrl https://open-vsx.org diff --git a/.gitignore b/.gitignore index 77aadf42..5421756b 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,8 @@ api_data/ server_backup/ *.backup +# apps/vscode-extension (Node / VS Code) +apps/vscode-extension/node_modules/ +apps/vscode-extension/out/ +apps/vscode-extension/*.vsix + diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 00000000..dd19bc82 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,16 @@ +# PowerMem IDE Apps + +## Contents + +| Directory | Description | +|-----------|--------------| +| **vscode-extension** | VS Code extension that links PowerMem to Cursor, Claude Code, Codex, Windsurf, and Copilot. Provides commands: Query memories, Add selection, Quick note, Link to AI tools, Setup, Dashboard. | +| **claude-code-plugin** | Claude Code–only plugin (`.claude-plugin` + `.mcp.json` + skills). Use with `claude --plugin-dir apps/claude-code-plugin` or publish to a Claude Code plugin marketplace. | + +## Quick start + +1. **Backend**: Start PowerMem (e.g. `powermem-server --port 8000` or `uvx powermem-mcp sse`). +2. **VS Code / Cursor**: Install the extension from `vscode-extension/` (Run and Debug or package as `.vsix`), set backend URL in PowerMem settings, then use **PowerMem: Link to AI tools**. +3. **Claude Code only**: Load the plugin with `claude --plugin-dir /path/to/powermem/apps/claude-code-plugin`. + +See each subdirectory’s `README.md` for details. diff --git a/apps/claude-code-plugin/.claude-plugin/plugin.json b/apps/claude-code-plugin/.claude-plugin/plugin.json new file mode 100644 index 00000000..5b3e287f --- /dev/null +++ b/apps/claude-code-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "memory-powermem", + "description": "PowerMem intelligent memory for Claude Code: add, search, update, and delete memories with Ebbinghaus decay and multi-agent support.", + "version": "1.0.0", + "author": { "name": "OceanBase / PowerMem" }, + "homepage": "https://github.com/oceanbase/powermem", + "repository": "https://github.com/oceanbase/powermem" +} diff --git a/apps/claude-code-plugin/.mcp.json b/apps/claude-code-plugin/.mcp.json new file mode 100644 index 00000000..c300597c --- /dev/null +++ b/apps/claude-code-plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "powermem": { + "transport": "http", + "url": "http://localhost:8000/mcp" + } + } +} diff --git a/apps/claude-code-plugin/CHANGELOG.md b/apps/claude-code-plugin/CHANGELOG.md new file mode 100644 index 00000000..39eb9741 --- /dev/null +++ b/apps/claude-code-plugin/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- Initial release: PowerMem MCP config and skills (remember, recall) for Claude Code. diff --git a/apps/claude-code-plugin/README.md b/apps/claude-code-plugin/README.md new file mode 100644 index 00000000..d22a1014 --- /dev/null +++ b/apps/claude-code-plugin/README.md @@ -0,0 +1,61 @@ +# PowerMem Plugin for Claude Code + +Claude Code plugin that connects to [PowerMem](https://github.com/oceanbase/powermem) for intelligent, persistent memory. + +## Features + +- **MCP integration**: Uses PowerMem MCP Server so Claude can call `add_memory`, `search_memories`, `get_memory_by_id`, `update_memory`, `delete_memory`, `list_memories`. +- **Skills**: `/memory-powermem:remember` and `/memory-powermem:recall` to guide when to store and when to search memories. + +## Prerequisites + +1. **PowerMem** installed and a running PowerMem backend: + - Either **MCP Server** (e.g. `uvx powermem-mcp sse` or `uvx powermem-mcp stdio`) with a `.env` in project or home directory. + - Or **HTTP API Server** (e.g. `powermem-server --host 0.0.0.0 --port 8000`). The plugin's default `.mcp.json` points to `http://localhost:8000/mcp` (MCP over HTTP). + +2. **Claude Code** (VS Code extension or CLI) with plugin support. + +## Installation + +### Option A: Load from directory (development) + +```bash +claude --plugin-dir /path/to/powermem/apps/claude-code-plugin +``` + +### Option B: Install from marketplace + +If this plugin is published to a Claude Code plugin marketplace, install it from there. + +## Configuration + +The default `.mcp.json` in this plugin uses: + +- **HTTP transport**: `http://localhost:8000/mcp` + +To use a different URL or **stdio** (local MCP process), edit `.mcp.json` in this directory. Example for stdio: + +```json +{ + "mcpServers": { + "powermem": { + "transport": "stdio", + "command": "uvx", + "args": ["powermem-mcp", "stdio"] + } + } +} +``` + +Ensure PowerMem is installed (`pip install powermem`) and a `.env` file is available when using stdio. + +## Usage + +- In Claude Code, the PowerMem MCP tools are available automatically once the plugin is loaded. +- Use **/memory-powermem:remember** when you want Claude to store something. +- Use **/memory-powermem:recall** when you want Claude to search memories before answering. + +## Links + +- [PowerMem](https://github.com/oceanbase/powermem) +- [PowerMem MCP docs](https://github.com/oceanbase/powermem/blob/master/docs/api/0004-mcp.md) diff --git a/apps/claude-code-plugin/skills/recall/SKILL.md b/apps/claude-code-plugin/skills/recall/SKILL.md new file mode 100644 index 00000000..0ecdffba --- /dev/null +++ b/apps/claude-code-plugin/skills/recall/SKILL.md @@ -0,0 +1,8 @@ +--- +description: Search PowerMem for relevant memories. Use before answering questions about the user, project, or past decisions. +--- + +Before answering questions about the user, project history, or past decisions: +1. Use the PowerMem `search_memories` tool with a short query. +2. Optionally filter by user_id or agent_id if the user has multiple contexts. +3. Use the retrieved memories to tailor the response. diff --git a/apps/claude-code-plugin/skills/remember/SKILL.md b/apps/claude-code-plugin/skills/remember/SKILL.md new file mode 100644 index 00000000..35246f04 --- /dev/null +++ b/apps/claude-code-plugin/skills/remember/SKILL.md @@ -0,0 +1,8 @@ +--- +description: Add or update a memory in PowerMem. Use when the user or conversation reveals a fact, preference, or decision that should be remembered across sessions. +--- + +When the user asks to remember something, or when the conversation contains a clear fact/preference/decision worth persisting: +1. Use the PowerMem `add_memory` tool with appropriate content and optional user_id/agent_id. +2. Prefer concise, factual memory content. +3. Confirm what was stored in one sentence. diff --git a/apps/vscode-extension/.vscodeignore b/apps/vscode-extension/.vscodeignore new file mode 100644 index 00000000..b5c3cefb --- /dev/null +++ b/apps/vscode-extension/.vscodeignore @@ -0,0 +1,6 @@ +.vscode/** +.vscode-test/** +src/** +tsconfig.json +**/*.map +node_modules/** diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md new file mode 100644 index 00000000..80beef5f --- /dev/null +++ b/apps/vscode-extension/README.md @@ -0,0 +1,64 @@ +# PowerMem for VS Code + +Give Cursor, Claude Code, Codex, Windsurf, and Copilot access to [PowerMem](https://github.com/oceanbase/powermem) intelligent memory with one click. + +## Features + +- **One-click link**: Auto-writes MCP or HTTP config for Cursor, Claude, Codex, Windsurf, and GitHub Copilot so they can use PowerMem. +- **Query memories**: Search your PowerMem from the editor (selection or query). +- **Add to memory**: Save selection or a quick note to PowerMem. +- **Dashboard**: Quick access to query, quick note, and setup. +- **Health check**: Status bar shows connection state; reconnect from the menu. + +## Requirements + +- A running **PowerMem** backend: + - **HTTP API + MCP**: `powermem-server --host 0.0.0.0 --port 8000` (default), or + - **MCP only**: e.g. `uvx powermem-mcp sse` (port 8000) or `uvx powermem-mcp stdio`. +- PowerMem is configured (e.g. `.env` next to the server or in project root). + +## Quick Start + +1. Install this extension in VS Code or Cursor. +2. Start your PowerMem backend (see above). +3. Click the **PowerMem** status bar item; if disconnected, run **Setup** and set **Backend URL** (e.g. `http://localhost:8000`). +4. Once connected, choose **Link to AI tools** to write configs for Cursor, Claude, Codex, Windsurf, and Copilot. +5. Use **Query memories** or **Add selection to memory** from the command palette or status bar menu. + +## Settings + +| Setting | Description | Default | +|--------|-------------|---------| +| `powermem.enabled` | Enable the extension | `true` | +| `powermem.backendUrl` | PowerMem backend URL | `http://localhost:8000` | +| `powermem.apiKey` | API key (X-API-Key) if required | (empty) | +| `powermem.useMCP` | Write MCP config for AI tools; if false, write HTTP where supported | `true` | +| `powermem.mcpServerPath` | Optional path/command for local MCP (e.g. `uvx`); empty = use backendUrl/mcp | (empty) | +| `powermem.userId` | User ID for memory scope; empty = auto-generated | (empty) | +| `powermem.projectName` | Project name; empty = workspace name | (empty) | + +## Commands + +- **PowerMem: Status Bar Menu** – Open the main menu (link, query, add, setup, etc.). +- **PowerMem: Query Memories** – Search PowerMem (uses selection or prompts for query). +- **PowerMem: Add Selection to Memory** – Save the current selection to PowerMem. +- **PowerMem: Quick Note** – Add a one-line note to PowerMem. +- **PowerMem: Link to AI Tools** – Write MCP/HTTP config for Cursor, Claude, Codex, Windsurf, Copilot. +- **PowerMem: Setup** – Change backend URL, API key, MCP path, test connection. +- **PowerMem: Dashboard** – Open the simple dashboard panel. + +## Where configs are written + +- **Cursor**: `~/.cursor/mcp.json` (merged with existing `mcpServers`). +- **Claude**: `~/.claude/providers/powermem.json`. +- **Codex**: `~/.codex/context.json` (merged). +- **Windsurf**: `~/.windsurf/context/powermem.json`. +- **Copilot**: `~/.github/copilot/powermem.json`. + +After linking, restart or reload the respective AI tool/IDE so it picks up the new config. + +## Links + +- [PowerMem](https://github.com/oceanbase/powermem) +- [PowerMem API](https://github.com/oceanbase/powermem/blob/master/docs/api/0005-api_server.md) +- [PowerMem MCP](https://github.com/oceanbase/powermem/blob/master/docs/api/0004-mcp.md) diff --git a/apps/vscode-extension/media/.gitkeep b/apps/vscode-extension/media/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json new file mode 100644 index 00000000..caafa4a9 --- /dev/null +++ b/apps/vscode-extension/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "powermem-vscode", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "powermem-vscode", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^18.x", + "@types/vscode": "^1.104.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.104.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json new file mode 100644 index 00000000..85d86951 --- /dev/null +++ b/apps/vscode-extension/package.json @@ -0,0 +1,74 @@ +{ + "name": "powermem-vscode", + "displayName": "PowerMem for VS Code", + "description": "PowerMem intelligent memory for AI assistants: connect Cursor, Claude Code, Codex, Windsurf to PowerMem with one click.", + "version": "1.0.0", + "publisher": "OceanBase", + "engines": { "vscode": "^1.104.0" }, + "categories": ["Machine Learning", "Other"], + "keywords": ["AI", "Memory", "PowerMem", "Cursor", "Claude", "Codex", "MCP"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { "command": "powermem.statusBarClick", "title": "PowerMem: Status Bar Menu" }, + { "command": "powermem.queryMemories", "title": "PowerMem: Query Memories" }, + { "command": "powermem.addSelectionToMemory", "title": "PowerMem: Add Selection to Memory" }, + { "command": "powermem.quickNote", "title": "PowerMem: Quick Note" }, + { "command": "powermem.linkToAITools", "title": "PowerMem: Link to AI Tools" }, + { "command": "powermem.setup", "title": "PowerMem: Setup" }, + { "command": "powermem.dashboard", "title": "PowerMem: Dashboard" } + ], + "configuration": { + "title": "PowerMem", + "properties": { + "powermem.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable PowerMem extension" + }, + "powermem.backendUrl": { + "type": "string", + "default": "http://localhost:8000", + "description": "PowerMem backend URL (HTTP API / MCP root)" + }, + "powermem.apiKey": { + "type": "string", + "default": "", + "description": "API key for PowerMem (X-API-Key header, leave empty if no auth)" + }, + "powermem.useMCP": { + "type": "boolean", + "default": true, + "description": "Write MCP config for AI tools; when false, write HTTP context endpoints where supported" + }, + "powermem.mcpServerPath": { + "type": "string", + "default": "", + "description": "Optional: path/command for local MCP (e.g. uvx). Leave empty to use backendUrl/mcp for HTTP MCP" + }, + "powermem.userId": { + "type": "string", + "default": "", + "description": "Custom user ID for memory scope (leave empty to auto-generate)" + }, + "powermem.projectName": { + "type": "string", + "default": "", + "description": "Custom project name (leave empty to use workspace name)" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts" + }, + "devDependencies": { + "@types/node": "^18.x", + "@types/vscode": "^1.104.0", + "typescript": "^5.3.0" + } +} diff --git a/apps/vscode-extension/src/api/client.ts b/apps/vscode-extension/src/api/client.ts new file mode 100644 index 00000000..af30a00f --- /dev/null +++ b/apps/vscode-extension/src/api/client.ts @@ -0,0 +1,80 @@ +/** + * PowerMem HTTP API client for extension commands (search, add memory). + * Base URL e.g. http://localhost:8000; endpoints: /api/v1/memories/search, /api/v1/memories + */ + +import type { + ApiResponse, + SearchRequest, + SearchResponseData, + MemoryCreateRequest, + MemoryCreateResponseDataItem, +} from './types'; + +function ensureNoTrailingSlash(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +function getHeaders(apiKey?: string): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiKey) headers['X-API-Key'] = apiKey; + return headers; +} + +export async function searchMemories( + baseUrl: string, + request: SearchRequest, + apiKey?: string +): Promise { + const url = `${ensureNoTrailingSlash(baseUrl)}/api/v1/memories/search`; + const res = await fetch(url, { + method: 'POST', + headers: getHeaders(apiKey), + body: JSON.stringify({ + query: request.query, + user_id: request.user_id ?? undefined, + agent_id: request.agent_id ?? undefined, + run_id: request.run_id ?? undefined, + limit: request.limit ?? 10, + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`PowerMem search failed: ${res.status} ${text}`); + } + const json = (await res.json()) as ApiResponse; + if (!json.success || !json.data) { + throw new Error(json.message || 'Search failed'); + } + return json.data; +} + +export async function addMemory( + baseUrl: string, + request: MemoryCreateRequest, + apiKey?: string +): Promise { + const url = `${ensureNoTrailingSlash(baseUrl)}/api/v1/memories`; + const res = await fetch(url, { + method: 'POST', + headers: getHeaders(apiKey), + body: JSON.stringify({ + content: request.content, + user_id: request.user_id ?? undefined, + agent_id: request.agent_id ?? undefined, + run_id: request.run_id ?? undefined, + metadata: request.metadata ?? undefined, + infer: request.infer ?? true, + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`PowerMem add memory failed: ${res.status} ${text}`); + } + const json = (await res.json()) as ApiResponse; + if (!json.success) { + throw new Error(json.message || 'Add memory failed'); + } + const data = json.data; + return Array.isArray(data) ? data : []; +} diff --git a/apps/vscode-extension/src/api/types.ts b/apps/vscode-extension/src/api/types.ts new file mode 100644 index 00000000..92d12303 --- /dev/null +++ b/apps/vscode-extension/src/api/types.ts @@ -0,0 +1,53 @@ +/** + * Types for PowerMem HTTP API (align with docs/api/0005-api_server.md) + */ + +export interface SearchResultItem { + memory_id: string; + content: string; + score?: number; + metadata?: Record; +} + +export interface SearchResponseData { + results: SearchResultItem[]; + total: number; + query: string; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + timestamp?: string; +} + +export interface MemoryCreateResponseDataItem { + memory_id: number; + content: string; + user_id?: string; + agent_id?: string; + run_id?: string; + metadata?: Record; +} + +export interface MemoryCreateResponseData { + data?: MemoryCreateResponseDataItem[]; +} + +export interface SearchRequest { + query: string; + user_id?: string; + agent_id?: string; + run_id?: string; + limit?: number; +} + +export interface MemoryCreateRequest { + content: string; + user_id?: string; + agent_id?: string; + run_id?: string; + metadata?: Record; + infer?: boolean; +} diff --git a/apps/vscode-extension/src/detectors/powermem.ts b/apps/vscode-extension/src/detectors/powermem.ts new file mode 100644 index 00000000..6f1140c4 --- /dev/null +++ b/apps/vscode-extension/src/detectors/powermem.ts @@ -0,0 +1,28 @@ +/** + * PowerMem backend detection (health + optional info) + */ + +export async function detectBackend(url: string): Promise { + const base = url.replace(/\/+$/, ''); + try { + const res = await fetch(`${base}/api/v1/system/health`, { + method: 'GET', + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } +} + +export async function getBackendInfo(url: string): Promise<{ status?: string } | null> { + try { + const base = url.replace(/\/+$/, ''); + const res = await fetch(`${base}/api/v1/system/health`); + if (!res.ok) return null; + const json = (await res.json()) as { data?: { status?: string } }; + return json?.data ? { status: json.data.status } : null; + } catch { + return null; + } +} diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts new file mode 100644 index 00000000..ba57535b --- /dev/null +++ b/apps/vscode-extension/src/extension.ts @@ -0,0 +1,310 @@ +import * as vscode from 'vscode'; +import { writeCursorConfig } from './writers/cursor'; +import { writeClaudeConfig } from './writers/claude'; +import { writeCodexConfig } from './writers/codex'; +import { writeWindsurfConfig } from './writers/windsurf'; +import { writeCopilotConfig } from './writers/copilot'; +import { DashboardPanel } from './panels/DashboardPanel'; +import { checkHealth } from './utils/health'; +import { searchMemories, addMemory } from './api/client'; +import type { SearchResultItem } from './api/types'; + +let backendUrl = 'http://localhost:8000'; +let apiKey: string | undefined; +let statusBar: vscode.StatusBarItem; +let useMCP = true; +let mcpServerPath = ''; +let isEnabled = true; +let userId = ''; + +function getUserId(context: vscode.ExtensionContext, config: vscode.WorkspaceConfiguration): string { + const configured = config.get('userId'); + if (configured) return configured; + let persisted = context.globalState.get('powermem.userId'); + if (persisted) return persisted; + const machineId = vscode.env.machineId; + const user = process.env.USERNAME || process.env.USER || 'user'; + persisted = `${user}-${machineId.substring(0, 8)}`; + context.globalState.update('powermem.userId', persisted); + return persisted; +} + +function updateStatusBar(state: 'active' | 'disconnected' | 'disabled'): void { + const icons = { + active: '$(database) PowerMem', + disconnected: '$(warning) PowerMem', + disabled: '$(circle-slash) PowerMem', + }; + statusBar.text = icons[state]; + statusBar.tooltip = state === 'active' ? 'PowerMem connected. Click for menu.' : state === 'disconnected' ? 'PowerMem disconnected. Click to setup.' : 'PowerMem disabled.'; +} + +async function autoLinkAll(): Promise { + try { + await writeCursorConfig(backendUrl, apiKey, useMCP, mcpServerPath || undefined); + await writeClaudeConfig(backendUrl, apiKey, useMCP, mcpServerPath || undefined); + await writeCodexConfig(backendUrl, apiKey, useMCP, mcpServerPath || undefined); + await writeWindsurfConfig(backendUrl, apiKey, useMCP, mcpServerPath || undefined); + await writeCopilotConfig(backendUrl, apiKey, useMCP, mcpServerPath || undefined); + vscode.window.showInformationMessage(`PowerMem linked to AI tools (${useMCP ? 'MCP' : 'HTTP'})`); + } catch (e) { + console.error('PowerMem auto-link failed:', e); + vscode.window.showErrorMessage(`PowerMem link failed: ${e}`); + } +} + +function formatMemories(results: SearchResultItem[]): string { + let out = '# PowerMem Search Results\n\n'; + if (results.length === 0) return out + 'No memories found.\n'; + for (const r of results) { + out += `## ${r.memory_id}\n**Score:** ${r.score ?? 'N/A'}\n${r.content}\n\n`; + } + return out; +} + +async function showMenu(): Promise { + if (!isEnabled) { + const choice = await vscode.window.showQuickPick( + [ + { label: '$(check) Enable PowerMem', action: 'enable' }, + { label: '$(gear) Setup', action: 'setup' }, + ], + { placeHolder: 'PowerMem is disabled' } + ); + if (!choice) return; + if (choice.action === 'enable') { + await vscode.workspace.getConfiguration('powermem').update('enabled', true, vscode.ConfigurationTarget.Global); + vscode.window.showInformationMessage('PowerMem enabled. Reload window to apply.'); + return; + } + if (choice.action === 'setup') { + await showSetup(); + } + return; + } + + const items = [ + { label: '$(link) Link to AI tools', action: 'link' }, + { label: '$(search) Query memories', action: 'query' }, + { label: '$(add) Add selection to memory', action: 'add' }, + { label: '$(pencil) Quick note', action: 'note' }, + { label: '$(dashboard) Dashboard', action: 'dashboard' }, + { label: useMCP ? '$(server-process) Switch to HTTP' : '$(link) Switch to MCP', action: 'toggleMcp' }, + { label: '$(gear) Setup', action: 'setup' }, + { label: '$(refresh) Reconnect', action: 'reconnect' }, + { label: '$(circle-slash) Disable', action: 'disable' }, + ]; + const choice = await vscode.window.showQuickPick(items, { placeHolder: 'PowerMem' }); + if (!choice) return; + switch (choice.action) { + case 'link': + await autoLinkAll(); + break; + case 'query': + vscode.commands.executeCommand('powermem.queryMemories'); + break; + case 'add': + vscode.commands.executeCommand('powermem.addSelectionToMemory'); + break; + case 'note': + vscode.commands.executeCommand('powermem.quickNote'); + break; + case 'dashboard': + vscode.commands.executeCommand('powermem.dashboard'); + break; + case 'toggleMcp': + useMCP = !useMCP; + await vscode.workspace.getConfiguration('powermem').update('useMCP', useMCP, vscode.ConfigurationTarget.Global); + await autoLinkAll(); + break; + case 'setup': + await showSetup(); + break; + case 'reconnect': + if (await checkHealth(backendUrl)) { + await autoLinkAll(); + updateStatusBar('active'); + vscode.window.showInformationMessage('PowerMem reconnected.'); + } else { + updateStatusBar('disconnected'); + vscode.window.showErrorMessage('Cannot connect to PowerMem backend.'); + } + break; + case 'disable': + await vscode.workspace.getConfiguration('powermem').update('enabled', false, vscode.ConfigurationTarget.Global); + isEnabled = false; + updateStatusBar('disabled'); + vscode.window.showInformationMessage('PowerMem disabled.'); + break; + } +} + +async function showSetup(): Promise { + const config = vscode.workspace.getConfiguration('powermem'); + const items = [ + { label: '$(server) Change backend URL', action: 'url', description: backendUrl }, + { label: '$(key) Set API key', action: 'apikey' }, + { label: '$(file-code) Set MCP server path', action: 'mcppath', description: mcpServerPath || '(use backend URL /mcp)' }, + { label: '$(debug-restart) Test connection', action: 'test' }, + { label: isEnabled ? '$(circle-slash) Disable' : '$(check) Enable', action: 'toggleEnabled' }, + ]; + const choice = await vscode.window.showQuickPick(items, { placeHolder: 'PowerMem Setup' }); + if (!choice) return; + switch (choice.action) { + case 'url': { + const url = await vscode.window.showInputBox({ prompt: 'PowerMem backend URL', value: backendUrl, placeHolder: 'http://localhost:8000' }); + if (url) { + await config.update('backendUrl', url, vscode.ConfigurationTarget.Global); + backendUrl = url; + if (await checkHealth(backendUrl)) { + await autoLinkAll(); + updateStatusBar('active'); + } + vscode.window.showInformationMessage('Backend URL updated.'); + } + break; + } + case 'apikey': { + const key = await vscode.window.showInputBox({ prompt: 'API key (empty if none)', password: true, value: apiKey ?? '' }); + await config.update('apiKey', key ?? '', vscode.ConfigurationTarget.Global); + apiKey = key || undefined; + vscode.window.showInformationMessage('API key saved.'); + break; + } + case 'mcppath': { + const path = await vscode.window.showInputBox({ prompt: 'MCP server path/command (empty = use URL/mcp)', value: mcpServerPath, placeHolder: 'uvx' }); + await config.update('mcpServerPath', path ?? '', vscode.ConfigurationTarget.Global); + mcpServerPath = path ?? ''; + vscode.window.showInformationMessage('MCP path updated.'); + break; + } + case 'test': + if (await checkHealth(backendUrl)) { + vscode.window.showInformationMessage('PowerMem connection OK.'); + updateStatusBar('active'); + } else { + vscode.window.showErrorMessage('PowerMem connection failed.'); + updateStatusBar('disconnected'); + } + break; + case 'toggleEnabled': + isEnabled = !isEnabled; + await config.update('enabled', isEnabled, vscode.ConfigurationTarget.Global); + if (isEnabled) { + if (await checkHealth(backendUrl)) { + await autoLinkAll(); + updateStatusBar('active'); + } else updateStatusBar('disconnected'); + } else updateStatusBar('disabled'); + vscode.window.showInformationMessage(isEnabled ? 'PowerMem enabled.' : 'PowerMem disabled.'); + break; + } +} + +export function activate(context: vscode.ExtensionContext): void { + const config = vscode.workspace.getConfiguration('powermem'); + isEnabled = config.get('enabled') ?? true; + backendUrl = config.get('backendUrl') || 'http://localhost:8000'; + apiKey = config.get('apiKey') || undefined; + useMCP = config.get('useMCP') ?? true; + mcpServerPath = config.get('mcpServerPath') || ''; + userId = getUserId(context, config); + + statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + statusBar.command = 'powermem.statusBarClick'; + context.subscriptions.push(statusBar); + + context.subscriptions.push( + vscode.commands.registerCommand('powermem.statusBarClick', () => showMenu()) + ); + + if (!isEnabled) { + updateStatusBar('disabled'); + statusBar.show(); + return; + } + + updateStatusBar('disconnected'); + statusBar.show(); + + checkHealth(backendUrl).then(async (connected) => { + if (connected) { + await autoLinkAll(); + updateStatusBar('active'); + } else { + updateStatusBar('disconnected'); + } + }); + + context.subscriptions.push( + vscode.commands.registerCommand('powermem.queryMemories', async () => { + const editor = vscode.window.activeTextEditor; + const query = editor ? (editor.document.getText(editor.selection) || editor.document.getText()).trim() : ''; + const input = await vscode.window.showInputBox({ prompt: 'Search query', placeHolder: 'e.g. user preferences' }); + const q = query || (input ?? ''); + if (!q) return; + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'PowerMem: Searching...', cancellable: false }, + async () => { + try { + const data = await searchMemories(backendUrl, { query: q, user_id: userId || undefined, limit: 10 }, apiKey); + const doc = await vscode.workspace.openTextDocument({ content: formatMemories(data.results), language: 'markdown' }); + await vscode.window.showTextDocument(doc); + } catch (e) { + vscode.window.showErrorMessage(`PowerMem search failed: ${e}`); + } + } + ); + }), + vscode.commands.registerCommand('powermem.addSelectionToMemory', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage('No active editor'); + return; + } + const selection = editor.document.getText(editor.selection); + if (!selection.trim()) { + vscode.window.showErrorMessage('Select text to add to memory'); + return; + } + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'PowerMem: Saving...', cancellable: false }, + async () => { + try { + await addMemory(backendUrl, { content: selection, user_id: userId || undefined, metadata: { source: 'vscode', file: editor.document.uri.fsPath } }, apiKey); + vscode.window.showInformationMessage('Selection added to PowerMem'); + } catch (e) { + vscode.window.showErrorMessage(`PowerMem add failed: ${e}`); + } + } + ); + }), + vscode.commands.registerCommand('powermem.quickNote', async () => { + const input = await vscode.window.showInputBox({ prompt: 'Quick note to remember', placeHolder: 'e.g. Use pnpm for this project' }); + if (!input?.trim()) return; + try { + await addMemory(backendUrl, { content: input.trim(), user_id: userId || undefined, metadata: { source: 'vscode', type: 'quick-note' } }, apiKey); + vscode.window.showInformationMessage('Note added to PowerMem'); + } catch (e) { + vscode.window.showErrorMessage(`PowerMem add failed: ${e}`); + } + }), + vscode.commands.registerCommand('powermem.linkToAITools', () => autoLinkAll()), + vscode.commands.registerCommand('powermem.setup', () => showSetup()), + vscode.commands.registerCommand('powermem.dashboard', () => DashboardPanel.createOrShow(context.extensionUri)) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration('powermem')) return; + const c = vscode.workspace.getConfiguration('powermem'); + backendUrl = c.get('backendUrl') || 'http://localhost:8000'; + apiKey = c.get('apiKey') || undefined; + useMCP = c.get('useMCP') ?? true; + mcpServerPath = c.get('mcpServerPath') || ''; + isEnabled = c.get('enabled') ?? true; + }) + ); +} + +export function deactivate(): void {} diff --git a/apps/vscode-extension/src/panels/DashboardPanel.ts b/apps/vscode-extension/src/panels/DashboardPanel.ts new file mode 100644 index 00000000..907317d9 --- /dev/null +++ b/apps/vscode-extension/src/panels/DashboardPanel.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +export class DashboardPanel { + public static currentPanel: DashboardPanel | undefined; + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + + public static createOrShow(extensionUri: vscode.Uri): void { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + if (DashboardPanel.currentPanel) { + DashboardPanel.currentPanel._panel.reveal(column); + return; + } + const panel = vscode.window.createWebviewPanel( + 'powermemDashboard', + 'PowerMem Dashboard', + column, + { enableScripts: true, localResourceRoots: [vscode.Uri.file(path.join(extensionUri.fsPath, 'media'))] } + ); + DashboardPanel.currentPanel = new DashboardPanel(panel, extensionUri); + } + + private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { + this._panel = panel; + this._extensionUri = extensionUri; + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + this._panel.webview.html = this.getHtml(); + this._panel.webview.onDidReceiveMessage( + (msg: { command: string }) => { + switch (msg.command) { + case 'quickNote': + vscode.commands.executeCommand('powermem.quickNote'); + break; + case 'query': + vscode.commands.executeCommand('powermem.queryMemories'); + break; + case 'settings': + vscode.commands.executeCommand('powermem.setup'); + break; + } + }, + null, + this._disposables + ); + } + + public dispose(): void { + DashboardPanel.currentPanel = undefined; + this._panel.dispose(); + this._disposables.forEach((d) => d.dispose()); + } + + private getHtml(): string { + return ` + + + + + PowerMem + + + +

PowerMem

+

Intelligent memory for AI assistants. Use the commands below or the status bar.

+ + + + + +`; + } +} diff --git a/apps/vscode-extension/src/utils/health.ts b/apps/vscode-extension/src/utils/health.ts new file mode 100644 index 00000000..690f47a4 --- /dev/null +++ b/apps/vscode-extension/src/utils/health.ts @@ -0,0 +1,16 @@ +/** + * PowerMem backend health check: GET /api/v1/system/health (no auth required) + */ + +export async function checkHealth(baseUrl: string, timeoutMs = 5000): Promise { + const url = baseUrl.replace(/\/+$/, '') + '/api/v1/system/health'; + try { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + const res = await fetch(url, { method: 'GET', signal: controller.signal }); + clearTimeout(id); + return res.ok; + } catch { + return false; + } +} diff --git a/apps/vscode-extension/src/writers/claude.ts b/apps/vscode-extension/src/writers/claude.ts new file mode 100644 index 00000000..a01b32ab --- /dev/null +++ b/apps/vscode-extension/src/writers/claude.ts @@ -0,0 +1,62 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface ClaudeConfig { + mcpServers?: { + powermem?: { + command?: string; + args?: string[]; + env?: Record; + url?: string; + }; + }; +} + +export function generateClaudeConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): ClaudeConfig { + const base = backendUrl.replace(/\/+$/, ''); + if (useMCP) { + if (mcpServerPath) { + return { + mcpServers: { + powermem: { + command: 'uvx', + args: ['powermem-mcp', 'stdio'], + env: apiKey ? { POWERMEM_API_KEY: apiKey } : undefined, + }, + }, + }; + } + return { + mcpServers: { + powermem: { url: `${base}/mcp` }, + }, + }; + } + return { + mcpServers: { + powermem: { url: `${base}/mcp` }, + }, + }; +} + +export async function writeClaudeConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): Promise { + const claudeDir = path.join(os.homedir(), '.claude', 'providers'); + const configFile = path.join(claudeDir, 'powermem.json'); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true }); + } + const config = generateClaudeConfig(backendUrl, apiKey, useMCP, mcpServerPath); + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); + return configFile; +} diff --git a/apps/vscode-extension/src/writers/codex.ts b/apps/vscode-extension/src/writers/codex.ts new file mode 100644 index 00000000..8703f415 --- /dev/null +++ b/apps/vscode-extension/src/writers/codex.ts @@ -0,0 +1,75 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface CodexConfig { + contextProviders?: Record; + mcpServers?: { + powermem?: { + url?: string; + command?: string; + args?: string[]; + env?: Record; + }; + }; +} + +export function generateCodexConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): CodexConfig { + const base = backendUrl.replace(/\/+$/, ''); + if (useMCP) { + const config: CodexConfig = { + mcpServers: { + powermem: mcpServerPath + ? { command: 'uvx', args: ['powermem-mcp', 'stdio'], env: apiKey ? { POWERMEM_API_KEY: apiKey } : undefined } + : { url: `${base}/mcp` }, + }, + }; + return config; + } + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiKey) headers['X-API-Key'] = apiKey; + return { + contextProviders: { + powermem: { + enabled: true, + endpoint: `${base}/api/v1/memories/search`, + method: 'POST', + headers, + queryField: 'query', + }, + }, + }; +} + +export async function writeCodexConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): Promise { + const codexDir = path.join(os.homedir(), '.codex'); + const configFile = path.join(codexDir, 'context.json'); + if (!fs.existsSync(codexDir)) { + fs.mkdirSync(codexDir, { recursive: true }); + } + let existing: CodexConfig = {}; + if (fs.existsSync(configFile)) { + try { + existing = JSON.parse(fs.readFileSync(configFile, 'utf8')) as CodexConfig; + } catch { + // ignore + } + } + const generated = generateCodexConfig(backendUrl, apiKey, useMCP, mcpServerPath); + const merged: CodexConfig = { + contextProviders: { ...existing.contextProviders, ...generated.contextProviders }, + mcpServers: { ...existing.mcpServers, ...generated.mcpServers }, + }; + fs.writeFileSync(configFile, JSON.stringify(merged, null, 2)); + return configFile; +} diff --git a/apps/vscode-extension/src/writers/copilot.ts b/apps/vscode-extension/src/writers/copilot.ts new file mode 100644 index 00000000..b1821006 --- /dev/null +++ b/apps/vscode-extension/src/writers/copilot.ts @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface CopilotConfig { + name: string; + type: string; + endpoint?: string; + authentication?: { type: string; header: string }; + mcpServer?: { command: string; args: string[]; env?: Record }; + url?: string; +} + +export function generateCopilotConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): CopilotConfig { + const base = backendUrl.replace(/\/+$/, ''); + if (useMCP) { + const config: CopilotConfig = { + name: 'PowerMem', + type: 'mcp', + mcpServer: { command: 'uvx', args: ['powermem-mcp', 'stdio'] }, + }; + if (mcpServerPath) { + config.mcpServer = { command: 'uvx', args: ['powermem-mcp', 'stdio'], env: apiKey ? { POWERMEM_API_KEY: apiKey } : undefined }; + } else { + (config as CopilotConfig & { url: string }).url = `${base}/mcp`; + } + return config; + } + const c: CopilotConfig = { + name: 'PowerMem', + type: 'context_provider', + endpoint: `${base}/api/v1/memories/search`, + }; + if (apiKey) c.authentication = { type: 'header', header: `X-API-Key: ${apiKey}` }; + return c; +} + +export async function writeCopilotConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): Promise { + const copilotDir = path.join(os.homedir(), '.github', 'copilot'); + const configFile = path.join(copilotDir, 'powermem.json'); + if (!fs.existsSync(copilotDir)) { + fs.mkdirSync(copilotDir, { recursive: true }); + } + const config = generateCopilotConfig(backendUrl, apiKey, useMCP, mcpServerPath); + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); + return configFile; +} diff --git a/apps/vscode-extension/src/writers/cursor.ts b/apps/vscode-extension/src/writers/cursor.ts new file mode 100644 index 00000000..ffa90308 --- /dev/null +++ b/apps/vscode-extension/src/writers/cursor.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** Cursor: ~/.cursor/mcp.json (global) or project .cursor/mcp.json. We write global. */ +export interface CursorMcpConfig { + mcpServers?: { + powermem?: { + url?: string; + command?: string; + args?: string[]; + env?: Record; + }; + }; +} + +export function generateCursorConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): CursorMcpConfig { + const base = backendUrl.replace(/\/+$/, ''); + if (useMCP) { + if (mcpServerPath) { + return { + mcpServers: { + powermem: { + command: 'uvx', + args: ['powermem-mcp', 'stdio'], + env: apiKey ? { POWERMEM_API_KEY: apiKey } : undefined, + }, + }, + }; + } + return { + mcpServers: { + powermem: { url: `${base}/mcp` }, + }, + }; + } + return { + mcpServers: { + powermem: { url: `${base}/mcp` }, + }, + }; +} + +export async function writeCursorConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): Promise { + const cursorDir = path.join(os.homedir(), '.cursor'); + const configFile = path.join(cursorDir, 'mcp.json'); + if (!fs.existsSync(cursorDir)) { + fs.mkdirSync(cursorDir, { recursive: true }); + } + let existing: CursorMcpConfig = {}; + if (fs.existsSync(configFile)) { + try { + existing = JSON.parse(fs.readFileSync(configFile, 'utf8')) as CursorMcpConfig; + } catch { + // ignore + } + } + const generated = generateCursorConfig(backendUrl, apiKey, useMCP, mcpServerPath); + const merged: CursorMcpConfig = { + mcpServers: { ...existing.mcpServers, ...generated.mcpServers }, + }; + fs.writeFileSync(configFile, JSON.stringify(merged, null, 2)); + return configFile; +} diff --git a/apps/vscode-extension/src/writers/windsurf.ts b/apps/vscode-extension/src/writers/windsurf.ts new file mode 100644 index 00000000..d075f3c3 --- /dev/null +++ b/apps/vscode-extension/src/writers/windsurf.ts @@ -0,0 +1,44 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface WindsurfConfig { + contextProvider?: string; + api?: string; + apiKey?: string; + mcp?: { configPath?: string; url?: string }; +} + +export function generateWindsurfConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): WindsurfConfig { + const base = backendUrl.replace(/\/+$/, ''); + if (useMCP) { + if (mcpServerPath) { + return { contextProvider: 'powermem-mcp', mcp: { configPath: mcpServerPath } }; + } + return { contextProvider: 'powermem-mcp', mcp: { url: `${base}/mcp` } }; + } + const config: WindsurfConfig = { contextProvider: 'powermem', api: `${base}/api/v1/memories/search` }; + if (apiKey) config.apiKey = apiKey; + return config; +} + +export async function writeWindsurfConfig( + backendUrl: string, + apiKey?: string, + useMCP = true, + mcpServerPath?: string +): Promise { + const windsurfDir = path.join(os.homedir(), '.windsurf', 'context'); + const configFile = path.join(windsurfDir, 'powermem.json'); + if (!fs.existsSync(windsurfDir)) { + fs.mkdirSync(windsurfDir, { recursive: true }); + } + const config = generateWindsurfConfig(backendUrl, apiKey, useMCP, mcpServerPath); + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); + return configFile; +} diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json new file mode 100644 index 00000000..ab7159e9 --- /dev/null +++ b/apps/vscode-extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "out", + "lib": ["ES2020"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", ".vscode-test"] +} From 0ec203eb42f8b3462fd3b5bc07ac11457cd3dd45 Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 19 Mar 2026 17:54:52 +0800 Subject: [PATCH 03/10] feat(vscode-extension): connection mode, chat participant, auto memory --- apps/vscode-extension/package.json | 76 ++++- apps/vscode-extension/src/chat/participant.ts | 262 ++++++++++++++++++ apps/vscode-extension/src/extension.ts | 86 +++++- apps/vscode-extension/src/writers/claude.ts | 7 +- apps/vscode-extension/src/writers/cursor.ts | 10 +- 5 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 apps/vscode-extension/src/chat/participant.ts diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 85d86951..5bd318c6 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "powermem-vscode", "displayName": "PowerMem for VS Code", "description": "PowerMem intelligent memory for AI assistants: connect Cursor, Claude Code, Codex, Windsurf to PowerMem with one click.", - "version": "1.0.0", + "version": "0.1.0", "publisher": "OceanBase", "engines": { "vscode": "^1.104.0" }, "categories": ["Machine Learning", "Other"], @@ -10,6 +10,31 @@ "activationEvents": ["onStartupFinished"], "main": "./out/extension.js", "contributes": { + "chatParticipants": [ + { + "id": "powermem-vscode.powermem", + "name": "powermem", + "fullName": "PowerMem", + "description": "Save chat to memory, search memories. Say remember or /save to save; /search to query.", + "isSticky": false, + "commands": [ + { "name": "remember", "description": "Save this conversation to PowerMem" }, + { "name": "save", "description": "Save current conversation to PowerMem" }, + { "name": "search", "description": "Search PowerMem" } + ], + "disambiguation": [ + { + "category": "memory", + "description": "The user wants to save something to long-term memory (PowerMem) or search their saved memories.", + "examples": [ + "Remember this", + "Save to memory: we use pnpm in this project", + "Search user login flow" + ] + } + ] + } + ], "commands": [ { "command": "powermem.statusBarClick", "title": "PowerMem: Status Bar Menu" }, { "command": "powermem.queryMemories", "title": "PowerMem: Query Memories" }, @@ -25,37 +50,72 @@ "powermem.enabled": { "type": "boolean", "default": true, - "description": "Enable or disable PowerMem extension" + "description": "Enable or disable PowerMem extension." + }, + "powermem.connectionMode": { + "type": "string", + "enum": ["http", "mcp"], + "default": "mcp", + "enumDescriptions": [ + "HTTP mode: AI tools fetch memories via HTTP context endpoints. Use when MCP is not available or you prefer a simpler setup.", + "MCP mode: AI tools connect via MCP (Model Context Protocol). Recommended for Cursor, Claude Code, etc. You can use remote MCP (backend URL /mcp) or local MCP (set MCP Server Path)." + ], + "description": "Connection mode: HTTP or MCP. Determines how AI tools (Cursor, Claude Code, etc.) connect to PowerMem." }, "powermem.backendUrl": { "type": "string", "default": "http://localhost:8000", - "description": "PowerMem backend URL (HTTP API / MCP root)" + "description": "PowerMem server address. HTTP mode: used as the API base URL. MCP mode: used as the MCP root (e.g. {backendUrl}/mcp if MCP Server Path is empty)." }, "powermem.apiKey": { "type": "string", "default": "", - "description": "API key for PowerMem (X-API-Key header, leave empty if no auth)" + "description": "API key for PowerMem (X-API-Key header). Leave empty if your server does not require auth." }, "powermem.useMCP": { "type": "boolean", "default": true, - "description": "Write MCP config for AI tools; when false, write HTTP context endpoints where supported" + "description": "Deprecated: use \"Connection Mode\" instead. When false, same as HTTP mode; when true, same as MCP mode." }, "powermem.mcpServerPath": { "type": "string", "default": "", - "description": "Optional: path/command for local MCP (e.g. uvx). Leave empty to use backendUrl/mcp for HTTP MCP" + "description": "MCP mode only. Leave empty to use remote MCP at Backend URL + /mcp. Set a path/command (e.g. uvx) to use a local MCP server process instead." }, "powermem.userId": { "type": "string", "default": "", - "description": "Custom user ID for memory scope (leave empty to auto-generate)" + "description": "Custom user ID for memory scope. Leave empty to auto-generate." }, "powermem.projectName": { "type": "string", "default": "", - "description": "Custom project name (leave empty to use workspace name)" + "description": "Custom project name. Leave empty to use the workspace name." + }, + "powermem.autoCapture.onSave": { + "type": "boolean", + "default": false, + "description": "When enabled, automatically add file content to memory on save (seamless write). Respects 'Include pattern' and 'Max chars'." + }, + "powermem.autoCapture.include": { + "type": "string", + "default": "**/*.md,**/*.txt,**/docs/**", + "description": "Glob pattern for which files to auto-capture on save (comma-separated). Example: **/*.md,**/docs/**" + }, + "powermem.autoCapture.maxChars": { + "type": "number", + "default": 8000, + "description": "Max characters per file to add to memory on auto-capture (avoids huge payloads)." + }, + "powermem.chat.autoSummarizeEveryNTurns": { + "type": "number", + "default": 10, + "description": "In @powermem chat: auto-summarize and save to memory every N conversation turns (0 = off). Seamless write." + }, + "powermem.chat.autoRetrieve": { + "type": "boolean", + "default": true, + "description": "In @powermem chat: always retrieve relevant memories before answering. Seamless retrieval." } } } diff --git a/apps/vscode-extension/src/chat/participant.ts b/apps/vscode-extension/src/chat/participant.ts new file mode 100644 index 00000000..54c0eec8 --- /dev/null +++ b/apps/vscode-extension/src/chat/participant.ts @@ -0,0 +1,262 @@ +/** + * Chat participant @powermem: seamless memory write (auto-summarize every N turns) + * and retrieval (auto-retrieve on every question, answer with LLM + memories). + */ + +import * as vscode from 'vscode'; +import { addMemory, searchMemories } from '../api/client'; +import type { SearchResultItem } from '../api/types'; + +const MAX_CHAT_MEMORY_CHARS = 12000; +const SUMMARY_PROMPT = + 'Summarize the following conversation into concise bullet points for long-term memory. ' + + 'Keep only important facts, decisions, and context. Output only the summary, same language as the conversation.'; + +function isSaveIntent(prompt: string, command?: string): boolean { + const t = prompt.trim().toLowerCase(); + if (command === 'remember' || command === 'save' || command === '\u4fdd\u5b58') return true; + if (/^(remember|save)(\s|$)/i.test(t)) return true; + if (/^[\u8bb0\u4f4f\u4fdd\u5b58](\s|$)/.test(t)) return true; + if (/^(\u628a)?(\u4e0a\u9762)?(\u8fd9\u6bb5)?(\u5bf9\u8bdd)?(\u8bb0\u4e0b\u6765|\u5b58\u5230?\u8bb0\u5fc6|\u4fdd\u5b58\u5230?powermem)/.test(t)) return true; + return false; +} + +function isSearchIntent(prompt: string, command?: string): boolean { + const t = prompt.trim().toLowerCase(); + if (command === 'search' || command === '\u641c\u7d22') return true; // \u641c\u7d22 = search (localized) + if (/^(search|query)\s/i.test(t)) return true; + if (/^[\u641c\u7d22\u67e5\u8be2]\s/.test(t)) return true; + return false; +} + +function responseTurnToText(turn: vscode.ChatResponseTurn): string { + const parts: string[] = []; + for (const part of turn.response) { + if (part instanceof vscode.ChatResponseMarkdownPart) { + const v = part.value; + parts.push(typeof v === 'string' ? v : (v as { value?: string }).value ?? ''); + } + } + return parts.join('\n').trim(); +} + +function historyToText(history: ReadonlyArray): string { + const lines: string[] = []; + for (const turn of history) { + if (turn instanceof vscode.ChatRequestTurn) { + lines.push(`[User] ${turn.prompt}`); + } else { + const text = responseTurnToText(turn); + if (text) lines.push(`[Assistant] ${text}`); + } + } + return lines.join('\n\n'); +} + +function formatMemoriesForPrompt(results: SearchResultItem[]): string { + if (results.length === 0) return ''; + return results.map((r, i) => `[${i + 1}] ${(r.content ?? '').trim().slice(0, 500)}`).join('\n\n'); +} + +async function summarizeWithModel( + model: vscode.LanguageModelChat, + conversationText: string, + token: vscode.CancellationToken +): Promise { + const truncated = + conversationText.length > MAX_CHAT_MEMORY_CHARS + ? conversationText.slice(0, MAX_CHAT_MEMORY_CHARS) + '\n…' + : conversationText; + const messages = [ + vscode.LanguageModelChatMessage.User(SUMMARY_PROMPT + '\n\n---\n\n' + truncated), + ]; + const response = await model.sendRequest(messages, {}, token); + let out = ''; + for await (const chunk of response.text) { + out += chunk; + } + return out.trim(); +} + +export function registerChatParticipant( + context: vscode.ExtensionContext, + getBackendUrl: () => string, + getApiKey: () => string | undefined, + getUserId: () => string, + getEnabled: () => boolean, + getChatAutoSummarizeTurns: () => number, + getChatAutoRetrieve: () => boolean +): void { + if (typeof vscode.chat?.createChatParticipant !== 'function') return; + + const handler: vscode.ChatRequestHandler = async ( + request: vscode.ChatRequest, + chatContext: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ): Promise => { + const enabled = getEnabled(); + const backendUrl = getBackendUrl(); + if (!enabled || !backendUrl) { + stream.markdown('PowerMem is disabled or not configured. Enable the extension and set Backend URL in settings.'); + return; + } + + const apiKey = getApiKey(); + const userId = getUserId(); + const prompt = request.prompt.trim(); + const command = request.command; + const autoSummarizeTurns = getChatAutoSummarizeTurns(); + const autoRetrieve = getChatAutoRetrieve(); + + // ——— Explicit save ——— + if (isSaveIntent(prompt, command)) { + const historyText = historyToText(chatContext.history); + let toSave = historyText || prompt || '[Empty conversation]'; + if (!historyText && prompt) { + const stripped = prompt.replace(/^(\u8bb0\u4f4f|\u4fdd\u5b58|remember|save)\s*[:\uFF1A]\s*/i, '').trim(); + if (stripped) toSave = stripped; + } + const content = + toSave.length > MAX_CHAT_MEMORY_CHARS ? toSave.slice(0, MAX_CHAT_MEMORY_CHARS) + '\n…' : toSave; + try { + await addMemory( + backendUrl, + { + content, + user_id: userId || undefined, + metadata: { source: 'vscode-chat', type: 'chat-history' }, + }, + apiKey + ); + stream.markdown('Saved to PowerMem.'); + } catch (e) { + stream.markdown(`Save failed: ${e}`); + } + return; + } + + // ——— Explicit search ——— + if (isSearchIntent(prompt, command)) { + const query = prompt.replace(/^(\u641c\u7d22|\u67e5\u8be2|search|query)\s*/gi, '').trim() || prompt; + if (!query) { + stream.markdown('Enter a search query, e.g. `/search login flow`'); + return; + } + stream.progress('Searching…'); + try { + const data = await searchMemories( + backendUrl, + { query, user_id: userId || undefined, limit: 8 }, + apiKey + ); + const results = data?.results ?? []; + if (results.length === 0) { + stream.markdown('No related memories found.'); + return; + } + let out = '**PowerMem search results**\n\n'; + for (const r of results) { + const score = r.score != null ? ` (relevance: ${r.score})` : ''; + out += `- ${(r.content ?? '').slice(0, 300)}${(r.content?.length ?? 0) > 300 ? '…' : ''}${score}\n\n`; + } + stream.markdown(out); + } catch (e) { + stream.markdown(`Search failed: ${e}`); + } + return; + } + + // ——— General question: auto-summarize (background) + auto-retrieve + answer with LLM ——— + const history = chatContext.history; + const model = request.model; + const canUseModel = + model && (context.languageModelAccessInformation?.canSendRequest?.(model) === true); + + // 1) Auto-summarize every N turns (fire-and-forget) + if (autoSummarizeTurns >= 2 && history.length >= autoSummarizeTurns - 1 && canUseModel) { + const start = Math.max(0, history.length - (autoSummarizeTurns - 1)); + const slice = history.slice(start); + const conversationText = historyToText(slice) + '\n\n[User] ' + prompt; + summarizeWithModel(model, conversationText, token) + .then((summary) => { + if (!summary || token.isCancellationRequested) return; + return addMemory( + backendUrl, + { + content: summary, + user_id: userId || undefined, + metadata: { source: 'vscode-chat', type: 'chat-summary' }, + }, + apiKey + ); + }) + .catch(() => {}); + } + + // 2) Auto-retrieve relevant memories + let memoriesText = ''; + if (autoRetrieve && prompt) { + try { + const data = await searchMemories( + backendUrl, + { query: prompt, user_id: userId || undefined, limit: 6 }, + apiKey + ); + const results = data?.results ?? []; + memoriesText = formatMemoriesForPrompt(results); + } catch { + // ignore + } + } + + // 3) Answer with LLM + memories, or show memories only + if (canUseModel && model) { + const systemContent = + memoriesText.length > 0 + ? `Relevant memories (use when helpful):\n\n${memoriesText}\n\nAnswer the user's question below, using these memories when relevant.` + : 'Answer the user\'s question concisely.'; + const messages = [ + vscode.LanguageModelChatMessage.User(systemContent), + vscode.LanguageModelChatMessage.User(prompt), + ]; + try { + const response = await model.sendRequest(messages, {}, token); + for await (const chunk of response.text) { + if (token.isCancellationRequested) break; + stream.markdown(chunk); + } + } catch (e) { + if (memoriesText) { + stream.markdown('**Relevant memories**\n\n' + memoriesText + '\n\n---\n\n*Model request failed: ' + String(e) + '*'); + } else { + stream.markdown('Model request failed: ' + String(e)); + } + } + return; + } + + // No model: show memories if any, else short help + if (memoriesText) { + stream.markdown('**Relevant memories**\n\n' + memoriesText + '\n\n---\n\nSelect a chat model above to get answers using these memories.'); + } else { + stream.markdown( + '**PowerMem** auto memory and retrieval are on. After you select a chat model:\n' + + '- Every N turns are summarized and saved to memory automatically.\n' + + '- Each answer uses retrieved memories when relevant.\n\n' + + 'You can also use **remember** / **/save** to save, **/search <query>** to search.' + ); + } + }; + + const participant = vscode.chat.createChatParticipant('powermem-vscode.powermem', handler); + participant.followupProvider = { + provideFollowups(_result: vscode.ChatResult, _ctx: vscode.ChatContext, _token: vscode.CancellationToken) { + return [ + { prompt: 'remember', label: 'Save this conversation to PowerMem' }, + { prompt: 'search recent project notes', label: 'Search PowerMem' }, + ]; + }, + }; + context.subscriptions.push(participant); +} diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index ba57535b..73337577 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -8,6 +8,7 @@ import { DashboardPanel } from './panels/DashboardPanel'; import { checkHealth } from './utils/health'; import { searchMemories, addMemory } from './api/client'; import type { SearchResultItem } from './api/types'; +import { registerChatParticipant } from './chat/participant'; let backendUrl = 'http://localhost:8000'; let apiKey: string | undefined; @@ -16,6 +17,34 @@ let useMCP = true; let mcpServerPath = ''; let isEnabled = true; let userId = ''; +let autoCaptureOnSave = false; +let autoCaptureInclude = '**/*.md,**/*.txt,**/docs/**'; +let autoCaptureMaxChars = 8000; +let chatAutoSummarizeTurns = 10; +let chatAutoRetrieve = true; + +function getUseMCPFromConfig(config: vscode.WorkspaceConfiguration): boolean { + const mode = config.get<'http' | 'mcp'>('connectionMode'); + if (mode !== undefined) return mode === 'mcp'; + return config.get('useMCP') ?? true; +} + +/** Simple glob match for auto-capture include (e.g. .md, docs/). Comma-separated patterns. */ +function matchesAutoCaptureInclude(filePath: string, includePattern: string): boolean { + const patterns = includePattern.split(',').map((p) => p.trim()).filter(Boolean); + if (patterns.length === 0 || patterns.includes('**') || patterns.includes('*')) return true; + const normalized = filePath.replace(/\\/g, '/'); + for (const p of patterns) { + if (p.endsWith('/**')) { + const segment = p.replace(/^\*\*\//, '').replace(/\/\*\*$/, ''); + if (segment && normalized.includes('/' + segment + '/')) return true; + } else if (p.startsWith('**/*.')) { + const ext = p.slice(5); + if (normalized.endsWith('.' + ext)) return true; + } + } + return false; +} function getUserId(context: vscode.ExtensionContext, config: vscode.WorkspaceConfiguration): string { const configured = config.get('userId'); @@ -114,7 +143,7 @@ async function showMenu(): Promise { break; case 'toggleMcp': useMCP = !useMCP; - await vscode.workspace.getConfiguration('powermem').update('useMCP', useMCP, vscode.ConfigurationTarget.Global); + await vscode.workspace.getConfiguration('powermem').update('connectionMode', useMCP ? 'mcp' : 'http', vscode.ConfigurationTarget.Global); await autoLinkAll(); break; case 'setup': @@ -206,8 +235,13 @@ export function activate(context: vscode.ExtensionContext): void { isEnabled = config.get('enabled') ?? true; backendUrl = config.get('backendUrl') || 'http://localhost:8000'; apiKey = config.get('apiKey') || undefined; - useMCP = config.get('useMCP') ?? true; + useMCP = getUseMCPFromConfig(config); mcpServerPath = config.get('mcpServerPath') || ''; + autoCaptureOnSave = config.get('autoCapture.onSave') ?? false; + autoCaptureInclude = config.get('autoCapture.include') ?? '**/*.md,**/*.txt,**/docs/**'; + autoCaptureMaxChars = Math.max(500, config.get('autoCapture.maxChars') ?? 8000); + chatAutoSummarizeTurns = Math.max(0, config.get('chat.autoSummarizeEveryNTurns') ?? 10); + chatAutoRetrieve = config.get('chat.autoRetrieve') ?? true; userId = getUserId(context, config); statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); @@ -218,6 +252,16 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand('powermem.statusBarClick', () => showMenu()) ); + registerChatParticipant( + context, + () => backendUrl, + () => apiKey, + () => userId, + () => isEnabled, + () => chatAutoSummarizeTurns, + () => chatAutoRetrieve + ); + if (!isEnabled) { updateStatusBar('disabled'); statusBar.show(); @@ -300,9 +344,45 @@ export function activate(context: vscode.ExtensionContext): void { const c = vscode.workspace.getConfiguration('powermem'); backendUrl = c.get('backendUrl') || 'http://localhost:8000'; apiKey = c.get('apiKey') || undefined; - useMCP = c.get('useMCP') ?? true; + useMCP = getUseMCPFromConfig(c); mcpServerPath = c.get('mcpServerPath') || ''; isEnabled = c.get('enabled') ?? true; + autoCaptureOnSave = c.get('autoCapture.onSave') ?? false; + autoCaptureInclude = c.get('autoCapture.include') ?? '**/*.md,**/*.txt,**/docs/**'; + autoCaptureMaxChars = Math.max(500, c.get('autoCapture.maxChars') ?? 8000); + chatAutoSummarizeTurns = Math.max(0, c.get('chat.autoSummarizeEveryNTurns') ?? 10); + chatAutoRetrieve = c.get('chat.autoRetrieve') ?? true; + // Re-link AI tools when connection/backend config changes so user does not need to click "Link to AI tools" + if ( + isEnabled && + (e.affectsConfiguration('powermem.backendUrl') || + e.affectsConfiguration('powermem.connectionMode') || + e.affectsConfiguration('powermem.useMCP') || + e.affectsConfiguration('powermem.mcpServerPath')) + ) { + autoLinkAll().catch((err) => console.error('PowerMem auto re-link failed:', err)); + } + }) + ); + + // Optional: auto-add to memory on save (seamless write) + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument(async (doc) => { + if (!isEnabled || !autoCaptureOnSave || doc.uri.scheme !== 'file') return; + const path = doc.uri.fsPath; + if (!matchesAutoCaptureInclude(path, autoCaptureInclude)) return; + const text = doc.getText(); + if (!text.trim()) return; + const content = text.length > autoCaptureMaxChars ? text.slice(0, autoCaptureMaxChars) + '\n…' : text; + try { + await addMemory(backendUrl, { + content, + user_id: userId || undefined, + metadata: { source: 'vscode', type: 'auto-save', file: path }, + }, apiKey); + } catch { + // Silent fail to avoid interrupting the user + } }) ); } diff --git a/apps/vscode-extension/src/writers/claude.ts b/apps/vscode-extension/src/writers/claude.ts index a01b32ab..dee7bd64 100644 --- a/apps/vscode-extension/src/writers/claude.ts +++ b/apps/vscode-extension/src/writers/claude.ts @@ -38,11 +38,8 @@ export function generateClaudeConfig( }, }; } - return { - mcpServers: { - powermem: { url: `${base}/mcp` }, - }, - }; + // HTTP mode: do not write MCP config so the client does not call /mcp + return { mcpServers: {} }; } export async function writeClaudeConfig( diff --git a/apps/vscode-extension/src/writers/cursor.ts b/apps/vscode-extension/src/writers/cursor.ts index ffa90308..56b1cdf7 100644 --- a/apps/vscode-extension/src/writers/cursor.ts +++ b/apps/vscode-extension/src/writers/cursor.ts @@ -39,11 +39,8 @@ export function generateCursorConfig( }, }; } - return { - mcpServers: { - powermem: { url: `${base}/mcp` }, - }, - }; + // HTTP mode: do not add MCP config; caller will remove existing powermem entry + return { mcpServers: {} }; } export async function writeCursorConfig( @@ -69,6 +66,9 @@ export async function writeCursorConfig( const merged: CursorMcpConfig = { mcpServers: { ...existing.mcpServers, ...generated.mcpServers }, }; + if (!useMCP && merged.mcpServers) { + delete merged.mcpServers.powermem; + } fs.writeFileSync(configFile, JSON.stringify(merged, null, 2)); return configFile; } From 64a94c94933c85ec689b039714915206b10b0f7d Mon Sep 17 00:00:00 2001 From: Teingi Date: Mon, 23 Mar 2026 09:52:17 +0800 Subject: [PATCH 04/10] claude code plugin --- .github/workflows/plugins-build.yml | 19 +- Makefile | 8 +- apps/README.md | 4 +- apps/claude-code-plugin/.gitignore | 4 + apps/claude-code-plugin/.mcp.json | 7 +- apps/claude-code-plugin/CHANGELOG.md | 23 +- apps/claude-code-plugin/README.md | 200 +++++- .../cmd/powermem-hook/detach_unix.go | 12 + .../cmd/powermem-hook/detach_windows.go | 16 + .../cmd/powermem-hook/main.go | 585 ++++++++++++++++++ .../cmd/powermem-hook/poll.go | 159 +++++ apps/claude-code-plugin/config/README.md | 24 + .../config/http-mode.mcp.json | 3 + .../config/mcp-mode.mcp.json | 8 + apps/claude-code-plugin/go.mod | 3 + apps/claude-code-plugin/hooks/hooks.json | 37 ++ .../hooks/hooks.windows.example.json | 37 ++ apps/claude-code-plugin/hooks/run-hook.ps1 | 13 + apps/claude-code-plugin/hooks/run-hook.sh | 21 + .../scripts/apply-connection-mode.sh | 19 + .../scripts/build-hook-binaries.sh | 31 + .../scripts/package-plugin.sh | 45 ++ apps/claude-code-plugin/watcher/README.md | 21 + apps/vscode-extension/package.json | 9 +- apps/vscode-extension/src/chat/participant.ts | 13 +- apps/vscode-extension/src/extension.ts | 13 +- 26 files changed, 1297 insertions(+), 37 deletions(-) create mode 100644 apps/claude-code-plugin/.gitignore create mode 100644 apps/claude-code-plugin/cmd/powermem-hook/detach_unix.go create mode 100644 apps/claude-code-plugin/cmd/powermem-hook/detach_windows.go create mode 100644 apps/claude-code-plugin/cmd/powermem-hook/main.go create mode 100644 apps/claude-code-plugin/cmd/powermem-hook/poll.go create mode 100644 apps/claude-code-plugin/config/README.md create mode 100644 apps/claude-code-plugin/config/http-mode.mcp.json create mode 100644 apps/claude-code-plugin/config/mcp-mode.mcp.json create mode 100644 apps/claude-code-plugin/go.mod create mode 100644 apps/claude-code-plugin/hooks/hooks.json create mode 100644 apps/claude-code-plugin/hooks/hooks.windows.example.json create mode 100644 apps/claude-code-plugin/hooks/run-hook.ps1 create mode 100644 apps/claude-code-plugin/hooks/run-hook.sh create mode 100755 apps/claude-code-plugin/scripts/apply-connection-mode.sh create mode 100755 apps/claude-code-plugin/scripts/build-hook-binaries.sh create mode 100755 apps/claude-code-plugin/scripts/package-plugin.sh create mode 100644 apps/claude-code-plugin/watcher/README.md diff --git a/.github/workflows/plugins-build.yml b/.github/workflows/plugins-build.yml index 37762c59..54bc2dcc 100644 --- a/.github/workflows/plugins-build.yml +++ b/.github/workflows/plugins-build.yml @@ -23,6 +23,7 @@ on: env: NODE_VERSION: '20' + GO_VERSION: '1.22.x' jobs: build-vscode-extension: @@ -71,16 +72,20 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Create plugin zip - run: | - cd apps/claude-code-plugin - zip -r ../../powermem-claude-code-plugin.zip . -x "*.git*" -x ".DS_Store" + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Package Claude Code plugin (cross-compile hooks + zip) + run: bash apps/claude-code-plugin/scripts/package-plugin.sh - name: Upload Claude Code plugin (zip) uses: actions/upload-artifact@v4 with: name: powermem-claude-code-plugin-zip - path: powermem-claude-code-plugin.zip + path: apps/claude-code-plugin/dist/powermem-claude-code-plugin-*.zip + if-no-files-found: error retention-days: 30 release-plugins: name: Release Plugin Assets @@ -121,12 +126,12 @@ jobs: ## PowerMem IDE Plugins - **PowerMem for VS Code** (`.vsix`): Install in VS Code/Cursor via Install from VSIX, or publish to marketplace. - - **PowerMem Claude Code Plugin** (`.zip`): Use with `claude --plugin-dir /path/to/unzipped-folder`. + - **PowerMem Claude Code Plugin** (`.zip`): **HTTP mode by default** (REST hooks; empty MCP). Cross-compiled hook binaries included. Unzip, then `claude --plugin-dir /path/to/powermem-claude-code-plugin`. Hooks default to `http://localhost:8000`; set `POWERMEM_BASE_URL` for a remote server. For in-chat tools, copy `config/mcp-mode.mcp.json` to `.mcp.json` (see plugin README). See [apps/README.md](https://github.com/${{ github.repository }}/blob/main/apps/README.md) and [apps/TESTING.md](https://github.com/${{ github.repository }}/blob/main/apps/TESTING.md). files: | vsix/*.vsix - zip/powermem-claude-code-plugin.zip + zip/powermem-claude-code-plugin-*.zip draft: false prerelease: ${{ contains(github.ref, 'refs/tags/') == false }} generate_release_notes: true diff --git a/Makefile b/Makefile index 122953bf..b65366dd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-dev test test-unit test-integration test-e2e test-coverage test-fast test-slow lint format clean build build-package build-check build-dashboard publish-pypi publish-testpypi install-build-tools upload docs bump-version server-start server-stop server-restart server-status server-logs server-dashboard-start docker-build docker-run docker-up docker-down docker-logs docker-stop docker-restart docker-clean docker-ps +.PHONY: help install install-dev test test-unit test-integration test-e2e test-coverage test-fast test-slow lint format clean build build-package build-check build-dashboard build-claude-hook package-claude-plugin publish-pypi publish-testpypi install-build-tools upload docs bump-version server-start server-stop server-restart server-status server-logs server-dashboard-start docker-build docker-run docker-up docker-down docker-logs docker-stop docker-restart docker-clean docker-ps help: ## Show help information @echo "powermem Project Build Tools" @@ -122,6 +122,12 @@ build-dashboard: ## Build dashboard frontend and inject into src/server/dashboar @cp -r dashboard/dist/* src/server/dashboard/ @echo "✓ Dashboard built. Start server with: make server-start-reload (then open http://localhost:$(SERVER_PORT)/dashboard/)" +build-claude-hook: ## Build Claude Code hook binaries (Go; output: apps/claude-code-plugin/hooks/bin/) + @bash apps/claude-code-plugin/scripts/build-hook-binaries.sh + +package-claude-plugin: ## Zip Claude Code plugin for sharing (apps/claude-code-plugin/dist/*.zip) + @bash apps/claude-code-plugin/scripts/package-plugin.sh + install-build-tools: ## Install build and upload tools @echo "Installing build tools..." pip install --upgrade build twine diff --git a/apps/README.md b/apps/README.md index dd19bc82..6a63e1a7 100644 --- a/apps/README.md +++ b/apps/README.md @@ -5,12 +5,12 @@ | Directory | Description | |-----------|--------------| | **vscode-extension** | VS Code extension that links PowerMem to Cursor, Claude Code, Codex, Windsurf, and Copilot. Provides commands: Query memories, Add selection, Quick note, Link to AI tools, Setup, Dashboard. | -| **claude-code-plugin** | Claude Code–only plugin (`.claude-plugin` + `.mcp.json` + skills). Use with `claude --plugin-dir apps/claude-code-plugin` or publish to a Claude Code plugin marketplace. | +| **claude-code-plugin** | Claude Code plugin: **HTTP mode by default** (REST hooks; empty `mcpServers`). Optional **MCP mode** via `config/mcp-mode.mcp.json`. See `claude-code-plugin/README.md`. | ## Quick start 1. **Backend**: Start PowerMem (e.g. `powermem-server --port 8000` or `uvx powermem-mcp sse`). 2. **VS Code / Cursor**: Install the extension from `vscode-extension/` (Run and Debug or package as `.vsix`), set backend URL in PowerMem settings, then use **PowerMem: Link to AI tools**. -3. **Claude Code only**: Load the plugin with `claude --plugin-dir /path/to/powermem/apps/claude-code-plugin`. +3. **Claude Code only**: `claude --plugin-dir /path/to/powermem/apps/claude-code-plugin`. **HTTP mode is default**; run `scripts/apply-connection-mode.sh mcp` for in-chat tools (see plugin README). See each subdirectory’s `README.md` for details. diff --git a/apps/claude-code-plugin/.gitignore b/apps/claude-code-plugin/.gitignore new file mode 100644 index 00000000..c6b8b5a1 --- /dev/null +++ b/apps/claude-code-plugin/.gitignore @@ -0,0 +1,4 @@ +dist/ +*.zip +# Built by scripts/build-hook-binaries.sh (included in release zip; omit from git to keep repo small) +hooks/bin/ diff --git a/apps/claude-code-plugin/.mcp.json b/apps/claude-code-plugin/.mcp.json index c300597c..da39e4ff 100644 --- a/apps/claude-code-plugin/.mcp.json +++ b/apps/claude-code-plugin/.mcp.json @@ -1,8 +1,3 @@ { - "mcpServers": { - "powermem": { - "transport": "http", - "url": "http://localhost:8000/mcp" - } - } + "mcpServers": {} } diff --git a/apps/claude-code-plugin/CHANGELOG.md b/apps/claude-code-plugin/CHANGELOG.md index 39eb9741..d2ae59c5 100644 --- a/apps/claude-code-plugin/CHANGELOG.md +++ b/apps/claude-code-plugin/CHANGELOG.md @@ -2,4 +2,25 @@ ## 1.0.0 -- Initial release: PowerMem MCP config and skills (remember, recall) for Claude Code. +Initial release of the PowerMem plugin for Claude Code. + +**Connection modes** + +- **HTTP mode (default):** Root `.mcp.json` ships with empty `mcpServers`; no PowerMem MCP tools in chat. Hooks always call the PowerMem REST API (`POWERMEM_BASE_URL`, default `http://localhost:8000`). +- **MCP mode (optional):** `scripts/apply-connection-mode.sh mcp` copies `config/mcp-mode.mcp.json` to `.mcp.json` so Claude can use PowerMem MCP (`search_memories`, `add_memory`, etc.) over HTTP `/mcp` or stdio. + +**Skills** + +- `/memory-powermem:remember` and `/memory-powermem:recall` — backed by MCP tools when MCP mode is enabled; in default HTTP mode they have no tools to invoke. + +**Hooks (native `powermem-hook` + `run-hook.sh` / `run-hook.ps1`)** + +- **SessionEnd:** Upload full session transcript to `POST /api/v1/memories` (detached worker so large uploads do not block exit). +- **PostCompact:** Upload compact summary to `POST /api/v1/memories`. +- **UserPromptSubmit (optional):** When `POWERMEM_PROMPT_SEARCH=1`, `POST /api/v1/memories/search` and inject hits via `additionalContext` (works in HTTP and MCP modes; off by default). + +**Other** + +- Optional workspace file poller: `sh hooks/run-hook.sh poll` (see `watcher/README.md`). +- Windows: `hooks/hooks.windows.example.json` + PowerShell `run-hook.ps1` when `sh` is unavailable. +- Packaging: `scripts/package-plugin.sh` / `make package-claude-plugin`; hook binaries via `scripts/build-hook-binaries.sh` (Go 1.22+). diff --git a/apps/claude-code-plugin/README.md b/apps/claude-code-plugin/README.md index d22a1014..eca887c1 100644 --- a/apps/claude-code-plugin/README.md +++ b/apps/claude-code-plugin/README.md @@ -4,16 +4,33 @@ Claude Code plugin that connects to [PowerMem](https://github.com/oceanbase/powe ## Features -- **MCP integration**: Uses PowerMem MCP Server so Claude can call `add_memory`, `search_memories`, `get_memory_by_id`, `update_memory`, `delete_memory`, `list_memories`. -- **Skills**: `/memory-powermem:remember` and `/memory-powermem:recall` to guide when to store and when to search memories. +- **Two connection modes** (aligned with the PowerMem VS Code extension). **HTTP mode is the default** (standard): REST-only via hooks, no PowerMem MCP tools in chat. **MCP mode** is optional when you want `search_memories` / `add_memory` in the conversation. See [Configuration](#configuration). +- **HTTP mode (default)**: Root `.mcp.json` ships with empty `mcpServers`. Hooks use **`POST /api/v1/memories`** (`POWERMEM_BASE_URL`, default `http://localhost:8000`). +- **MCP mode (optional)**: Copy [`config/mcp-mode.mcp.json`](config/mcp-mode.mcp.json) to `.mcp.json` (or run `apply-connection-mode.sh mcp`). Claude gets PowerMem tools over **HTTP** `…/mcp` or **stdio**. +- **Skills**: `/memory-powermem:remember` and `/memory-powermem:recall` — effective in **MCP mode**; in default HTTP mode they cannot drive tools. +- **Seamless REST capture**: Hooks run in **both** modes. Optional **file poller** — see [watcher/README.md](watcher/README.md). +- **Optional auto-retrieval (no MCP required)**: A `UserPromptSubmit` hook can call **`POST /api/v1/memories/search`** with the user’s prompt and inject hits into Claude’s context via [`additionalContext`](https://code.claude.com/docs/en/hooks#userpromptsubmit). Enable with **`POWERMEM_PROMPT_SEARCH=1`** (off by default — each turn adds a search round-trip). Works in **HTTP and MCP** modes. -## Prerequisites +## Runtime requirements (end users) + +| Piece | Needs Python? | Notes | +|--------|----------------|-------| +| Claude Code | No | | +| MCP tools | No | **Off by default** (HTTP mode). Run `apply-connection-mode.sh mcp` to enable. | +| **Hooks** (transcript / compact → HTTP API) | **No** | Native binaries under `hooks/bin/` + `run-hook.sh` (macOS/Linux) or PowerShell on Windows. **`POWERMEM_BASE_URL` defaults to `http://localhost:8000`.** | +| Optional **file poller** | No | Same binary: `sh hooks/run-hook.sh poll` — see [watcher/README.md](watcher/README.md). | + +**macOS / Linux:** default `hooks/hooks.json` runs `sh …/run-hook.sh`. POSIX `sh` is always present. -1. **PowerMem** installed and a running PowerMem backend: - - Either **MCP Server** (e.g. `uvx powermem-mcp sse` or `uvx powermem-mcp stdio`) with a `.env` in project or home directory. - - Or **HTTP API Server** (e.g. `powermem-server --host 0.0.0.0 --port 8000`). The plugin's default `.mcp.json` points to `http://localhost:8000/mcp` (MCP over HTTP). +**Windows (native, no Git Bash):** if `sh` is missing, merge the commands from [`hooks/hooks.windows.example.json`](hooks/hooks.windows.example.json) into your Claude `settings.json` so hooks call `powershell.exe -File …/run-hook.ps1`. The zip includes `hooks/bin/powermem-hook-windows-amd64.exe` (add `windows/arm64` to the build script if you need it). -2. **Claude Code** (VS Code extension or CLI) with plugin support. +**Rebuilding binaries** (developers / CI): Go **1.22+**, then `bash scripts/build-hook-binaries.sh` or `make build-claude-hook` from the repo root. `make package-claude-plugin` builds them automatically before zipping. + +## Prerequisites + +1. **PowerMem HTTP API** reachable from the machine running Claude (e.g. `powermem-server --port 8000`). Default hooks use **`http://localhost:8000`** — override with `POWERMEM_BASE_URL` for a remote server. +2. **MCP mode only:** additionally expose MCP (same host, usually `/mcp`) or stdio `powermem-mcp`, and switch `.mcp.json` via [`config/mcp-mode.mcp.json`](config/mcp-mode.mcp.json). +3. **Claude Code** (VS Code extension or CLI) with plugin support. ## Installation @@ -27,13 +44,101 @@ claude --plugin-dir /path/to/powermem/apps/claude-code-plugin If this plugin is published to a Claude Code plugin marketplace, install it from there. +### Option C: Pack and copy to another machine (offline / internal) + +From the **powermem repo root**: + +```bash +make package-claude-plugin +``` + +Or run the script directly: + +```bash +bash apps/claude-code-plugin/scripts/package-plugin.sh +``` + +This writes **`apps/claude-code-plugin/dist/powermem-claude-code-plugin-.zip`**. Share that zip (USB, internal artifact server, etc.). + +**On the other computer:** + +1. Unzip → you get a folder `powermem-claude-code-plugin/` containing `.claude-plugin/`, `hooks/`, `skills/`, `.mcp.json`, etc. +2. Point Claude Code at that folder (absolute path recommended): + + ```bash + # Optional: hooks default to http://localhost:8000 if POWERMEM_BASE_URL is unset + export POWERMEM_BASE_URL=https://your-team-powermem.example.com # team server only + claude --plugin-dir /path/to/powermem-claude-code-plugin + ``` + +3. Requirements on that machine: **no Python**; use **macOS/Linux** `sh` or follow **Windows** PowerShell hooks above. **HTTP API** must be reachable for hooks (and `/mcp` too if you enable MCP mode). + +To publish a zip **with MCP enabled by default**, replace root `.mcp.json` with `config/mcp-mode.mcp.json` before `make package-claude-plugin`, or document that users run `apply-connection-mode.sh mcp`. + +## Uninstall and update + +### Uninstall + +How you remove the plugin depends on how you enabled it: + +| How you installed | What to do | +|-------------------|------------| +| **`claude --plugin-dir /path/to/...`** | Stop passing `--plugin-dir` (remove it from shell aliases, scripts, or IDE task). Optionally delete the plugin folder. Nothing is left in `~/.claude` **unless** you also changed global settings (see below). | +| **Zip / copied folder** | Delete the unzipped directory. Stop using `--plugin-dir` pointing at it. | +| **Git clone / repo path** | Stop using `--plugin-dir` for that path; remove the clone if you no longer need it. | +| **Marketplace / built-in plugin UI** | Disable or uninstall **memory-powermem** (or the listed name) in Claude Code’s plugin settings. Follow [Claude Code plugins](https://code.claude.com/docs/en/plugins) for the exact UI or CLI your version provides. | +| **You merged [`hooks/hooks.windows.example.json`](hooks/hooks.windows.example.json) into `settings.json`** | Edit `~/.claude/settings.json` or `.claude/settings.json` in the project and remove the `UserPromptSubmit` / `SessionEnd` / `PostCompact` hook entries that call `run-hook.ps1` (or restore a backup). Otherwise hooks keep running even after the plugin folder is deleted. | + +The hook binary only **writes** to your PowerMem server; it does not install a system daemon. No separate “service uninstall” is required. + +### Update + +| Install style | Update steps | +|---------------|--------------| +| **Zip** | Download the new `.zip`, replace the old folder (delete the previous `powermem-claude-code-plugin` tree, unzip the new one to the same or a new path), then start Claude with `--plugin-dir` pointing at the new folder. | +| **Repo / `git`** | `git pull` (or fetch the release you want), run `make package-claude-plugin` or `bash scripts/package-plugin.sh` if you need a fresh zip, then restart Claude Code. | +| **Marketplace** | Use “update” / reinstall from the marketplace when your team publishes a new version. | + +After updating, restart the Claude Code session (or the whole app) so MCP config, skills, and hooks reload. + ## Configuration -The default `.mcp.json` in this plugin uses: +### Two PowerMem modes (HTTP default, MCP optional) + +Same **MCP / HTTP** split as elsewhere in PowerMem. **Standard shipping = HTTP mode**: root `.mcp.json` has **`mcpServers: {}`**. **Hooks always use REST** in both modes. + +| Mode | Plugin root `.mcp.json` | Claude in-chat | Silent capture (hooks → REST) | +|------|-------------------------|----------------|--------------------------------| +| **HTTP mode (default)** | Empty `mcpServers` — same as [`config/http-mode.mcp.json`](config/http-mode.mcp.json) | No PowerMem MCP tools | Yes (`POWERMEM_BASE_URL`, default `http://localhost:8000`) | +| **MCP mode** | Includes `powermem` — [`config/mcp-mode.mcp.json`](config/mcp-mode.mcp.json) | Yes — `search_memories`, `add_memory`, … | Yes | + +**Switch mode** (from the plugin directory): + +```bash +bash scripts/apply-connection-mode.sh http # restore standard (default) HTTP-only mode +bash scripts/apply-connection-mode.sh mcp # enable in-chat PowerMem tools +``` -- **HTTP transport**: `http://localhost:8000/mcp` +Restart Claude Code after changing `.mcp.json`. See [`config/README.md`](config/README.md). -To use a different URL or **stdio** (local MCP process), edit `.mcp.json` in this directory. Example for stdio: +**Naming note:** In **MCP mode**, `transport: "http"` means “connect to the **MCP** endpoint over HTTP” (`https://host/mcp`), not “replace MCP with REST.” **HTTP mode** means “no MCP entry for PowerMem”; REST is still used by hooks. + +### MCP mode: team or local URL + +After `apply-connection-mode.sh mcp`, edit `.mcp.json` or `config/mcp-mode.mcp.json` before copying. Same host as your REST API, MCP path is usually `/mcp`: + +```json +{ + "mcpServers": { + "powermem": { + "transport": "http", + "url": "https://powermem.example.com/mcp" + } + } +} +``` + +**stdio MCP** (local `powermem-mcp` process) — in **MCP mode**, replace the `powermem` block with: ```json { @@ -47,15 +152,82 @@ To use a different URL or **stdio** (local MCP process), edit `.mcp.json` in thi } ``` -Ensure PowerMem is installed (`pip install powermem`) and a `.env` file is available when using stdio. +Ensure PowerMem is installed (`pip install powermem`) and a `.env` is available when using stdio. + +### HTTP mode: REST only (standard) + +This is the **default** root `.mcp.json`. Claude has **no** PowerMem MCP tools; skills that reference those tools have nothing to call. **Hooks** still send transcripts / compact summaries to `POST /api/v1/memories`. To reset after trying MCP: `bash scripts/apply-connection-mode.sh http`. + +### Seamless recording (hooks + HTTP API) + +The plugin ships [`hooks/hooks.json`](hooks/hooks.json), [`hooks/run-hook.sh`](hooks/run-hook.sh), and **native** `hooks/bin/powermem-hook-*` (built from [`cmd/powermem-hook`](cmd/powermem-hook/)). When the plugin is enabled, Claude Code merges these hooks: + +| Hook | What happens | +|------|----------------| +| `UserPromptSubmit` | If **`POWERMEM_PROMPT_SEARCH=1`**: **`POST …/api/v1/memories/search`** with the submitted `prompt`; top results are injected as **additional context** for that turn ([Claude Code hooks](https://code.claude.com/docs/en/hooks#userpromptsubmit)). If unset, the hook no-ops (still registered; overhead is small). | +| `SessionEnd` | Full **transcript** from `transcript_path` (parsed JSONL: user/assistant/summary lines) → **`POST …/api/v1/memories`**. | +| `PostCompact` | The **`compact_summary`** field after `/compact` or auto-compact → **`POST …/api/v1/memories`**. | + +**Write** hooks use `POST {POWERMEM_BASE_URL}/api/v1/memories`. **Prompt search** uses `POST {POWERMEM_BASE_URL}/api/v1/memories/search`. Neither path requires MCP. + +Optional environment variables (where you launch Claude Code): + +| Variable | Required | Description | +|----------|----------|-------------| +| `POWERMEM_BASE_URL` | No | Defaults to **`http://localhost:8000`** (same host as default `.mcp.json`, without `/mcp`). Set for a team gateway, e.g. `https://powermem.example.com`. | +| `POWERMEM_API_KEY` | If server uses auth | Sent as `X-API-Key` | +| `POWERMEM_USER_ID` | No | Defaults to OS login name | +| `POWERMEM_AGENT_ID` | No | Optional `agent_id` on memories | +| `POWERMEM_HOOK_MAX_CHARS` | No | Transcript cap (default `120000`) | +| `POWERMEM_INFER_TRANSCRIPT` | No | Set `1` to enable server-side infer on large transcripts (default off) | +| `POWERMEM_INFER_COMPACT` | No | Set `0` to disable infer on compact summaries (default on) | +| `POWERMEM_PROMPT_SEARCH` | No | Set **`1`** (or `true` / `yes` / `on`) to **inject semantic search results** on every user prompt via `UserPromptSubmit`. Default: off. | +| `POWERMEM_PROMPT_SEARCH_LIMIT` | No | Max memories returned per prompt (default **8**, cap **30**). | +| `POWERMEM_PROMPT_SEARCH_MAX_CHARS` | No | Cap on injected context string (default **24000**). | + +**SessionEnd timeout:** Claude Code defaults to a short timeout for `SessionEnd` hooks. The hook **returns immediately** and uploads in a **detached worker process**, so large transcripts still upload without blocking exit. If you ever switch to a synchronous upload inside the hook, raise `CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS` (see [Claude Code hooks – SessionEnd](https://code.claude.com/docs/en/hooks#sessionend)). + +### Troubleshooting: “no requests” while vibe-coding + +What you see is often **expected**: + +1. **Default HTTP mode** — There are **no** PowerMem MCP tools during chat, so Claude does **not** call `/mcp` or `POST /api/v1/memories` on each message. +2. **Hooks are not per-turn** — `SessionEnd` runs when the **session ends** (quit, `/clear`, `/resume` switch, etc.). `PostCompact` runs after **manual or auto compact**, not after every reply. **Normal back-and-forth coding does not trigger a hook.** +3. **Those GETs** (`/system/status`, `/memories/stats`, …) usually come from another client (e.g. **PowerMem VS Code extension** dashboard), not from Claude Code hooks. + +**How to verify hooks:** + +- **End the Claude Code session** (exit the CLI session that used `--plugin-dir`), then check server logs for **`POST /api/v1/memories`** (the worker runs shortly after exit). +- Or trigger **`/compact`** (or wait for auto-compact) and look for a compact-summary write. +- In Claude Code, type **`/hooks`** and confirm `UserPromptSubmit` (if present) / `SessionEnd` / `PostCompact` list this plugin’s command (see [hooks menu](https://code.claude.com/docs/en/hooks#the-hooks-menu)). + +**If you want traffic during the conversation:** + +- Set **`POWERMEM_PROMPT_SEARCH=1`** so each user message triggers **`POST /api/v1/memories/search`** and retrieved memories are **injected automatically** (no MCP tools needed). +- Or switch to **MCP mode** (`bash scripts/apply-connection-mode.sh mcp`) so Claude can call memory tools when it chooses — traffic goes to **`/mcp`**, not necessarily the same paths as the dashboard GETs. +- Or rely on **VS Code extension** save capture / `sh hooks/run-hook.sh poll` for file-based writes. + +### Optional: workspace file watcher (CLI / no VS Code) + +If engineers use **Claude Code without** the [PowerMem VS Code extension](../vscode-extension/) (which already **auto-captures on save** against `powermem.backendUrl`), run the native poller: + +```bash +export POWERMEM_BASE_URL=https://powermem.example.com +export POWERMEM_API_KEY=... # if required +export POWERMEM_WATCH_ROOT=/path/to/repo +sh hooks/run-hook.sh poll +``` + +See [watcher/README.md](watcher/README.md) for environment variables. ## Usage -- In Claude Code, the PowerMem MCP tools are available automatically once the plugin is loaded. -- Use **/memory-powermem:remember** when you want Claude to store something. -- Use **/memory-powermem:recall** when you want Claude to search memories before answering. +- **Default (HTTP mode):** Hooks capture to REST automatically; no PowerMem tools in chat. For **automatic retrieval each turn**, set **`POWERMEM_PROMPT_SEARCH=1`** (see [Seamless recording](#seamless-recording-hooks--http-api)). +- **MCP mode:** Run `apply-connection-mode.sh mcp`, then PowerMem tools appear; use **/memory-powermem:remember** / **recall** with real tool backing. You can combine MCP tools with **`POWERMEM_PROMPT_SEARCH=1`** if you want both explicit tool use and per-prompt injection. +- In **both** modes, transcript/compact hooks write to REST (`POWERMEM_BASE_URL`, default `http://localhost:8000`) without the model calling tools. ## Links - [PowerMem](https://github.com/oceanbase/powermem) - [PowerMem MCP docs](https://github.com/oceanbase/powermem/blob/master/docs/api/0004-mcp.md) +- [Claude Code hooks reference](https://code.claude.com/docs/en/hooks) diff --git a/apps/claude-code-plugin/cmd/powermem-hook/detach_unix.go b/apps/claude-code-plugin/cmd/powermem-hook/detach_unix.go new file mode 100644 index 00000000..aa162501 --- /dev/null +++ b/apps/claude-code-plugin/cmd/powermem-hook/detach_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import ( + "os/exec" + "syscall" +) + +func setDetachedChild(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/apps/claude-code-plugin/cmd/powermem-hook/detach_windows.go b/apps/claude-code-plugin/cmd/powermem-hook/detach_windows.go new file mode 100644 index 00000000..6f0d381a --- /dev/null +++ b/apps/claude-code-plugin/cmd/powermem-hook/detach_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +const createNoWindow = 0x08000000 + +func setDetachedChild(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | createNoWindow, + } +} diff --git a/apps/claude-code-plugin/cmd/powermem-hook/main.go b/apps/claude-code-plugin/cmd/powermem-hook/main.go new file mode 100644 index 00000000..d8106da5 --- /dev/null +++ b/apps/claude-code-plugin/cmd/powermem-hook/main.go @@ -0,0 +1,585 @@ +// powermem-hook: Claude Code hook — stdin JSON (SessionEnd / PostCompact) → background HTTP POST to PowerMem. +// Cross-platform; zero runtime deps beyond the single binary. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Default REST base when POWERMEM_BASE_URL is unset (matches .mcp.json local server). +const defaultPowerMemBaseURL = "http://localhost:8000" + +func main() { + if len(os.Args) >= 2 { + switch os.Args[1] { + case "worker-transcript": + workerTranscript() + return + case "worker-compact": + workerCompact() + return + case "worker-file": + workerFile() + return + case "poll": + runPollLoop() + return + } + } + stdinHook() +} + +func baseURL() string { + s := strings.TrimSpace(os.Getenv("POWERMEM_BASE_URL")) + if s == "" { + s = defaultPowerMemBaseURL + } + return strings.TrimRight(s, "/") +} + +func spawnWorker(mode string, envExtra map[string]string) { + self, err := os.Executable() + if err != nil { + return + } + env := os.Environ() + for k, v := range envExtra { + env = append(env, k+"="+v) + } + cmd := exec.Command(self, mode) + cmd.Env = env + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + setDetachedChild(cmd) + _ = cmd.Start() +} + +func stdinHook() { + raw, err := io.ReadAll(os.Stdin) + if err != nil || len(bytes.TrimSpace(raw)) == 0 { + return + } + var payload map[string]any + if json.Unmarshal(raw, &payload) != nil { + return + } + + event, _ := payload["hook_event_name"].(string) + sid, _ := payload["session_id"].(string) + cwd, _ := payload["cwd"].(string) + + switch event { + case "UserPromptSubmit": + handleUserPromptSubmit(payload) + case "SessionEnd": + tp, _ := payload["transcript_path"].(string) + if tp == "" { + return + } + if st, err := os.Stat(tp); err != nil || st.IsDir() { + return + } + reason, _ := payload["reason"].(string) + spawnWorker("worker-transcript", map[string]string{ + "POWERMEM_WORKER_TRANSCRIPT_PATH": tp, + "POWERMEM_WORKER_SESSION_ID": sid, + "POWERMEM_WORKER_CWD": cwd, + "POWERMEM_WORKER_REASON": reason, + }) + case "PostCompact": + summary, _ := payload["compact_summary"].(string) + if strings.TrimSpace(summary) == "" { + return + } + if len(summary) > 900000 { + summary = summary[:900000] + "\n…" + } + trigger, _ := payload["trigger"].(string) + spawnWorker("worker-compact", map[string]string{ + "POWERMEM_WORKER_COMPACT_SUMMARY": summary, + "POWERMEM_WORKER_SESSION_ID": sid, + "POWERMEM_WORKER_CWD": cwd, + "POWERMEM_WORKER_TRIGGER": trigger, + }) + } +} + +func maxHookChars() int { + s := strings.TrimSpace(os.Getenv("POWERMEM_HOOK_MAX_CHARS")) + if s == "" { + return 120000 + } + n, err := strconv.Atoi(s) + if err != nil || n < 500 { + return 120000 + } + return n +} + +func inferTranscript() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_INFER_TRANSCRIPT"))) { + case "1", "true", "yes": + return true + default: + return false + } +} + +func inferCompact() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_INFER_COMPACT"))) { + case "0", "false", "no": + return false + default: + return true + } +} + +func inferFile() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_INFER_FILE"))) { + case "1", "true", "yes": + return true + default: + return false + } +} + +func promptSearchEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_PROMPT_SEARCH"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func searchBodyUserID() string { + if u := strings.TrimSpace(os.Getenv("POWERMEM_USER_ID")); u != "" { + return u + } + if u := os.Getenv("USER"); u != "" { + return u + } + return os.Getenv("USERNAME") +} + +func searchBodyAgentID() string { + return strings.TrimSpace(os.Getenv("POWERMEM_AGENT_ID")) +} + +func promptSearchLimit() int { + const defaultLimit = 8 + s := strings.TrimSpace(os.Getenv("POWERMEM_PROMPT_SEARCH_LIMIT")) + if s == "" { + return defaultLimit + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 { + return defaultLimit + } + if n > 30 { + return 30 + } + return n +} + +func promptSearchMaxContextChars() int { + const defaultMax = 24000 + s := strings.TrimSpace(os.Getenv("POWERMEM_PROMPT_SEARCH_MAX_CHARS")) + if s == "" { + return defaultMax + } + n, err := strconv.Atoi(s) + if err != nil || n < 500 { + return defaultMax + } + return n +} + +func handleUserPromptSubmit(payload map[string]any) { + if !promptSearchEnabled() { + return + } + prompt, _ := payload["prompt"].(string) + prompt = strings.TrimSpace(prompt) + if len(prompt) < 2 { + return + } + ctx, err := searchMemoriesForPrompt(prompt) + if err != nil || strings.TrimSpace(ctx) == "" { + return + } + maxC := promptSearchMaxContextChars() + if len(ctx) > maxC { + ctx = ctx[:maxC] + "\n…" + } + out := map[string]any{ + "hookSpecificOutput": map[string]any{ + "hookEventName": "UserPromptSubmit", + "additionalContext": ctx, + }, + } + b, err := json.Marshal(out) + if err != nil { + return + } + _, _ = os.Stdout.Write(b) +} + +func searchMemoriesForPrompt(query string) (string, error) { + base := baseURL() + body := map[string]any{ + "query": query, + "limit": promptSearchLimit(), + } + if u := searchBodyUserID(); u != "" { + body["user_id"] = u + } + if a := searchBodyAgentID(); a != "" { + body["agent_id"] = a + } + b, err := json.Marshal(body) + if err != nil { + return "", err + } + req, err := http.NewRequest(http.MethodPost, base+"/api/v1/memories/search", bytes.NewReader(b)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + if k := strings.TrimSpace(os.Getenv("POWERMEM_API_KEY")); k != "" { + req.Header.Set("X-API-Key", k) + } + c := &http.Client{Timeout: 90 * time.Second} + resp, err := c.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("search http %d", resp.StatusCode) + } + return formatSearchResults(respBody) +} + +func formatSearchResults(respBody []byte) (string, error) { + var root map[string]any + if json.Unmarshal(respBody, &root) != nil { + return "", fmt.Errorf("invalid search json") + } + data, _ := root["data"].(map[string]any) + if data == nil { + return "", nil + } + results, _ := data["results"].([]any) + if len(results) == 0 { + return "", nil + } + var b strings.Builder + b.WriteString("## PowerMem (retrieved for this prompt)\n\nRelevant long-term memories from PowerMem; use if they help answer the user. Ignore if unrelated.\n\n") + for i, el := range results { + m, ok := el.(map[string]any) + if !ok { + continue + } + content, _ := m["content"].(string) + content = strings.TrimSpace(content) + if content == "" { + continue + } + score, _ := m["score"].(float64) + b.WriteString(fmt.Sprintf("### Memory %d", i+1)) + if score > 0 { + b.WriteString(fmt.Sprintf(" (score %.2f)", score)) + } + b.WriteString("\n\n") + b.WriteString(content) + b.WriteString("\n\n") + } + s := strings.TrimSpace(b.String()) + if s == "" { + return "", nil + } + return s, nil +} + +func postMemory(content string, meta map[string]any, runID *string, infer bool) error { + base := baseURL() + body := map[string]any{ + "content": content, + "infer": infer, + "metadata": meta, + } + if u := strings.TrimSpace(os.Getenv("POWERMEM_USER_ID")); u != "" { + body["user_id"] = u + } else if u := os.Getenv("USER"); u != "" { + body["user_id"] = u + } else if u := os.Getenv("USERNAME"); u != "" { + body["user_id"] = u + } + if a := strings.TrimSpace(os.Getenv("POWERMEM_AGENT_ID")); a != "" { + body["agent_id"] = a + } + if runID != nil && *runID != "" { + body["run_id"] = *runID + } + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, base+"/api/v1/memories", bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + if k := strings.TrimSpace(os.Getenv("POWERMEM_API_KEY")); k != "" { + req.Header.Set("X-API-Key", k) + } + c := &http.Client{Timeout: 120 * time.Second} + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("http %d", resp.StatusCode) + } + return nil +} + +func workerTranscript() { + path := os.Getenv("POWERMEM_WORKER_TRANSCRIPT_PATH") + if path == "" { + return + } + text, err := readTranscriptText(path, maxHookChars()) + if err != nil || strings.TrimSpace(text) == "" { + return + } + sid := os.Getenv("POWERMEM_WORKER_SESSION_ID") + cwd := os.Getenv("POWERMEM_WORKER_CWD") + reason := os.Getenv("POWERMEM_WORKER_REASON") + header := fmt.Sprintf("Claude Code session transcript (session_id=%s, cwd=%s, reason=%s)\n\n", sid, cwd, reason) + runID := sid + if err := postMemory(header+text, map[string]any{ + "source": "claude-code-hook", + "kind": "session-end-transcript", + "transcript_path": path, + "session_id": sid, + "cwd": cwd, + "session_end_reason": reason, + }, &runID, inferTranscript()); err != nil { + os.Exit(1) + } +} + +func workerCompact() { + summary := strings.TrimSpace(os.Getenv("POWERMEM_WORKER_COMPACT_SUMMARY")) + if summary == "" { + return + } + sid := os.Getenv("POWERMEM_WORKER_SESSION_ID") + cwd := os.Getenv("POWERMEM_WORKER_CWD") + trigger := os.Getenv("POWERMEM_WORKER_TRIGGER") + runID := sid + content := fmt.Sprintf("Claude Code context compact summary (session_id=%s, cwd=%s, trigger=%s)\n\n%s", sid, cwd, trigger, summary) + if err := postMemory(content, map[string]any{ + "source": "claude-code-hook", + "kind": "post-compact-summary", + "session_id": sid, + "cwd": cwd, + "compact_trigger": trigger, + }, &runID, inferCompact()); err != nil { + os.Exit(1) + } +} + +func maxFileChars() int { + s := strings.TrimSpace(os.Getenv("POWERMEM_HOOK_MAX_CHARS")) + if s == "" { + return 8000 + } + n, err := strconv.Atoi(s) + if err != nil || n < 500 { + return 8000 + } + return n +} + +func workerFile() { + p := os.Getenv("POWERMEM_WORKER_FILE_PATH") + if p == "" { + return + } + data, err := os.ReadFile(p) + if err != nil { + os.Exit(1) + } + maxC := maxFileChars() + if len(data) > maxC { + data = append(data[:maxC], []byte("\n…")...) + } + if strings.TrimSpace(string(data)) == "" { + return + } + abs, _ := filepath.Abs(p) + if err := postMemory(string(data), map[string]any{ + "source": "powermem-file-watcher", + "kind": "workspace-file", + "file": abs, + }, nil, inferFile()); err != nil { + os.Exit(1) + } +} + +func flattenContent(v any) string { + if v == nil { + return "" + } + switch x := v.(type) { + case string: + return x + case []any: + var parts []string + for _, el := range x { + m, ok := el.(map[string]any) + if !ok { + if s, ok := el.(string); ok { + parts = append(parts, s) + } + continue + } + if m["type"] == "text" { + if t, ok := m["text"].(string); ok { + parts = append(parts, t) + } + } else if t, ok := m["text"].(string); ok { + parts = append(parts, t) + } else { + b, _ := json.Marshal(m) + s := string(b) + if len(s) > 2000 { + s = s[:2000] + } + parts = append(parts, s) + } + } + return strings.Join(parts, "\n") + default: + s := fmt.Sprint(x) + if len(s) > 8000 { + return s[:8000] + } + return s + } +} + +func flattenMessage(msg any) string { + if msg == nil { + return "" + } + s, ok := msg.(string) + if ok { + return s + } + m, ok := msg.(map[string]any) + if !ok { + out := fmt.Sprint(msg) + if len(out) > 2000 { + return out[:2000] + } + return out + } + if c, ok := m["content"]; ok { + return flattenContent(c) + } + var bits []string + for _, k := range []string{"text", "prompt"} { + if t, ok := m[k].(string); ok { + bits = append(bits, t) + } + } + return strings.Join(bits, "\n") +} + +func transcriptLineToText(obj map[string]any) string { + t, _ := obj["type"].(string) + switch t { + case "summary": + if s, ok := obj["summary"].(string); ok { + return "[session title] " + s + } + case "user", "assistant": + role := "User" + if t == "assistant" { + role = "Assistant" + } + body := strings.TrimSpace(flattenMessage(obj["message"])) + if body != "" { + return fmt.Sprintf("[%s]\n%s", role, body) + } + return "[" + role + "]" + } + return "" +} + +func readTranscriptText(path string, maxChars int) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + sc := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + sc.Buffer(buf, 1024*1024) + var lines []string + total := 0 + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var obj map[string]any + if json.Unmarshal([]byte(line), &obj) != nil || obj == nil { + continue + } + chunk := transcriptLineToText(obj) + if chunk == "" { + continue + } + if total+len(chunk)+1 > maxChars { + lines = append(lines, "… [truncated]") + break + } + lines = append(lines, chunk) + total += len(chunk) + 1 + } + return strings.Join(lines, "\n\n---\n\n"), sc.Err() +} + +func runWorkerFileSync(path string) error { + self, err := os.Executable() + if err != nil { + return err + } + cmd := exec.Command(self, "worker-file") + cmd.Env = append(os.Environ(), "POWERMEM_WORKER_FILE_PATH="+path) + cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil + return cmd.Run() +} diff --git a/apps/claude-code-plugin/cmd/powermem-hook/poll.go b/apps/claude-code-plugin/cmd/powermem-hook/poll.go new file mode 100644 index 00000000..fa4e074c --- /dev/null +++ b/apps/claude-code-plugin/cmd/powermem-hook/poll.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +func runPollLoop() { + base := baseURL() + root := filepath.Clean(os.Getenv("POWERMEM_WATCH_ROOT")) + if root == "" || root == "." { + var err error + root, err = os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + if st, err := os.Stat(root); err != nil || !st.IsDir() { + fmt.Fprintf(os.Stderr, "POWERMEM_WATCH_ROOT is not a directory: %s\n", root) + os.Exit(1) + } + + interval := 20.0 + if s := strings.TrimSpace(os.Getenv("POWERMEM_POLL_INTERVAL")); s != "" { + if v, err := strconv.ParseFloat(s, 64); err == nil && v >= 5 { + interval = v + } + } + sufRaw := os.Getenv("POWERMEM_WATCH_SUFFIXES") + if strings.TrimSpace(sufRaw) == "" { + sufRaw = ".md,.mdx,.txt" + } + suffixes := parseSuffixes(sufRaw) + ignRaw := os.Getenv("POWERMEM_WATCH_IGNORE_DIRS") + if strings.TrimSpace(ignRaw) == "" { + ignRaw = ".git,node_modules,.venv,dist,build,target,.claude" + } + ignore := parseIgnoreDirs(ignRaw) + + statePath := filepath.Join(root, ".powermem-watcher-state.json") + state := loadState(statePath) + + fmt.Fprintf(os.Stdout, "PowerMem file watcher: root=%s interval=%gs -> %s\n", root, interval, strings.TrimRight(base, "/")) + + for { + current := scanMtimes(root, suffixes, ignore) + changed := false + for pathStr, mtimeNs := range current { + if state[pathStr] >= mtimeNs { + continue + } + if err := runWorkerFileSync(pathStr); err == nil { + state[pathStr] = mtimeNs + changed = true + fmt.Fprintf(os.Stdout, "uploaded: %s\n", pathStr) + } + } + if changed { + saveState(statePath, state) + } + time.Sleep(time.Duration(interval * float64(time.Second))) + } +} + +func parseSuffixes(raw string) map[string]struct{} { + out := make(map[string]struct{}) + for _, p := range strings.Split(raw, ",") { + p = strings.TrimSpace(strings.ToLower(p)) + if p == "" { + continue + } + if !strings.HasPrefix(p, ".") { + p = "." + p + } + out[p] = struct{}{} + } + return out +} + +func parseIgnoreDirs(raw string) map[string]struct{} { + out := make(map[string]struct{}) + for _, p := range strings.Split(raw, ",") { + p = strings.TrimSpace(p) + if p != "" { + out[p] = struct{}{} + } + } + return out +} + +func scanMtimes(root string, suffixes map[string]struct{}, ignore map[string]struct{}) map[string]int64 { + out := make(map[string]int64) + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if name == "." || name == "" { + return nil + } + if _, skip := ignore[name]; skip { + return filepath.SkipDir + } + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if _, ok := suffixes[ext]; !ok { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return nil + } + st, err := os.Stat(path) + if err != nil { + return nil + } + out[abs] = st.ModTime().UnixNano() + return nil + }) + return out +} + +func loadState(path string) map[string]int64 { + b, err := os.ReadFile(path) + if err != nil { + return make(map[string]int64) + } + var raw map[string]any + if json.Unmarshal(b, &raw) != nil { + return make(map[string]int64) + } + out := make(map[string]int64) + for k, v := range raw { + switch t := v.(type) { + case float64: + out[k] = int64(t) + case int64: + out[k] = t + default: + } + } + return out +} + +func saveState(path string, state map[string]int64) { + b, err := json.MarshalIndent(state, "", " ") + if err != nil { + return + } + _ = os.WriteFile(path, b, 0o644) +} diff --git a/apps/claude-code-plugin/config/README.md b/apps/claude-code-plugin/config/README.md new file mode 100644 index 00000000..ddf1d037 --- /dev/null +++ b/apps/claude-code-plugin/config/README.md @@ -0,0 +1,24 @@ +# PowerMem connection mode templates + +**Default (standard):** the plugin root `.mcp.json` matches [`http-mode.mcp.json`](http-mode.mcp.json) — REST hooks only, no PowerMem MCP in chat. + +| File | Use when | +|------|----------| +| [`http-mode.mcp.json`](http-mode.mcp.json) | **HTTP mode (default)** — same as shipped `.mcp.json`. | +| [`mcp-mode.mcp.json`](mcp-mode.mcp.json) | **MCP mode** — Claude gets PowerMem tools. Edit `url` (or stdio) for your server, then copy to `.mcp.json`. | + +Copy one to the plugin root as `.mcp.json`: + +```bash +cp config/http-mode.mcp.json .mcp.json # standard HTTP-only (default) +cp config/mcp-mode.mcp.json .mcp.json # enable MCP tools +``` + +Or from the plugin directory: + +```bash +bash scripts/apply-connection-mode.sh http # default +bash scripts/apply-connection-mode.sh mcp +``` + +Restart Claude Code after changing `.mcp.json`. diff --git a/apps/claude-code-plugin/config/http-mode.mcp.json b/apps/claude-code-plugin/config/http-mode.mcp.json new file mode 100644 index 00000000..da39e4ff --- /dev/null +++ b/apps/claude-code-plugin/config/http-mode.mcp.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} diff --git a/apps/claude-code-plugin/config/mcp-mode.mcp.json b/apps/claude-code-plugin/config/mcp-mode.mcp.json new file mode 100644 index 00000000..c300597c --- /dev/null +++ b/apps/claude-code-plugin/config/mcp-mode.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "powermem": { + "transport": "http", + "url": "http://localhost:8000/mcp" + } + } +} diff --git a/apps/claude-code-plugin/go.mod b/apps/claude-code-plugin/go.mod new file mode 100644 index 00000000..80e4ace4 --- /dev/null +++ b/apps/claude-code-plugin/go.mod @@ -0,0 +1,3 @@ +module github.com/oceanbase/powermem/claude-code-hook + +go 1.22 diff --git a/apps/claude-code-plugin/hooks/hooks.json b/apps/claude-code-plugin/hooks/hooks.json new file mode 100644 index 00000000..f51cde7e --- /dev/null +++ b/apps/claude-code-plugin/hooks/hooks.json @@ -0,0 +1,37 @@ +{ + "description": "Push Claude Code session transcripts (SessionEnd) and compact summaries (PostCompact) to PowerMem via HTTP. Optional UserPromptSubmit: semantic search injects context when POWERMEM_PROMPT_SEARCH=1. Uses native binaries under hooks/bin. macOS/Linux: sh launcher. Windows without sh: merge hooks/hooks.windows.example.json. POWERMEM_BASE_URL defaults to http://localhost:8000 if unset.", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"", + "timeout": 120 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "auto|manual", + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\"" + } + ] + } + ] + } +} diff --git a/apps/claude-code-plugin/hooks/hooks.windows.example.json b/apps/claude-code-plugin/hooks/hooks.windows.example.json new file mode 100644 index 00000000..4d5d4f44 --- /dev/null +++ b/apps/claude-code-plugin/hooks/hooks.windows.example.json @@ -0,0 +1,37 @@ +{ + "description": "Windows: use this hook command shape if `sh` is not available. Merge into ~/.claude/settings.json or project .claude/settings.json under the same hook events. POWERMEM_BASE_URL defaults to http://localhost:8000 if unset.", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"", + "timeout": 120 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ], + "PostCompact": [ + { + "matcher": "auto|manual", + "hooks": [ + { + "type": "command", + "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.ps1\"" + } + ] + } + ] + } +} diff --git a/apps/claude-code-plugin/hooks/run-hook.ps1 b/apps/claude-code-plugin/hooks/run-hook.ps1 new file mode 100644 index 00000000..10bad656 --- /dev/null +++ b/apps/claude-code-plugin/hooks/run-hook.ps1 @@ -0,0 +1,13 @@ +# PowerShell launcher for Windows (native Claude Code without Git Bash). +# Merge hooks command into settings — see hooks/hooks.windows.example.json +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'amd64' } +$exe = Join-Path $Root "bin\powermem-hook-windows-$arch.exe" +if (-not (Test-Path $exe)) { + $exe = Join-Path $Root "bin\powermem-hook-windows-amd64.exe" +} +if (-not (Test-Path $exe)) { + exit 0 +} +& $exe @args +exit $LASTEXITCODE diff --git a/apps/claude-code-plugin/hooks/run-hook.sh b/apps/claude-code-plugin/hooks/run-hook.sh new file mode 100644 index 00000000..4ffdf8f9 --- /dev/null +++ b/apps/claude-code-plugin/hooks/run-hook.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Select the correct native binary for macOS / Linux. Pass-through args (e.g. "poll" for file watcher). +ROOT=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +case "$(uname -s 2>/dev/null)" in + Darwin) GOOS=darwin ;; + Linux) GOOS=linux ;; + *) exit 0 ;; +esac +case "$(uname -m 2>/dev/null)" in + arm64|aarch64) GOARCH=arm64 ;; + x86_64|amd64) GOARCH=amd64 ;; + *) GOARCH=amd64 ;; +esac +BIN="$ROOT/bin/powermem-hook-${GOOS}-${GOARCH}" +if [ ! -x "$BIN" ] && [ -f "$BIN" ]; then + chmod +x "$BIN" 2>/dev/null || true +fi +if [ ! -f "$BIN" ]; then + exit 0 +fi +exec "$BIN" "$@" diff --git a/apps/claude-code-plugin/scripts/apply-connection-mode.sh b/apps/claude-code-plugin/scripts/apply-connection-mode.sh new file mode 100755 index 00000000..77e95dad --- /dev/null +++ b/apps/claude-code-plugin/scripts/apply-connection-mode.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Copy the chosen PowerMem connection template to .mcp.json (plugin root). +# Usage: bash scripts/apply-connection-mode.sh mcp|http +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +case "${1:-}" in + mcp) + cp "${ROOT}/config/mcp-mode.mcp.json" "${ROOT}/.mcp.json" + echo "Applied MCP mode -> ${ROOT}/.mcp.json" + ;; + http) + cp "${ROOT}/config/http-mode.mcp.json" "${ROOT}/.mcp.json" + echo "Applied HTTP-only mode (no PowerMem MCP tools) -> ${ROOT}/.mcp.json" + ;; + *) + echo "usage: $0 mcp|http" >&2 + exit 1 + ;; +esac diff --git a/apps/claude-code-plugin/scripts/build-hook-binaries.sh b/apps/claude-code-plugin/scripts/build-hook-binaries.sh new file mode 100755 index 00000000..d63a033b --- /dev/null +++ b/apps/claude-code-plugin/scripts/build-hook-binaries.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Cross-compile powermem-hook for all supported platforms (requires Go 1.22+). +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +BIN="${PLUGIN_ROOT}/hooks/bin" +mkdir -p "${BIN}" +cd "${PLUGIN_ROOT}" + +if ! command -v go >/dev/null 2>&1; then + echo "error: Go is not installed. Install Go 1.22+ to build hook binaries." >&2 + exit 1 +fi + +build_one() { + local goos=$1 goarch=$2 + local out="${BIN}/powermem-hook-${goos}-${goarch}" + if [[ "${goos}" == "windows" ]]; then + out="${out}.exe" + fi + echo "Building ${goos}/${goarch} -> ${out}" + GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "${out}" ./cmd/powermem-hook +} + +build_one darwin amd64 +build_one darwin arm64 +build_one linux amd64 +build_one linux arm64 +build_one windows amd64 + +echo "Done. Binaries in ${BIN}/" diff --git a/apps/claude-code-plugin/scripts/package-plugin.sh b/apps/claude-code-plugin/scripts/package-plugin.sh new file mode 100755 index 00000000..df87169e --- /dev/null +++ b/apps/claude-code-plugin/scripts/package-plugin.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build a portable zip of the Claude Code plugin for sharing or offline install. +# Output: apps/claude-code-plugin/dist/powermem-claude-code-plugin-.zip +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DIST="${PLUGIN_ROOT}/dist" + +VERSION="$(python3 -c "import json; print(json.load(open('${PLUGIN_ROOT}/.claude-plugin/plugin.json'))['version'])")" +ZIP_NAME="powermem-claude-code-plugin-${VERSION}.zip" +ZIP_PATH="${DIST}/${ZIP_NAME}" + +if ! command -v zip >/dev/null 2>&1; then + echo "error: 'zip' not found. Install zip (e.g. brew install zip) or zip manually." >&2 + exit 1 +fi + +bash "${PLUGIN_ROOT}/scripts/build-hook-binaries.sh" + +mkdir -p "${DIST}" +TMP="$(mktemp -d)" +cleanup() { rm -rf "${TMP}"; } +trap cleanup EXIT + +STAGE="${TMP}/powermem-claude-code-plugin" +mkdir -p "${STAGE}" + +cp -R "${PLUGIN_ROOT}/.claude-plugin" "${STAGE}/" +cp "${PLUGIN_ROOT}/.mcp.json" "${STAGE}/" +[[ -d "${PLUGIN_ROOT}/config" ]] && cp -R "${PLUGIN_ROOT}/config" "${STAGE}/" +cp -R "${PLUGIN_ROOT}/hooks" "${STAGE}/" +chmod +x "${STAGE}/hooks/run-hook.sh" 2>/dev/null || true +cp -R "${PLUGIN_ROOT}/skills" "${STAGE}/" +[[ -d "${PLUGIN_ROOT}/watcher" ]] && cp -R "${PLUGIN_ROOT}/watcher" "${STAGE}/" +[[ -f "${PLUGIN_ROOT}/README.md" ]] && cp "${PLUGIN_ROOT}/README.md" "${STAGE}/" +[[ -f "${PLUGIN_ROOT}/CHANGELOG.md" ]] && cp "${PLUGIN_ROOT}/CHANGELOG.md" "${STAGE}/" +[[ -f "${PLUGIN_ROOT}/go.mod" ]] && cp "${PLUGIN_ROOT}/go.mod" "${STAGE}/" +[[ -d "${PLUGIN_ROOT}/cmd" ]] && cp -R "${PLUGIN_ROOT}/cmd" "${STAGE}/" +[[ -d "${PLUGIN_ROOT}/scripts" ]] && cp -R "${PLUGIN_ROOT}/scripts" "${STAGE}/" + +( cd "${TMP}" && zip -r "${ZIP_PATH}" "powermem-claude-code-plugin" -x "*.DS_Store" -x "*__pycache__*" -x "*.pyc" ) + +echo "Packaged: ${ZIP_PATH}" +ls -lh "${ZIP_PATH}" diff --git a/apps/claude-code-plugin/watcher/README.md b/apps/claude-code-plugin/watcher/README.md new file mode 100644 index 00000000..597f80f1 --- /dev/null +++ b/apps/claude-code-plugin/watcher/README.md @@ -0,0 +1,21 @@ +# Workspace file watcher (optional) + +The poller lives in the same **native binary** as the Claude hooks (no Python). + +From the plugin root. `POWERMEM_BASE_URL` defaults to `http://localhost:8000` if unset (optional `POWERMEM_API_KEY`): + +```bash +sh hooks/run-hook.sh poll +``` + +Or invoke the binary for your OS directly, e.g. `hooks/bin/powermem-hook-linux-amd64 poll`. + +Environment variables match the former Python script: `POWERMEM_WATCH_ROOT`, `POWERMEM_POLL_INTERVAL`, `POWERMEM_WATCH_SUFFIXES`, `POWERMEM_WATCH_IGNORE_DIRS`. + +On **Windows** (PowerShell): + +```powershell +.\hooks\bin\powermem-hook-windows-amd64.exe poll +``` + +(Use `arm64` on ARM64 Windows if you build that target.) diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index 5bd318c6..94267597 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -15,7 +15,7 @@ "id": "powermem-vscode.powermem", "name": "powermem", "fullName": "PowerMem", - "description": "Save chat to memory, search memories. Say remember or /save to save; /search to query.", + "description": "Memory for AI (optional). In seamless mode use your linked AI instead; no need to @ mention.", "isSticky": false, "commands": [ { "name": "remember", "description": "Save this conversation to PowerMem" }, @@ -92,10 +92,15 @@ "default": "", "description": "Custom project name. Leave empty to use the workspace name." }, + "powermem.seamlessMode": { + "type": "boolean", + "default": true, + "description": "Zero-friction mode: no need to use @powermem. Memory is automatic: linked AI (Cursor/Claude/Codex) get retrieval via MCP; optional file auto-capture adds content on save. Disable to use @powermem chat commands (remember/save/search)." + }, "powermem.autoCapture.onSave": { "type": "boolean", "default": false, - "description": "When enabled, automatically add file content to memory on save (seamless write). Respects 'Include pattern' and 'Max chars'." + "description": "When enabled, automatically add file content to memory on save (seamless write). In seamless mode, defaults to true unless set explicitly. Respects 'Include pattern' and 'Max chars'." }, "powermem.autoCapture.include": { "type": "string", diff --git a/apps/vscode-extension/src/chat/participant.ts b/apps/vscode-extension/src/chat/participant.ts index 54c0eec8..2e7b13ae 100644 --- a/apps/vscode-extension/src/chat/participant.ts +++ b/apps/vscode-extension/src/chat/participant.ts @@ -1,6 +1,7 @@ /** - * Chat participant @powermem: seamless memory write (auto-summarize every N turns) - * and retrieval (auto-retrieve on every question, answer with LLM + memories). + * Chat participant @powermem: when seamless mode is off, supports remember/save/search + * and auto-summarize + auto-retrieve. When seamless mode is on, memory is automatic + * via linked AI (MCP) and file auto-capture; this handler only shows a short redirect. */ import * as vscode from 'vscode'; @@ -78,12 +79,16 @@ async function summarizeWithModel( return out.trim(); } +const SEAMLESS_REDIRECT = + 'PowerMem is in **seamless mode**: memory is automatic. Use your linked AI (Cursor, Claude Code, Codex) for chat—they already have retrieval via MCP. File content is added to memory on save when auto-capture is on (default in seamless mode). Disable *Seamless mode* in PowerMem settings to use @powermem commands (remember / save / search).'; + export function registerChatParticipant( context: vscode.ExtensionContext, getBackendUrl: () => string, getApiKey: () => string | undefined, getUserId: () => string, getEnabled: () => boolean, + getSeamlessMode: () => boolean, getChatAutoSummarizeTurns: () => number, getChatAutoRetrieve: () => boolean ): void { @@ -95,6 +100,10 @@ export function registerChatParticipant( stream: vscode.ChatResponseStream, token: vscode.CancellationToken ): Promise => { + if (getSeamlessMode()) { + stream.markdown(SEAMLESS_REDIRECT); + return; + } const enabled = getEnabled(); const backendUrl = getBackendUrl(); if (!enabled || !backendUrl) { diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 73337577..b84f0654 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -22,6 +22,7 @@ let autoCaptureInclude = '**/*.md,**/*.txt,**/docs/**'; let autoCaptureMaxChars = 8000; let chatAutoSummarizeTurns = 10; let chatAutoRetrieve = true; +let seamlessMode = true; function getUseMCPFromConfig(config: vscode.WorkspaceConfiguration): boolean { const mode = config.get<'http' | 'mcp'>('connectionMode'); @@ -237,7 +238,11 @@ export function activate(context: vscode.ExtensionContext): void { apiKey = config.get('apiKey') || undefined; useMCP = getUseMCPFromConfig(config); mcpServerPath = config.get('mcpServerPath') || ''; - autoCaptureOnSave = config.get('autoCapture.onSave') ?? false; + seamlessMode = config.get('seamlessMode') ?? true; + // In seamless mode, default auto-capture on save to true so extraction is automatic + const explicitAutoCapture = config.inspect('autoCapture.onSave'); + const explicitOnSave = explicitAutoCapture?.workspaceValue ?? explicitAutoCapture?.globalValue; + autoCaptureOnSave = explicitOnSave !== undefined ? explicitOnSave : seamlessMode; autoCaptureInclude = config.get('autoCapture.include') ?? '**/*.md,**/*.txt,**/docs/**'; autoCaptureMaxChars = Math.max(500, config.get('autoCapture.maxChars') ?? 8000); chatAutoSummarizeTurns = Math.max(0, config.get('chat.autoSummarizeEveryNTurns') ?? 10); @@ -258,6 +263,7 @@ export function activate(context: vscode.ExtensionContext): void { () => apiKey, () => userId, () => isEnabled, + () => seamlessMode, () => chatAutoSummarizeTurns, () => chatAutoRetrieve ); @@ -347,7 +353,10 @@ export function activate(context: vscode.ExtensionContext): void { useMCP = getUseMCPFromConfig(c); mcpServerPath = c.get('mcpServerPath') || ''; isEnabled = c.get('enabled') ?? true; - autoCaptureOnSave = c.get('autoCapture.onSave') ?? false; + seamlessMode = c.get('seamlessMode') ?? true; + const explicitAutoCapture = c.inspect('autoCapture.onSave'); + const explicitOnSave = explicitAutoCapture?.workspaceValue ?? explicitAutoCapture?.globalValue; + autoCaptureOnSave = explicitOnSave !== undefined ? explicitOnSave : seamlessMode; autoCaptureInclude = c.get('autoCapture.include') ?? '**/*.md,**/*.txt,**/docs/**'; autoCaptureMaxChars = Math.max(500, c.get('autoCapture.maxChars') ?? 8000); chatAutoSummarizeTurns = Math.max(0, c.get('chat.autoSummarizeEveryNTurns') ?? 10); From 6f5c3a66f75b60cacbb92265e8088376931fe818 Mon Sep 17 00:00:00 2001 From: Teingi Date: Mon, 23 Mar 2026 16:10:37 +0800 Subject: [PATCH 05/10] fix(cli): load dotenv from POWERMEM_ENV_FILE for --env-file --- src/powermem/config_loader.py | 17 +++++++++++-- tests/unit/test_config_loader.py | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/powermem/config_loader.py b/src/powermem/config_loader.py index 479f3f7f..8581dbea 100644 --- a/src/powermem/config_loader.py +++ b/src/powermem/config_loader.py @@ -5,6 +5,8 @@ or other sources. It simplifies the configuration setup process. """ +import os +from pathlib import Path from typing import Any, Dict, Optional import warnings @@ -18,14 +20,25 @@ from powermem.settings import _DEFAULT_ENV_FILE, settings_config +def _resolve_dotenv_path() -> Optional[str]: + """Prefer POWERMEM_ENV_FILE (CLI --env-file) over auto-detected default .env.""" + explicit = os.environ.get("POWERMEM_ENV_FILE") + if explicit: + path = Path(os.path.expanduser(explicit)) + if path.is_file(): + return str(path) + return _DEFAULT_ENV_FILE + + def _load_dotenv_if_available() -> None: - if not _DEFAULT_ENV_FILE: + env_path = _resolve_dotenv_path() + if not env_path: return try: from dotenv import load_dotenv except Exception: return - load_dotenv(_DEFAULT_ENV_FILE, override=False) + load_dotenv(env_path, override=False) class _BasePowermemSettings(BaseSettings): diff --git a/tests/unit/test_config_loader.py b/tests/unit/test_config_loader.py index d1976fc2..0083c1d0 100644 --- a/tests/unit/test_config_loader.py +++ b/tests/unit/test_config_loader.py @@ -1,3 +1,5 @@ +import textwrap + import powermem.config_loader as config_loader import powermem.settings as settings @@ -186,3 +188,44 @@ def test_load_config_from_env_embedding_common_override(monkeypatch): assert config["embedder"]["provider"] == "azure_openai" assert config["embedder"]["config"]["api_key"] == "common-key" + + +def test_load_config_from_env_respects_powermem_env_file(tmp_path, monkeypatch): + """CLI --env-file sets POWERMEM_ENV_FILE; dotenv must load that file.""" + env_file = tmp_path / "custom.env" + env_file.write_text( + textwrap.dedent( + """ + DATABASE_PROVIDER=sqlite + SQLITE_PATH=/tmp/powermem_test.db + LLM_PROVIDER=qwen + LLM_API_KEY=from-custom-env-file + LLM_MODEL=qwen-plus + EMBEDDING_PROVIDER=qwen + EMBEDDING_API_KEY=from-custom-env-file-emb + EMBEDDING_MODEL=text-embedding-v4 + """ + ).strip(), + encoding="utf-8", + ) + _reset_env( + monkeypatch, + [ + "DATABASE_PROVIDER", + "SQLITE_PATH", + "LLM_PROVIDER", + "LLM_API_KEY", + "LLM_MODEL", + "EMBEDDING_PROVIDER", + "EMBEDDING_API_KEY", + "EMBEDDING_MODEL", + "POWERMEM_ENV_FILE", + ], + ) + _disable_env_file(monkeypatch) + monkeypatch.setenv("POWERMEM_ENV_FILE", str(env_file)) + + config = config_loader.load_config_from_env() + + assert config["llm"]["config"]["api_key"] == "from-custom-env-file" + assert config["embedder"]["config"]["api_key"] == "from-custom-env-file-emb" From 3aed18b6f09ebbfebcc9e839a2ea45190a2a34f1 Mon Sep 17 00:00:00 2001 From: Teingi Date: Mon, 23 Mar 2026 16:14:39 +0800 Subject: [PATCH 06/10] fix(cli): load dotenv from POWERMEM_ENV_FILE for --env-file --- Makefile | 12 ++++++++---- pyproject.toml | 2 +- src/powermem/core/audit.py | 2 +- src/powermem/core/telemetry.py | 4 ++-- src/powermem/version.py | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index b65366dd..a4e94eae 100644 --- a/Makefile +++ b/Makefile @@ -176,6 +176,10 @@ setup-env: ## Setup development environment python scripts/setup.py # Version management +# macOS BSD sed: -i requires a backup extension; use '' for in-place without backup. +# GNU sed accepts plain -i; '' is also valid. \+ in patterns is GNU-specific; use -E and + for portability. +SED_INPLACE := $(if $(filter Darwin,$(shell uname -s)),sed -i '',sed -i) + bump-version: ## Bump version number (usage: make bump-version VERSION=0.2.0) @if [ -z "$(VERSION)" ]; then \ echo "Error: VERSION is required. Usage: make bump-version VERSION=0.2.0"; \ @@ -183,13 +187,13 @@ bump-version: ## Bump version number (usage: make bump-version VERSION=0.2.0) fi @echo "Bumping version to $(VERSION)..." @# Update pyproject.toml - @sed -i 's/^version = ".*"/version = "$(VERSION)"/' pyproject.toml + @$(SED_INPLACE) 's/^version = ".*"/version = "$(VERSION)"/' pyproject.toml @# Update src/powermem/version.py - @sed -i 's/^__version__ = ".*"/__version__ = "$(VERSION)"/' src/powermem/version.py + @$(SED_INPLACE) 's/^__version__ = ".*"/__version__ = "$(VERSION)"/' src/powermem/version.py @# Update src/powermem/core/telemetry.py (all occurrences; match any x.y.z) - @sed -i 's/"version": "[0-9]\+\.[0-9]\+\.[0-9]\+"/"version": "$(VERSION)"/g' src/powermem/core/telemetry.py + @$(SED_INPLACE) -E 's/"version": "[0-9]+\.[0-9]+\.[0-9]+"/"version": "$(VERSION)"/g' src/powermem/core/telemetry.py @# Update src/powermem/core/audit.py (match any x.y.z) - @sed -i 's/"version": "[0-9]\+\.[0-9]\+\.[0-9]\+"/"version": "$(VERSION)"/g' src/powermem/core/audit.py + @$(SED_INPLACE) -E 's/"version": "[0-9]+\.[0-9]+\.[0-9]+"/"version": "$(VERSION)"/g' src/powermem/core/audit.py @echo "✓ Version updated to $(VERSION) in all files (excluding examples/)" @echo "" @echo "Updated files:" diff --git a/pyproject.toml b/pyproject.toml index 24b0f49b..554b94f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "powermem" -version = "1.0.1" +version = "1.0.2" description = "Intelligent Memory System - Persistent memory layer for LLM applications" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/src/powermem/core/audit.py b/src/powermem/core/audit.py index b93ca297..ff993ffc 100644 --- a/src/powermem/core/audit.py +++ b/src/powermem/core/audit.py @@ -92,7 +92,7 @@ def log_event( "user_id": user_id, "agent_id": agent_id, "details": details, - "version": "1.0.1", + "version": "1.0.2", } # Log to file diff --git a/src/powermem/core/telemetry.py b/src/powermem/core/telemetry.py index 18a98354..a0162968 100644 --- a/src/powermem/core/telemetry.py +++ b/src/powermem/core/telemetry.py @@ -82,7 +82,7 @@ def capture_event( "user_id": user_id, "agent_id": agent_id, "timestamp": get_current_datetime().isoformat(), - "version": "1.0.1", + "version": "1.0.2", } self.events.append(event) @@ -182,7 +182,7 @@ def set_user_properties(self, user_id: str, properties: Dict[str, Any]) -> None: "properties": properties, "user_id": user_id, "timestamp": get_current_datetime().isoformat(), - "version": "1.0.1", + "version": "1.0.2", } self.events.append(event) diff --git a/src/powermem/version.py b/src/powermem/version.py index 4494f015..3ac559be 100644 --- a/src/powermem/version.py +++ b/src/powermem/version.py @@ -2,7 +2,7 @@ Version information management """ -__version__ = "1.0.1" +__version__ = "1.0.2" __version_info__ = tuple(map(int, __version__.split("."))) # Version history From d3e15e811475e6156e7cc033a90a6858b4419903 Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 26 Mar 2026 14:28:00 +0800 Subject: [PATCH 07/10] fix --- src/powermem/config_loader.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/powermem/config_loader.py b/src/powermem/config_loader.py index cb395b98..ce4e5f68 100644 --- a/src/powermem/config_loader.py +++ b/src/powermem/config_loader.py @@ -19,16 +19,6 @@ from powermem.settings import _DEFAULT_ENV_FILE, settings_config -def _resolve_dotenv_path() -> Optional[str]: - """Prefer POWERMEM_ENV_FILE (CLI --env-file) over auto-detected default .env.""" - explicit = os.environ.get("POWERMEM_ENV_FILE") - if explicit: - path = Path(os.path.expanduser(explicit)) - if path.is_file(): - return str(path) - return _DEFAULT_ENV_FILE - - def _load_dotenv_if_available() -> None: """ Load env files into os.environ before BaseSettings / Memory read configuration. From 674cd08cd08afbafa214234e6ebf2a793757cfb4 Mon Sep 17 00:00:00 2001 From: Teingi Date: Thu, 26 Mar 2026 21:15:14 +0800 Subject: [PATCH 08/10] fix --- .github/workflows/plugins-build.yml | 6 +++--- .github/workflows/publish.yml | 2 ++ apps/claude-code-plugin/.claude-plugin/plugin.json | 2 +- apps/claude-code-plugin/CHANGELOG.md | 2 +- apps/vscode-extension/package-lock.json | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/plugins-build.yml b/.github/workflows/plugins-build.yml index 54bc2dcc..5ecc1fbb 100644 --- a/.github/workflows/plugins-build.yml +++ b/.github/workflows/plugins-build.yml @@ -125,10 +125,10 @@ jobs: body: | ## PowerMem IDE Plugins - - **PowerMem for VS Code** (`.vsix`): Install in VS Code/Cursor via Install from VSIX, or publish to marketplace. - - **PowerMem Claude Code Plugin** (`.zip`): **HTTP mode by default** (REST hooks; empty MCP). Cross-compiled hook binaries included. Unzip, then `claude --plugin-dir /path/to/powermem-claude-code-plugin`. Hooks default to `http://localhost:8000`; set `POWERMEM_BASE_URL` for a remote server. For in-chat tools, copy `config/mcp-mode.mcp.json` to `.mcp.json` (see plugin README). + - **PowerMem for VS Code** (`.vsix`): Download and install from VSIX in VS Code or Cursor. + - **PowerMem for Claude Code** (`.zip`): Download, unzip, and start Claude with `claude --plugin-dir /path/to/powermem-claude-code-plugin`. - See [apps/README.md](https://github.com/${{ github.repository }}/blob/main/apps/README.md) and [apps/TESTING.md](https://github.com/${{ github.repository }}/blob/main/apps/TESTING.md). + See [apps/README.md](https://github.com/${{ github.repository }}/blob/main/apps/README.md). files: | vsix/*.vsix zip/powermem-claude-code-plugin-*.zip diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ef5f4934..5f64098f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,7 @@ permissions: jobs: release-build: + if: startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'plugins-') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -57,6 +58,7 @@ jobs: path: dist/ pypi-publish: + if: startsWith(github.event.release.tag_name, 'v') && !startsWith(github.event.release.tag_name, 'plugins-') runs-on: ubuntu-latest needs: - release-build diff --git a/apps/claude-code-plugin/.claude-plugin/plugin.json b/apps/claude-code-plugin/.claude-plugin/plugin.json index 5b3e287f..1f4c0ab5 100644 --- a/apps/claude-code-plugin/.claude-plugin/plugin.json +++ b/apps/claude-code-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memory-powermem", "description": "PowerMem intelligent memory for Claude Code: add, search, update, and delete memories with Ebbinghaus decay and multi-agent support.", - "version": "1.0.0", + "version": "0.1.0", "author": { "name": "OceanBase / PowerMem" }, "homepage": "https://github.com/oceanbase/powermem", "repository": "https://github.com/oceanbase/powermem" diff --git a/apps/claude-code-plugin/CHANGELOG.md b/apps/claude-code-plugin/CHANGELOG.md index d2ae59c5..b406c895 100644 --- a/apps/claude-code-plugin/CHANGELOG.md +++ b/apps/claude-code-plugin/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0 +## 0.1.0 Initial release of the PowerMem plugin for Claude Code. diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json index caafa4a9..45ccb6a0 100644 --- a/apps/vscode-extension/package-lock.json +++ b/apps/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "powermem-vscode", - "version": "1.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "powermem-vscode", - "version": "1.0.0", + "version": "0.1.0", "devDependencies": { "@types/node": "^18.x", "@types/vscode": "^1.104.0", From fcf341f548bb5c1d10e35d257fba35e1304382d6 Mon Sep 17 00:00:00 2001 From: Teingi Date: Tue, 7 Apr 2026 11:04:23 +0800 Subject: [PATCH 09/10] feat(claude-code-plugin): default POWERMEM_PROMPT_SEARCH to on --- apps/claude-code-plugin/CHANGELOG.md | 6 +++++- apps/claude-code-plugin/README.md | 16 ++++++++-------- .../claude-code-plugin/cmd/powermem-hook/main.go | 6 +++--- apps/claude-code-plugin/hooks/hooks.json | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/claude-code-plugin/CHANGELOG.md b/apps/claude-code-plugin/CHANGELOG.md index b406c895..78d737d5 100644 --- a/apps/claude-code-plugin/CHANGELOG.md +++ b/apps/claude-code-plugin/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **UserPromptSubmit:** `POWERMEM_PROMPT_SEARCH` defaults to **on** (`POST /api/v1/memories/search` + `additionalContext` per prompt). Set `0`, `false`, `no`, or `off` to disable. + ## 0.1.0 Initial release of the PowerMem plugin for Claude Code. @@ -17,7 +21,7 @@ Initial release of the PowerMem plugin for Claude Code. - **SessionEnd:** Upload full session transcript to `POST /api/v1/memories` (detached worker so large uploads do not block exit). - **PostCompact:** Upload compact summary to `POST /api/v1/memories`. -- **UserPromptSubmit (optional):** When `POWERMEM_PROMPT_SEARCH=1`, `POST /api/v1/memories/search` and inject hits via `additionalContext` (works in HTTP and MCP modes; off by default). +- **UserPromptSubmit (optional):** When `POWERMEM_PROMPT_SEARCH=1`, `POST /api/v1/memories/search` and inject hits via `additionalContext` (works in HTTP and MCP modes; off by default in this release). **Other** diff --git a/apps/claude-code-plugin/README.md b/apps/claude-code-plugin/README.md index eca887c1..a5853192 100644 --- a/apps/claude-code-plugin/README.md +++ b/apps/claude-code-plugin/README.md @@ -9,7 +9,7 @@ Claude Code plugin that connects to [PowerMem](https://github.com/oceanbase/powe - **MCP mode (optional)**: Copy [`config/mcp-mode.mcp.json`](config/mcp-mode.mcp.json) to `.mcp.json` (or run `apply-connection-mode.sh mcp`). Claude gets PowerMem tools over **HTTP** `…/mcp` or **stdio**. - **Skills**: `/memory-powermem:remember` and `/memory-powermem:recall` — effective in **MCP mode**; in default HTTP mode they cannot drive tools. - **Seamless REST capture**: Hooks run in **both** modes. Optional **file poller** — see [watcher/README.md](watcher/README.md). -- **Optional auto-retrieval (no MCP required)**: A `UserPromptSubmit` hook can call **`POST /api/v1/memories/search`** with the user’s prompt and inject hits into Claude’s context via [`additionalContext`](https://code.claude.com/docs/en/hooks#userpromptsubmit). Enable with **`POWERMEM_PROMPT_SEARCH=1`** (off by default — each turn adds a search round-trip). Works in **HTTP and MCP** modes. +- **Auto-retrieval (no MCP required, on by default)**: The `UserPromptSubmit` hook calls **`POST /api/v1/memories/search`** with the user’s prompt and injects hits via [`additionalContext`](https://code.claude.com/docs/en/hooks#userpromptsubmit). Set **`POWERMEM_PROMPT_SEARCH=0`** (or `false` / `no` / `off`) to disable — saves a search round-trip per turn. Works in **HTTP and MCP** modes. ## Runtime requirements (end users) @@ -164,7 +164,7 @@ The plugin ships [`hooks/hooks.json`](hooks/hooks.json), [`hooks/run-hook.sh`](h | Hook | What happens | |------|----------------| -| `UserPromptSubmit` | If **`POWERMEM_PROMPT_SEARCH=1`**: **`POST …/api/v1/memories/search`** with the submitted `prompt`; top results are injected as **additional context** for that turn ([Claude Code hooks](https://code.claude.com/docs/en/hooks#userpromptsubmit)). If unset, the hook no-ops (still registered; overhead is small). | +| `UserPromptSubmit` | By default, **`POST …/api/v1/memories/search`** with the submitted `prompt`; top results are injected as **additional context** for that turn ([Claude Code hooks](https://code.claude.com/docs/en/hooks#userpromptsubmit)). Set **`POWERMEM_PROMPT_SEARCH=0`** (or `false` / `no` / `off`) to skip search (hook still registered; overhead is small when disabled). | | `SessionEnd` | Full **transcript** from `transcript_path` (parsed JSONL: user/assistant/summary lines) → **`POST …/api/v1/memories`**. | | `PostCompact` | The **`compact_summary`** field after `/compact` or auto-compact → **`POST …/api/v1/memories`**. | @@ -181,7 +181,7 @@ Optional environment variables (where you launch Claude Code): | `POWERMEM_HOOK_MAX_CHARS` | No | Transcript cap (default `120000`) | | `POWERMEM_INFER_TRANSCRIPT` | No | Set `1` to enable server-side infer on large transcripts (default off) | | `POWERMEM_INFER_COMPACT` | No | Set `0` to disable infer on compact summaries (default on) | -| `POWERMEM_PROMPT_SEARCH` | No | Set **`1`** (or `true` / `yes` / `on`) to **inject semantic search results** on every user prompt via `UserPromptSubmit`. Default: off. | +| `POWERMEM_PROMPT_SEARCH` | No | **Default: on** — injects semantic search results on every user prompt via `UserPromptSubmit`. Set **`0`** / **`false`** / **`no`** / **`off`** to disable. | | `POWERMEM_PROMPT_SEARCH_LIMIT` | No | Max memories returned per prompt (default **8**, cap **30**). | | `POWERMEM_PROMPT_SEARCH_MAX_CHARS` | No | Cap on injected context string (default **24000**). | @@ -191,8 +191,8 @@ Optional environment variables (where you launch Claude Code): What you see is often **expected**: -1. **Default HTTP mode** — There are **no** PowerMem MCP tools during chat, so Claude does **not** call `/mcp` or `POST /api/v1/memories` on each message. -2. **Hooks are not per-turn** — `SessionEnd` runs when the **session ends** (quit, `/clear`, `/resume` switch, etc.). `PostCompact` runs after **manual or auto compact**, not after every reply. **Normal back-and-forth coding does not trigger a hook.** +1. **Default HTTP mode** — There are **no** PowerMem MCP tools during chat, so Claude does **not** call `/mcp` on each message. **`POST /api/v1/memories`** (writes) still come from **`SessionEnd`** / **`PostCompact`**, not every reply. By default, **`POST /api/v1/memories/search`** runs **on each user message** via `UserPromptSubmit`; set **`POWERMEM_PROMPT_SEARCH=0`** to turn that off. +2. **Not every hook is per-turn** — `SessionEnd` runs when the **session ends** (quit, `/clear`, `/resume` switch, etc.). `PostCompact` runs after **manual or auto compact**, not after every reply. 3. **Those GETs** (`/system/status`, `/memories/stats`, …) usually come from another client (e.g. **PowerMem VS Code extension** dashboard), not from Claude Code hooks. **How to verify hooks:** @@ -203,7 +203,7 @@ What you see is often **expected**: **If you want traffic during the conversation:** -- Set **`POWERMEM_PROMPT_SEARCH=1`** so each user message triggers **`POST /api/v1/memories/search`** and retrieved memories are **injected automatically** (no MCP tools needed). +- **`POWERMEM_PROMPT_SEARCH` is on by default**, so each user message triggers **`POST /api/v1/memories/search`** and retrieved memories are **injected automatically** (no MCP tools needed). Set **`POWERMEM_PROMPT_SEARCH=0`** to turn that off. - Or switch to **MCP mode** (`bash scripts/apply-connection-mode.sh mcp`) so Claude can call memory tools when it chooses — traffic goes to **`/mcp`**, not necessarily the same paths as the dashboard GETs. - Or rely on **VS Code extension** save capture / `sh hooks/run-hook.sh poll` for file-based writes. @@ -222,8 +222,8 @@ See [watcher/README.md](watcher/README.md) for environment variables. ## Usage -- **Default (HTTP mode):** Hooks capture to REST automatically; no PowerMem tools in chat. For **automatic retrieval each turn**, set **`POWERMEM_PROMPT_SEARCH=1`** (see [Seamless recording](#seamless-recording-hooks--http-api)). -- **MCP mode:** Run `apply-connection-mode.sh mcp`, then PowerMem tools appear; use **/memory-powermem:remember** / **recall** with real tool backing. You can combine MCP tools with **`POWERMEM_PROMPT_SEARCH=1`** if you want both explicit tool use and per-prompt injection. +- **Default (HTTP mode):** Hooks capture to REST automatically; no PowerMem tools in chat. **Per-prompt semantic retrieval is on by default** (see [Seamless recording](#seamless-recording-hooks--http-api)); set **`POWERMEM_PROMPT_SEARCH=0`** to disable. +- **MCP mode:** Run `apply-connection-mode.sh mcp`, then PowerMem tools appear; use **/memory-powermem:remember** / **recall** with real tool backing. Per-prompt injection stays **on by default**; set **`POWERMEM_PROMPT_SEARCH=0`** if you only want explicit MCP tool use. - In **both** modes, transcript/compact hooks write to REST (`POWERMEM_BASE_URL`, default `http://localhost:8000`) without the model calling tools. ## Links diff --git a/apps/claude-code-plugin/cmd/powermem-hook/main.go b/apps/claude-code-plugin/cmd/powermem-hook/main.go index d8106da5..d03654f7 100644 --- a/apps/claude-code-plugin/cmd/powermem-hook/main.go +++ b/apps/claude-code-plugin/cmd/powermem-hook/main.go @@ -157,10 +157,10 @@ func inferFile() bool { func promptSearchEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv("POWERMEM_PROMPT_SEARCH"))) { - case "1", "true", "yes", "on": - return true - default: + case "0", "false", "no", "off": return false + default: + return true } } diff --git a/apps/claude-code-plugin/hooks/hooks.json b/apps/claude-code-plugin/hooks/hooks.json index f51cde7e..a66e6f5d 100644 --- a/apps/claude-code-plugin/hooks/hooks.json +++ b/apps/claude-code-plugin/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Push Claude Code session transcripts (SessionEnd) and compact summaries (PostCompact) to PowerMem via HTTP. Optional UserPromptSubmit: semantic search injects context when POWERMEM_PROMPT_SEARCH=1. Uses native binaries under hooks/bin. macOS/Linux: sh launcher. Windows without sh: merge hooks/hooks.windows.example.json. POWERMEM_BASE_URL defaults to http://localhost:8000 if unset.", + "description": "Push Claude Code session transcripts (SessionEnd) and compact summaries (PostCompact) to PowerMem via HTTP. UserPromptSubmit: semantic search injects context by default; set POWERMEM_PROMPT_SEARCH=0 (or false/no/off) to disable. Uses native binaries under hooks/bin. macOS/Linux: sh launcher. Windows without sh: merge hooks/hooks.windows.example.json. POWERMEM_BASE_URL defaults to http://localhost:8000 if unset.", "hooks": { "UserPromptSubmit": [ { From d6103c98fad9ebc535114b7ba4258c6773133568 Mon Sep 17 00:00:00 2001 From: Teingi Date: Tue, 7 Apr 2026 11:13:12 +0800 Subject: [PATCH 10/10] docs: fix OpenClaw GitHub URL in README files --- README.md | 2 +- README_CN.md | 2 +- README_JP.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e1d9f41d..111aafba 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

-*PowerMem integrated with [OpenClaw](https://github.com/openclaw-ai/openclaw): intelligent memory for AI agents. **OpenClaw PowerMem Plugin**: [View Plugin](https://github.com/ob-labs/memory-powermem)* +*PowerMem integrated with [OpenClaw](https://github.com/openclaw/openclaw): intelligent memory for AI agents. **OpenClaw PowerMem Plugin**: [View Plugin](https://github.com/ob-labs/memory-powermem)* One command to add PowerMem memory to OpenClaw: `openclaw plugins install memory-powermem`. diff --git a/README_CN.md b/README_CN.md index ff82e905..fbded48b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -6,7 +6,7 @@

-*PowerMem 与 [OpenClaw](https://github.com/openclaw-ai/openclaw) 集成:为 AI 智能体提供智能记忆。**OpenClaw PowerMem 记忆插件**:[查看插件](https://github.com/ob-labs/memory-powermem)* +*PowerMem 与 [OpenClaw](https://github.com/openclaw/openclaw) 集成:为 AI 智能体提供智能记忆。**OpenClaw PowerMem 记忆插件**:[查看插件](https://github.com/ob-labs/memory-powermem)* 一行命令即可为 OpenClaw 接入 PowerMem 记忆:`openclaw plugins install memory-powermem`。 diff --git a/README_JP.md b/README_JP.md index 59ba2895..95ab29b1 100644 --- a/README_JP.md +++ b/README_JP.md @@ -6,7 +6,7 @@

-*PowerMem と [OpenClaw](https://github.com/openclaw-ai/openclaw) の連携:AI エージェント向けインテリジェントメモリ。**OpenClaw PowerMem メモリプラグイン**:[プラグインを見る](https://github.com/ob-labs/memory-powermem)* +*PowerMem と [OpenClaw](https://github.com/openclaw/openclaw) の連携:AI エージェント向けインテリジェントメモリ。**OpenClaw PowerMem メモリプラグイン**:[プラグインを見る](https://github.com/ob-labs/memory-powermem)* 1 コマンドで OpenClaw に PowerMem メモリを追加:`openclaw plugins install memory-powermem`。