diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ccf7e25..7f4a4efa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,6 @@ name: Build Package +# Main PowerMem release (Python packages). Does not run on plugin-only tags (plugins-v*). on: push: branches: [main, develop] @@ -140,7 +141,8 @@ jobs: build-and-release: runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + # Only release on version tags v*; do not run for plugin-only tags (plugins-v*) + if: startsWith(github.ref, 'refs/tags/v') && !startsWith(github.ref, 'refs/tags/plugins-') needs: combine-artifacts permissions: contents: write diff --git a/.github/workflows/plugins-build.yml b/.github/workflows/plugins-build.yml new file mode 100644 index 00000000..37762c59 --- /dev/null +++ b/.github/workflows/plugins-build.yml @@ -0,0 +1,135 @@ +name: Plugins Build and Release + +on: + push: + branches: [main, develop] + paths: + - 'apps/**' + - '.github/workflows/plugins-build.yml' + tags: + - 'plugins-v*' + pull_request: + branches: [main, develop] + paths: + - 'apps/**' + - '.github/workflows/plugins-build.yml' + workflow_dispatch: + inputs: + create_release: + description: 'Create a GitHub Release with plugin assets (only if not tag)' + required: false + default: false + type: boolean + +env: + NODE_VERSION: '20' + +jobs: + build-vscode-extension: + name: Build VS Code Extension + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/vscode-extension + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: apps/vscode-extension/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Compile + run: npm run compile + + - name: Install vsce + run: npm install -g @vscode/vsce + + - name: Package .vsix + run: vsce package --no-dependencies + id: vsce + + - name: Upload VS Code extension (.vsix) + uses: actions/upload-artifact@v4 + with: + name: powermem-vscode-vsix + path: apps/vscode-extension/*.vsix + retention-days: 30 + + package-claude-plugin: + name: Package Claude Code Plugin + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create plugin zip + run: | + cd apps/claude-code-plugin + zip -r ../../powermem-claude-code-plugin.zip . -x "*.git*" -x ".DS_Store" + + - name: Upload Claude Code plugin (zip) + uses: actions/upload-artifact@v4 + with: + name: powermem-claude-code-plugin-zip + path: powermem-claude-code-plugin.zip + retention-days: 30 + release-plugins: + name: Release Plugin Assets + runs-on: ubuntu-latest + needs: [build-vscode-extension, package-claude-plugin] + if: startsWith(github.ref, 'refs/tags/plugins-') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true') + permissions: + contents: write + + steps: + - name: Download VS Code extension + uses: actions/download-artifact@v4 + with: + name: powermem-vscode-vsix + path: vsix + + - name: Download Claude Code plugin + uses: actions/download-artifact@v4 + with: + name: powermem-claude-code-plugin-zip + path: zip + + - name: Get version from tag or default + id: ver + run: | + if [[ "${{ github.ref }}" == refs/tags/plugins-* ]]; then + echo "version=${GITHUB_REF#refs/tags/plugins-}" >> $GITHUB_OUTPUT + else + echo "version=dev-$(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('plugins-{0}', steps.ver.outputs.version) }} + name: Plugins ${{ steps.ver.outputs.version }} + body: | + ## PowerMem IDE Plugins + + - **PowerMem for VS Code** (`.vsix`): Install in VS Code/Cursor via Install from VSIX, or publish to marketplace. + - **PowerMem Claude Code Plugin** (`.zip`): Use with `claude --plugin-dir /path/to/unzipped-folder`. + + See [apps/README.md](https://github.com/${{ github.repository }}/blob/main/apps/README.md) and [apps/TESTING.md](https://github.com/${{ github.repository }}/blob/main/apps/TESTING.md). + files: | + vsix/*.vsix + zip/powermem-claude-code-plugin.zip + draft: false + prerelease: ${{ contains(github.ref, 'refs/tags/') == false }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/plugins-publish-vscode.yml b/.github/workflows/plugins-publish-vscode.yml new file mode 100644 index 00000000..4433c5ea --- /dev/null +++ b/.github/workflows/plugins-publish-vscode.yml @@ -0,0 +1,64 @@ +# Publish VS Code extension to Visual Studio Marketplace / Open VSX (manual trigger only). +# Configure in repo Settings -> Secrets and variables -> Actions: +# - VSCE_PAT: Personal Access Token for Visual Studio Marketplace +# (https://dev.azure.com -> User settings -> Personal access tokens; scope must include Marketplace) +# - OVSX_PAT: Token for Open VSX (optional; create at https://open-vsx.org after sign-in) + +name: Publish VS Code Extension + +on: + workflow_dispatch: + inputs: + publish_vsce: + description: 'Publish to Visual Studio Marketplace' + required: false + default: true + type: boolean + publish_ovsx: + description: 'Publish to Open VSX' + required: false + default: false + type: boolean + +jobs: + publish: + name: Publish to Marketplace(s) + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: apps/vscode-extension/package-lock.json + + - name: Build .vsix + run: | + cd apps/vscode-extension + npm ci + npm run compile + npx @vscode/vsce package --no-dependencies + mkdir -p ../.vsix + cp *.vsix ../.vsix/ + + - name: Publish to Visual Studio Marketplace + if: github.event.inputs.publish_vsce == 'true' + env: + VSCE_TOKEN: ${{ secrets.VSCE_PAT }} + run: | + cd apps/vscode-extension + npx @vscode/vsce publish --pat $VSCE_TOKEN -i ../.vsix/*.vsix + + - name: Publish to Open VSX + if: github.event.inputs.publish_ovsx == 'true' + env: + OVSX_TOKEN: ${{ secrets.OVSX_PAT }} + run: | + cd apps/vscode-extension + npx @vscode/vsce publish --pat $OVSX_TOKEN -i ../.vsix/*.vsix --registryUrl https://open-vsx.org diff --git a/.gitignore b/.gitignore index 77aadf42..5421756b 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,8 @@ api_data/ server_backup/ *.backup +# apps/vscode-extension (Node / VS Code) +apps/vscode-extension/node_modules/ +apps/vscode-extension/out/ +apps/vscode-extension/*.vsix + diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 00000000..dd19bc82 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,16 @@ +# PowerMem IDE Apps + +## Contents + +| Directory | Description | +|-----------|--------------| +| **vscode-extension** | VS Code extension that links PowerMem to Cursor, Claude Code, Codex, Windsurf, and Copilot. Provides commands: Query memories, Add selection, Quick note, Link to AI tools, Setup, Dashboard. | +| **claude-code-plugin** | Claude Code–only plugin (`.claude-plugin` + `.mcp.json` + skills). Use with `claude --plugin-dir apps/claude-code-plugin` or publish to a Claude Code plugin marketplace. | + +## Quick start + +1. **Backend**: Start PowerMem (e.g. `powermem-server --port 8000` or `uvx powermem-mcp sse`). +2. **VS Code / Cursor**: Install the extension from `vscode-extension/` (Run and Debug or package as `.vsix`), set backend URL in PowerMem settings, then use **PowerMem: Link to AI tools**. +3. **Claude Code only**: Load the plugin with `claude --plugin-dir /path/to/powermem/apps/claude-code-plugin`. + +See each subdirectory’s `README.md` for details. diff --git a/apps/claude-code-plugin/.claude-plugin/plugin.json b/apps/claude-code-plugin/.claude-plugin/plugin.json new file mode 100644 index 00000000..5b3e287f --- /dev/null +++ b/apps/claude-code-plugin/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "memory-powermem", + "description": "PowerMem intelligent memory for Claude Code: add, search, update, and delete memories with Ebbinghaus decay and multi-agent support.", + "version": "1.0.0", + "author": { "name": "OceanBase / PowerMem" }, + "homepage": "https://github.com/oceanbase/powermem", + "repository": "https://github.com/oceanbase/powermem" +} diff --git a/apps/claude-code-plugin/.mcp.json b/apps/claude-code-plugin/.mcp.json new file mode 100644 index 00000000..c300597c --- /dev/null +++ b/apps/claude-code-plugin/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "powermem": { + "transport": "http", + "url": "http://localhost:8000/mcp" + } + } +} diff --git a/apps/claude-code-plugin/CHANGELOG.md b/apps/claude-code-plugin/CHANGELOG.md new file mode 100644 index 00000000..39eb9741 --- /dev/null +++ b/apps/claude-code-plugin/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- Initial release: PowerMem MCP config and skills (remember, recall) for Claude Code. diff --git a/apps/claude-code-plugin/README.md b/apps/claude-code-plugin/README.md new file mode 100644 index 00000000..d22a1014 --- /dev/null +++ b/apps/claude-code-plugin/README.md @@ -0,0 +1,61 @@ +# PowerMem Plugin for Claude Code + +Claude Code plugin that connects to [PowerMem](https://github.com/oceanbase/powermem) for intelligent, persistent memory. + +## Features + +- **MCP integration**: Uses PowerMem MCP Server so Claude can call `add_memory`, `search_memories`, `get_memory_by_id`, `update_memory`, `delete_memory`, `list_memories`. +- **Skills**: `/memory-powermem:remember` and `/memory-powermem:recall` to guide when to store and when to search memories. + +## Prerequisites + +1. **PowerMem** installed and a running PowerMem backend: + - Either **MCP Server** (e.g. `uvx powermem-mcp sse` or `uvx powermem-mcp stdio`) with a `.env` in project or home directory. + - Or **HTTP API Server** (e.g. `powermem-server --host 0.0.0.0 --port 8000`). The plugin's default `.mcp.json` points to `http://localhost:8000/mcp` (MCP over HTTP). + +2. **Claude Code** (VS Code extension or CLI) with plugin support. + +## Installation + +### Option A: Load from directory (development) + +```bash +claude --plugin-dir /path/to/powermem/apps/claude-code-plugin +``` + +### Option B: Install from marketplace + +If this plugin is published to a Claude Code plugin marketplace, install it from there. + +## Configuration + +The default `.mcp.json` in this plugin uses: + +- **HTTP transport**: `http://localhost:8000/mcp` + +To use a different URL or **stdio** (local MCP process), edit `.mcp.json` in this directory. Example for stdio: + +```json +{ + "mcpServers": { + "powermem": { + "transport": "stdio", + "command": "uvx", + "args": ["powermem-mcp", "stdio"] + } + } +} +``` + +Ensure PowerMem is installed (`pip install powermem`) and a `.env` file is available when using stdio. + +## Usage + +- In Claude Code, the PowerMem MCP tools are available automatically once the plugin is loaded. +- Use **/memory-powermem:remember** when you want Claude to store something. +- Use **/memory-powermem:recall** when you want Claude to search memories before answering. + +## Links + +- [PowerMem](https://github.com/oceanbase/powermem) +- [PowerMem MCP docs](https://github.com/oceanbase/powermem/blob/master/docs/api/0004-mcp.md) diff --git a/apps/claude-code-plugin/skills/recall/SKILL.md b/apps/claude-code-plugin/skills/recall/SKILL.md new file mode 100644 index 00000000..0ecdffba --- /dev/null +++ b/apps/claude-code-plugin/skills/recall/SKILL.md @@ -0,0 +1,8 @@ +--- +description: Search PowerMem for relevant memories. Use before answering questions about the user, project, or past decisions. +--- + +Before answering questions about the user, project history, or past decisions: +1. Use the PowerMem `search_memories` tool with a short query. +2. Optionally filter by user_id or agent_id if the user has multiple contexts. +3. Use the retrieved memories to tailor the response. diff --git a/apps/claude-code-plugin/skills/remember/SKILL.md b/apps/claude-code-plugin/skills/remember/SKILL.md new file mode 100644 index 00000000..35246f04 --- /dev/null +++ b/apps/claude-code-plugin/skills/remember/SKILL.md @@ -0,0 +1,8 @@ +--- +description: Add or update a memory in PowerMem. Use when the user or conversation reveals a fact, preference, or decision that should be remembered across sessions. +--- + +When the user asks to remember something, or when the conversation contains a clear fact/preference/decision worth persisting: +1. Use the PowerMem `add_memory` tool with appropriate content and optional user_id/agent_id. +2. Prefer concise, factual memory content. +3. Confirm what was stored in one sentence. diff --git a/apps/vscode-extension/.vscodeignore b/apps/vscode-extension/.vscodeignore new file mode 100644 index 00000000..b5c3cefb --- /dev/null +++ b/apps/vscode-extension/.vscodeignore @@ -0,0 +1,6 @@ +.vscode/** +.vscode-test/** +src/** +tsconfig.json +**/*.map +node_modules/** diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md new file mode 100644 index 00000000..80beef5f --- /dev/null +++ b/apps/vscode-extension/README.md @@ -0,0 +1,64 @@ +# PowerMem for VS Code + +Give Cursor, Claude Code, Codex, Windsurf, and Copilot access to [PowerMem](https://github.com/oceanbase/powermem) intelligent memory with one click. + +## Features + +- **One-click link**: Auto-writes MCP or HTTP config for Cursor, Claude, Codex, Windsurf, and GitHub Copilot so they can use PowerMem. +- **Query memories**: Search your PowerMem from the editor (selection or query). +- **Add to memory**: Save selection or a quick note to PowerMem. +- **Dashboard**: Quick access to query, quick note, and setup. +- **Health check**: Status bar shows connection state; reconnect from the menu. + +## Requirements + +- A running **PowerMem** backend: + - **HTTP API + MCP**: `powermem-server --host 0.0.0.0 --port 8000` (default), or + - **MCP only**: e.g. `uvx powermem-mcp sse` (port 8000) or `uvx powermem-mcp stdio`. +- PowerMem is configured (e.g. `.env` next to the server or in project root). + +## Quick Start + +1. Install this extension in VS Code or Cursor. +2. Start your PowerMem backend (see above). +3. Click the **PowerMem** status bar item; if disconnected, run **Setup** and set **Backend URL** (e.g. `http://localhost:8000`). +4. Once connected, choose **Link to AI tools** to write configs for Cursor, Claude, Codex, Windsurf, and Copilot. +5. Use **Query memories** or **Add selection to memory** from the command palette or status bar menu. + +## Settings + +| Setting | Description | Default | +|--------|-------------|---------| +| `powermem.enabled` | Enable the extension | `true` | +| `powermem.backendUrl` | PowerMem backend URL | `http://localhost:8000` | +| `powermem.apiKey` | API key (X-API-Key) if required | (empty) | +| `powermem.useMCP` | Write MCP config for AI tools; if false, write HTTP where supported | `true` | +| `powermem.mcpServerPath` | Optional path/command for local MCP (e.g. `uvx`); empty = use backendUrl/mcp | (empty) | +| `powermem.userId` | User ID for memory scope; empty = auto-generated | (empty) | +| `powermem.projectName` | Project name; empty = workspace name | (empty) | + +## Commands + +- **PowerMem: Status Bar Menu** – Open the main menu (link, query, add, setup, etc.). +- **PowerMem: Query Memories** – Search PowerMem (uses selection or prompts for query). +- **PowerMem: Add Selection to Memory** – Save the current selection to PowerMem. +- **PowerMem: Quick Note** – Add a one-line note to PowerMem. +- **PowerMem: Link to AI Tools** – Write MCP/HTTP config for Cursor, Claude, Codex, Windsurf, Copilot. +- **PowerMem: Setup** – Change backend URL, API key, MCP path, test connection. +- **PowerMem: Dashboard** – Open the simple dashboard panel. + +## Where configs are written + +- **Cursor**: `~/.cursor/mcp.json` (merged with existing `mcpServers`). +- **Claude**: `~/.claude/providers/powermem.json`. +- **Codex**: `~/.codex/context.json` (merged). +- **Windsurf**: `~/.windsurf/context/powermem.json`. +- **Copilot**: `~/.github/copilot/powermem.json`. + +After linking, restart or reload the respective AI tool/IDE so it picks up the new config. + +## Links + +- [PowerMem](https://github.com/oceanbase/powermem) +- [PowerMem API](https://github.com/oceanbase/powermem/blob/master/docs/api/0005-api_server.md) +- [PowerMem MCP](https://github.com/oceanbase/powermem/blob/master/docs/api/0004-mcp.md) diff --git a/apps/vscode-extension/media/.gitkeep b/apps/vscode-extension/media/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json new file mode 100644 index 00000000..caafa4a9 --- /dev/null +++ b/apps/vscode-extension/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "powermem-vscode", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "powermem-vscode", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^18.x", + "@types/vscode": "^1.104.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.104.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.110.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz", + "integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json new file mode 100644 index 00000000..5bd318c6 --- /dev/null +++ b/apps/vscode-extension/package.json @@ -0,0 +1,134 @@ +{ + "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": "Save chat to memory, search memories. Say remember or /save to save; /search to query.", + "isSticky": false, + "commands": [ + { "name": "remember", "description": "Save this conversation to PowerMem" }, + { "name": "save", "description": "Save current conversation to PowerMem" }, + { "name": "search", "description": "Search PowerMem" } + ], + "disambiguation": [ + { + "category": "memory", + "description": "The user wants to save something to long-term memory (PowerMem) or search their saved memories.", + "examples": [ + "Remember this", + "Save to memory: we use pnpm in this project", + "Search user login flow" + ] + } + ] + } + ], + "commands": [ + { "command": "powermem.statusBarClick", "title": "PowerMem: Status Bar Menu" }, + { "command": "powermem.queryMemories", "title": "PowerMem: Query Memories" }, + { "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.autoCapture.onSave": { + "type": "boolean", + "default": false, + "description": "When enabled, automatically add file content to memory on save (seamless write). Respects 'Include pattern' and 'Max chars'." + }, + "powermem.autoCapture.include": { + "type": "string", + "default": "**/*.md,**/*.txt,**/docs/**", + "description": "Glob pattern for which files to auto-capture on save (comma-separated). Example: **/*.md,**/docs/**" + }, + "powermem.autoCapture.maxChars": { + "type": "number", + "default": 8000, + "description": "Max characters per file to add to memory on auto-capture (avoids huge payloads)." + }, + "powermem.chat.autoSummarizeEveryNTurns": { + "type": "number", + "default": 10, + "description": "In @powermem chat: auto-summarize and save to memory every N conversation turns (0 = off). Seamless write." + }, + "powermem.chat.autoRetrieve": { + "type": "boolean", + "default": true, + "description": "In @powermem chat: always retrieve relevant memories before answering. Seamless retrieval." + } + } + } + }, + "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..54c0eec8 --- /dev/null +++ b/apps/vscode-extension/src/chat/participant.ts @@ -0,0 +1,262 @@ +/** + * Chat participant @powermem: seamless memory write (auto-summarize every N turns) + * and retrieval (auto-retrieve on every question, answer with LLM + memories). + */ + +import * as vscode from 'vscode'; +import { addMemory, searchMemories } from '../api/client'; +import type { SearchResultItem } from '../api/types'; + +const MAX_CHAT_MEMORY_CHARS = 12000; +const SUMMARY_PROMPT = + 'Summarize the following conversation into concise bullet points for long-term memory. ' + + 'Keep only important facts, decisions, and context. Output only the summary, same language as the conversation.'; + +function isSaveIntent(prompt: string, command?: string): boolean { + const t = prompt.trim().toLowerCase(); + if (command === 'remember' || command === 'save' || command === '\u4fdd\u5b58') return true; + if (/^(remember|save)(\s|$)/i.test(t)) return true; + if (/^[\u8bb0\u4f4f\u4fdd\u5b58](\s|$)/.test(t)) return true; + if (/^(\u628a)?(\u4e0a\u9762)?(\u8fd9\u6bb5)?(\u5bf9\u8bdd)?(\u8bb0\u4e0b\u6765|\u5b58\u5230?\u8bb0\u5fc6|\u4fdd\u5b58\u5230?powermem)/.test(t)) return true; + return false; +} + +function isSearchIntent(prompt: string, command?: string): boolean { + const t = prompt.trim().toLowerCase(); + if (command === 'search' || command === '\u641c\u7d22') return true; // \u641c\u7d22 = search (localized) + if (/^(search|query)\s/i.test(t)) return true; + if (/^[\u641c\u7d22\u67e5\u8be2]\s/.test(t)) return true; + return false; +} + +function responseTurnToText(turn: vscode.ChatResponseTurn): string { + const parts: string[] = []; + for (const part of turn.response) { + if (part instanceof vscode.ChatResponseMarkdownPart) { + const v = part.value; + parts.push(typeof v === 'string' ? v : (v as { value?: string }).value ?? ''); + } + } + return parts.join('\n').trim(); +} + +function historyToText(history: ReadonlyArray): string { + const lines: string[] = []; + for (const turn of history) { + if (turn instanceof vscode.ChatRequestTurn) { + lines.push(`[User] ${turn.prompt}`); + } else { + const text = responseTurnToText(turn); + if (text) lines.push(`[Assistant] ${text}`); + } + } + return lines.join('\n\n'); +} + +function formatMemoriesForPrompt(results: SearchResultItem[]): string { + if (results.length === 0) return ''; + return results.map((r, i) => `[${i + 1}] ${(r.content ?? '').trim().slice(0, 500)}`).join('\n\n'); +} + +async function summarizeWithModel( + model: vscode.LanguageModelChat, + conversationText: string, + token: vscode.CancellationToken +): Promise { + const truncated = + conversationText.length > MAX_CHAT_MEMORY_CHARS + ? conversationText.slice(0, MAX_CHAT_MEMORY_CHARS) + '\n…' + : conversationText; + const messages = [ + vscode.LanguageModelChatMessage.User(SUMMARY_PROMPT + '\n\n---\n\n' + truncated), + ]; + const response = await model.sendRequest(messages, {}, token); + let out = ''; + for await (const chunk of response.text) { + out += chunk; + } + return out.trim(); +} + +export function registerChatParticipant( + context: vscode.ExtensionContext, + getBackendUrl: () => string, + getApiKey: () => string | undefined, + getUserId: () => string, + getEnabled: () => boolean, + getChatAutoSummarizeTurns: () => number, + getChatAutoRetrieve: () => boolean +): void { + if (typeof vscode.chat?.createChatParticipant !== 'function') return; + + const handler: vscode.ChatRequestHandler = async ( + request: vscode.ChatRequest, + chatContext: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken + ): Promise => { + const enabled = getEnabled(); + const backendUrl = getBackendUrl(); + if (!enabled || !backendUrl) { + stream.markdown('PowerMem is disabled or not configured. Enable the extension and set Backend URL in settings.'); + return; + } + + const apiKey = getApiKey(); + const userId = getUserId(); + const prompt = request.prompt.trim(); + const command = request.command; + const autoSummarizeTurns = getChatAutoSummarizeTurns(); + const autoRetrieve = getChatAutoRetrieve(); + + // ——— Explicit save ——— + if (isSaveIntent(prompt, command)) { + const historyText = historyToText(chatContext.history); + let toSave = historyText || prompt || '[Empty conversation]'; + if (!historyText && prompt) { + const stripped = prompt.replace(/^(\u8bb0\u4f4f|\u4fdd\u5b58|remember|save)\s*[:\uFF1A]\s*/i, '').trim(); + if (stripped) toSave = stripped; + } + const content = + toSave.length > MAX_CHAT_MEMORY_CHARS ? toSave.slice(0, MAX_CHAT_MEMORY_CHARS) + '\n…' : toSave; + try { + await addMemory( + backendUrl, + { + content, + user_id: userId || undefined, + metadata: { source: 'vscode-chat', type: 'chat-history' }, + }, + apiKey + ); + stream.markdown('Saved to PowerMem.'); + } catch (e) { + stream.markdown(`Save failed: ${e}`); + } + return; + } + + // ——— Explicit search ——— + if (isSearchIntent(prompt, command)) { + const query = prompt.replace(/^(\u641c\u7d22|\u67e5\u8be2|search|query)\s*/gi, '').trim() || prompt; + if (!query) { + stream.markdown('Enter a search query, e.g. `/search login flow`'); + return; + } + stream.progress('Searching…'); + try { + const data = await searchMemories( + backendUrl, + { query, user_id: userId || undefined, limit: 8 }, + apiKey + ); + const results = data?.results ?? []; + if (results.length === 0) { + stream.markdown('No related memories found.'); + return; + } + let out = '**PowerMem search results**\n\n'; + for (const r of results) { + const score = r.score != null ? ` (relevance: ${r.score})` : ''; + out += `- ${(r.content ?? '').slice(0, 300)}${(r.content?.length ?? 0) > 300 ? '…' : ''}${score}\n\n`; + } + stream.markdown(out); + } catch (e) { + stream.markdown(`Search failed: ${e}`); + } + return; + } + + // ——— General question: auto-summarize (background) + auto-retrieve + answer with LLM ——— + const history = chatContext.history; + const model = request.model; + const canUseModel = + model && (context.languageModelAccessInformation?.canSendRequest?.(model) === true); + + // 1) Auto-summarize every N turns (fire-and-forget) + if (autoSummarizeTurns >= 2 && history.length >= autoSummarizeTurns - 1 && canUseModel) { + const start = Math.max(0, history.length - (autoSummarizeTurns - 1)); + const slice = history.slice(start); + const conversationText = historyToText(slice) + '\n\n[User] ' + prompt; + summarizeWithModel(model, conversationText, token) + .then((summary) => { + if (!summary || token.isCancellationRequested) return; + return addMemory( + backendUrl, + { + content: summary, + user_id: userId || undefined, + metadata: { source: 'vscode-chat', type: 'chat-summary' }, + }, + apiKey + ); + }) + .catch(() => {}); + } + + // 2) Auto-retrieve relevant memories + let memoriesText = ''; + if (autoRetrieve && prompt) { + try { + const data = await searchMemories( + backendUrl, + { query: prompt, user_id: userId || undefined, limit: 6 }, + apiKey + ); + const results = data?.results ?? []; + memoriesText = formatMemoriesForPrompt(results); + } catch { + // ignore + } + } + + // 3) Answer with LLM + memories, or show memories only + if (canUseModel && model) { + const systemContent = + memoriesText.length > 0 + ? `Relevant memories (use when helpful):\n\n${memoriesText}\n\nAnswer the user's question below, using these memories when relevant.` + : 'Answer the user\'s question concisely.'; + const messages = [ + vscode.LanguageModelChatMessage.User(systemContent), + vscode.LanguageModelChatMessage.User(prompt), + ]; + try { + const response = await model.sendRequest(messages, {}, token); + for await (const chunk of response.text) { + if (token.isCancellationRequested) break; + stream.markdown(chunk); + } + } catch (e) { + if (memoriesText) { + stream.markdown('**Relevant memories**\n\n' + memoriesText + '\n\n---\n\n*Model request failed: ' + String(e) + '*'); + } else { + stream.markdown('Model request failed: ' + String(e)); + } + } + return; + } + + // No model: show memories if any, else short help + if (memoriesText) { + stream.markdown('**Relevant memories**\n\n' + memoriesText + '\n\n---\n\nSelect a chat model above to get answers using these memories.'); + } else { + stream.markdown( + '**PowerMem** auto memory and retrieval are on. After you select a chat model:\n' + + '- Every N turns are summarized and saved to memory automatically.\n' + + '- Each answer uses retrieved memories when relevant.\n\n' + + 'You can also use **remember** / **/save** to save, **/search <query>** to search.' + ); + } + }; + + const participant = vscode.chat.createChatParticipant('powermem-vscode.powermem', handler); + participant.followupProvider = { + provideFollowups(_result: vscode.ChatResult, _ctx: vscode.ChatContext, _token: vscode.CancellationToken) { + return [ + { prompt: 'remember', label: 'Save this conversation to PowerMem' }, + { prompt: 'search recent project notes', label: 'Search PowerMem' }, + ]; + }, + }; + context.subscriptions.push(participant); +} diff --git a/apps/vscode-extension/src/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..73337577 --- /dev/null +++ b/apps/vscode-extension/src/extension.ts @@ -0,0 +1,390 @@ +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; + +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') || ''; + autoCaptureOnSave = config.get('autoCapture.onSave') ?? false; + autoCaptureInclude = config.get('autoCapture.include') ?? '**/*.md,**/*.txt,**/docs/**'; + autoCaptureMaxChars = Math.max(500, config.get('autoCapture.maxChars') ?? 8000); + chatAutoSummarizeTurns = Math.max(0, config.get('chat.autoSummarizeEveryNTurns') ?? 10); + chatAutoRetrieve = config.get('chat.autoRetrieve') ?? true; + userId = getUserId(context, config); + + statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + statusBar.command = 'powermem.statusBarClick'; + context.subscriptions.push(statusBar); + + context.subscriptions.push( + vscode.commands.registerCommand('powermem.statusBarClick', () => showMenu()) + ); + + registerChatParticipant( + context, + () => backendUrl, + () => apiKey, + () => userId, + () => isEnabled, + () => 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; + autoCaptureOnSave = c.get('autoCapture.onSave') ?? false; + autoCaptureInclude = c.get('autoCapture.include') ?? '**/*.md,**/*.txt,**/docs/**'; + autoCaptureMaxChars = Math.max(500, c.get('autoCapture.maxChars') ?? 8000); + chatAutoSummarizeTurns = Math.max(0, c.get('chat.autoSummarizeEveryNTurns') ?? 10); + chatAutoRetrieve = c.get('chat.autoRetrieve') ?? true; + // Re-link AI tools when connection/backend config changes so user does not need to click "Link to AI tools" + if ( + isEnabled && + (e.affectsConfiguration('powermem.backendUrl') || + e.affectsConfiguration('powermem.connectionMode') || + e.affectsConfiguration('powermem.useMCP') || + e.affectsConfiguration('powermem.mcpServerPath')) + ) { + autoLinkAll().catch((err) => console.error('PowerMem auto re-link failed:', err)); + } + }) + ); + + // Optional: auto-add to memory on save (seamless write) + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument(async (doc) => { + if (!isEnabled || !autoCaptureOnSave || doc.uri.scheme !== 'file') return; + const path = doc.uri.fsPath; + if (!matchesAutoCaptureInclude(path, autoCaptureInclude)) return; + const text = doc.getText(); + if (!text.trim()) return; + const content = text.length > autoCaptureMaxChars ? text.slice(0, autoCaptureMaxChars) + '\n…' : text; + try { + await addMemory(backendUrl, { + content, + user_id: userId || undefined, + metadata: { source: 'vscode', type: 'auto-save', file: path }, + }, apiKey); + } catch { + // Silent fail to avoid interrupting the user + } + }) + ); +} + +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"] +}