diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d5504cc2..be9a6949 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]
@@ -139,7 +140,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..5ecc1fbb
--- /dev/null
+++ b/.github/workflows/plugins-build.yml
@@ -0,0 +1,140 @@
+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'
+ GO_VERSION: '1.22.x'
+
+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: 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: apps/claude-code-plugin/dist/powermem-claude-code-plugin-*.zip
+ if-no-files-found: error
+ 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`): 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).
+ 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/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 6f461ea5..8c7db0e3 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/.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/Makefile b/Makefile
index 122953bf..a4e94eae 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
@@ -170,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"; \
@@ -177,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/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`。
diff --git a/apps/README.md b/apps/README.md
new file mode 100644
index 00000000..6a63e1a7
--- /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 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**: `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/.claude-plugin/plugin.json b/apps/claude-code-plugin/.claude-plugin/plugin.json
new file mode 100644
index 00000000..1f4c0ab5
--- /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": "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/.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
new file mode 100644
index 00000000..da39e4ff
--- /dev/null
+++ b/apps/claude-code-plugin/.mcp.json
@@ -0,0 +1,3 @@
+{
+ "mcpServers": {}
+}
diff --git a/apps/claude-code-plugin/CHANGELOG.md b/apps/claude-code-plugin/CHANGELOG.md
new file mode 100644
index 00000000..78d737d5
--- /dev/null
+++ b/apps/claude-code-plugin/CHANGELOG.md
@@ -0,0 +1,30 @@
+# 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.
+
+**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 in this release).
+
+**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
new file mode 100644
index 00000000..a5853192
--- /dev/null
+++ b/apps/claude-code-plugin/README.md
@@ -0,0 +1,233 @@
+# PowerMem Plugin for Claude Code
+
+Claude Code plugin that connects to [PowerMem](https://github.com/oceanbase/powermem) for intelligent, persistent memory.
+
+## Features
+
+- **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).
+- **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)
+
+| 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.
+
+**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).
+
+**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
+
+### 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.
+
+### 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
+
+### 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
+```
+
+Restart Claude Code after changing `.mcp.json`. See [`config/README.md`](config/README.md).
+
+**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
+{
+ "mcpServers": {
+ "powermem": {
+ "transport": "stdio",
+ "command": "uvx",
+ "args": ["powermem-mcp", "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` | 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`**. |
+
+**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 | **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**). |
+
+**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` 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:**
+
+- **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:**
+
+- **`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.
+
+### 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
+
+- **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
+
+- [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..d03654f7
--- /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 "0", "false", "no", "off":
+ return false
+ default:
+ return true
+ }
+}
+
+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..a66e6f5d
--- /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. 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": [
+ {
+ "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/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/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/.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..45ccb6a0
--- /dev/null
+++ b/apps/vscode-extension/package-lock.json
@@ -0,0 +1,58 @@
+{
+ "name": "powermem-vscode",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "powermem-vscode",
+ "version": "0.1.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..94267597
--- /dev/null
+++ b/apps/vscode-extension/package.json
@@ -0,0 +1,139 @@
+{
+ "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": "0.1.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": {
+ "chatParticipants": [
+ {
+ "id": "powermem-vscode.powermem",
+ "name": "powermem",
+ "fullName": "PowerMem",
+ "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" },
+ { "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" },
+ { "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.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 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 your server does not require auth."
+ },
+ "powermem.useMCP": {
+ "type": "boolean",
+ "default": true,
+ "description": "Deprecated: use \"Connection Mode\" instead. When false, same as HTTP mode; when true, same as MCP mode."
+ },
+ "powermem.mcpServerPath": {
+ "type": "string",
+ "default": "",
+ "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."
+ },
+ "powermem.projectName": {
+ "type": "string",
+ "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). In seamless mode, defaults to true unless set explicitly. 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."
+ }
+ }
+ }
+ },
+ "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/chat/participant.ts b/apps/vscode-extension/src/chat/participant.ts
new file mode 100644
index 00000000..2e7b13ae
--- /dev/null
+++ b/apps/vscode-extension/src/chat/participant.ts
@@ -0,0 +1,271 @@
+/**
+ * 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';
+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();
+}
+
+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 {
+ 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 => {
+ if (getSeamlessMode()) {
+ stream.markdown(SEAMLESS_REDIRECT);
+ return;
+ }
+ 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/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..b84f0654
--- /dev/null
+++ b/apps/vscode-extension/src/extension.ts
@@ -0,0 +1,399 @@
+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';
+import { registerChatParticipant } from './chat/participant';
+
+let backendUrl = 'http://localhost:8000';
+let apiKey: string | undefined;
+let statusBar: vscode.StatusBarItem;
+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;
+let seamlessMode = 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');
+ 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('connectionMode', useMCP ? 'mcp' : 'http', 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 = getUseMCPFromConfig(config);
+ mcpServerPath = config.get('mcpServerPath') || '';
+ 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);
+ chatAutoRetrieve = config.get('chat.autoRetrieve') ?? true;
+ 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())
+ );
+
+ registerChatParticipant(
+ context,
+ () => backendUrl,
+ () => apiKey,
+ () => userId,
+ () => isEnabled,
+ () => seamlessMode,
+ () => chatAutoSummarizeTurns,
+ () => chatAutoRetrieve
+ );
+
+ 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 = getUseMCPFromConfig(c);
+ mcpServerPath = c.get('mcpServerPath') || '';
+ isEnabled = c.get('enabled') ?? true;
+ 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);
+ 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
+ }
+ })
+ );
+}
+
+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..dee7bd64
--- /dev/null
+++ b/apps/vscode-extension/src/writers/claude.ts
@@ -0,0 +1,59 @@
+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` },
+ },
+ };
+ }
+ // HTTP mode: do not write MCP config so the client does not call /mcp
+ return { mcpServers: {} };
+}
+
+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..56b1cdf7
--- /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` },
+ },
+ };
+ }
+ // HTTP mode: do not add MCP config; caller will remove existing powermem entry
+ return { mcpServers: {} };
+}
+
+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 },
+ };
+ if (!useMCP && merged.mcpServers) {
+ delete merged.mcpServers.powermem;
+ }
+ 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"]
+}