diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1bc0cd1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + bump: + if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: "20" + + - name: Bump patch version + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + npm version patch --no-git-tag-version + VERSION="$(node -p "require('./package.json').version")" + node -e ' + const fs = require("fs"); + const version = require("./package.json").version; + for (const file of ["plugin/.claude-plugin/plugin.json", "latest.json"]) { + const next = fs.readFileSync(file, "utf8").replace( + /"version":\s*"[^"]+"/, + `"version": "${version}"`, + ); + fs.writeFileSync(file, next); + } + ' + git add package.json package-lock.json plugin/.claude-plugin/plugin.json latest.json + git commit -m "chore(release): ${VERSION}" + + - name: Push version bump + run: git push origin HEAD:main diff --git a/plugin/agents/context-gatherer.md b/plugin/agents/context-gatherer.md index 2331cf4..54addee 100644 --- a/plugin/agents/context-gatherer.md +++ b/plugin/agents/context-gatherer.md @@ -10,7 +10,7 @@ You are the Supermemory context gatherer. Your job: assemble the background a co ## Process -1. Identify the project's memory container from the task prompt (the caller passes the active containerTag; if not, call `listSpaces` and pick the container matching the repo name). +1. Search this project's container by default (`search_memory` with no `containerTag` is already scoped to the repo). If the caller names a different space, resolve it with `listSpaces` and pass that `containerTag`. 2. Run several `search_memory` calls from different angles, not one broad query: - the specific task or files named in the prompt - recent decisions and conventions in this repo diff --git a/plugin/hooks/mcp-proxy.js b/plugin/hooks/mcp-proxy.js index 5add792..0511bc0 100644 --- a/plugin/hooks/mcp-proxy.js +++ b/plugin/hooks/mcp-proxy.js @@ -1,17 +1,52 @@ #!/usr/bin/env node -// Bridges Claude Code's stdio MCP transport to the hosted Supermemory MCP -// server, authenticating with the same credentials file the hooks use — one -// browser login covers both. Messages are forwarded sequentially to preserve -// JSON-RPC ordering; SSE responses are unwrapped back into stdout lines. const readline = require('node:readline'); +const { getContainerTag } = require('./lib/container-tag'); const { getApiKey } = require('./lib/settings'); const MCP_URL = process.env.SUPERMEMORY_MCP_URL || 'https://mcp.supermemory.ai/mcp'; const REQUEST_TIMEOUT_MS = 30000; +const REPO_SCOPED_TOOLS = new Set([ + 'search_memory', + 'add_memory', + 'listDocuments', + 'listMemories', + 'memory-graph', + 'fetch-graph-data', + 'save-memory', +]); + let sessionId = null; +// Hosted MCP omits to activeSpace; default space-scoped calls to this repo instead. +function injectRepoContainerTag(message, containerTag) { + if (!containerTag || message.method !== 'tools/call') return; + const params = message.params; + if (!params || typeof params !== 'object') return; + if (!REPO_SCOPED_TOOLS.has(params.name)) return; + + let args = params.arguments; + let encoded = false; + if (args == null) { + params.arguments = { containerTag }; + return; + } + if (typeof args === 'string') { + try { + args = JSON.parse(args); + encoded = true; + } catch { + return; + } + } + if (!args || typeof args !== 'object' || Array.isArray(args)) return; + if (typeof args.containerTag === 'string' && args.containerTag.trim()) return; + + args.containerTag = containerTag; + params.arguments = encoded ? JSON.stringify(args) : args; +} + function send(message) { process.stdout.write(`${JSON.stringify(message)}\n`); } @@ -73,13 +108,20 @@ async function forward(message, apiKey) { } async function main() { + const cwd = process.cwd(); let apiKey = null; let keyError = null; + let repoContainerTag = null; try { - apiKey = getApiKey(process.cwd()); + apiKey = getApiKey(cwd); } catch (err) { keyError = err; } + try { + repoContainerTag = getContainerTag(cwd); + } catch { + repoContainerTag = null; + } let queue = Promise.resolve(); const rl = readline.createInterface({ input: process.stdin }); @@ -103,6 +145,7 @@ async function main() { return; } try { + injectRepoContainerTag(message, repoContainerTag); await forward(message, apiKey); } catch (err) { sendError(message.id, -32000, `Supermemory MCP proxy error: ${err.message}`); diff --git a/plugin/hooks/recall-directive.js b/plugin/hooks/recall-directive.js index 89d8cb6..be453ec 100644 --- a/plugin/hooks/recall-directive.js +++ b/plugin/hooks/recall-directive.js @@ -85,7 +85,7 @@ function formatRecall(results, containerTag) { ◪ Recalled from supermemory for this prompt (relevance-ranked): ${lines.join('\n')} -When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool (containerTag: "${containerTag}") or launch the context-gatherer agent. +When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool — it defaults to this project's container (${containerTag}). Pass containerTag only to search a different space. Or launch the context-gatherer agent. `; } diff --git a/test/unit.mjs b/test/unit.mjs index c883f95..035040d 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -206,6 +206,8 @@ describe('recall-directive hook', () => { assert.match(context, /- ◪ Migration plan — Use expand-contract migrations/); assert.doesNotMatch(context, /irrelevant low-similarity hit/); assert.match(context, /repo_example_project__/); + assert.match(context, /defaults to this project's container/); + assert.doesNotMatch(context, /omit containerTag to search the account/i); assert.match(plain(output.systemMessage), /^◪ supermemory · recalled \d+ memories \(\d+ tok\)$/); assert.equal(stub.requests[0].url, '/v4/profile'); assert.equal( @@ -497,10 +499,11 @@ describe('capture hook', () => { }); describe('mcp proxy', () => { - function runProxy(t, env, lines) { + function runProxy(t, env, lines, options = {}) { return new Promise((resolve, reject) => { const child = spawn('node', [join(HOOKS_DIR, 'mcp-proxy.js')], { env: { ...process.env, ...env }, + cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; @@ -570,6 +573,104 @@ describe('mcp proxy', () => { assert.equal(messages[0].error.code, -32001); assert.match(messages[0].error.message, /not authenticated/); }); + + test('injects the repo container tag when MCP tools omit it', async (t) => { + const { repo, home } = makeRepo(t); + const expected = readTags(repo, home).tag; + const stub = await startStubServer(t, (record, res) => { + res.setHeader('Content-Type', 'application/json'); + const { id } = JSON.parse(record.body); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result: { ok: true } })); + }); + + await runProxy( + t, + { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_CC_API_KEY: 'sm_test_key_0123456789abcdef', + SUPERMEMORY_MCP_URL: `${stub.url}/mcp`, + }, + [ + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'search_memory', arguments: { query: 'auth' } }, + }, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'add_memory', arguments: { content: 'remember this' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'listDocuments' }, + }, + ], + { cwd: repo }, + ); + + const forwarded = stub.requests.map((r) => JSON.parse(r.body)); + assert.equal(forwarded[0].params.arguments.containerTag, expected); + assert.equal(forwarded[0].params.arguments.query, 'auth'); + assert.equal(forwarded[1].params.arguments.containerTag, expected); + assert.equal(forwarded[2].params.arguments.containerTag, expected); + }); + + test('keeps an explicit containerTag and does not rewrite unrelated tools', async (t) => { + const { repo, home } = makeRepo(t); + const stub = await startStubServer(t, (record, res) => { + res.setHeader('Content-Type', 'application/json'); + const { id } = JSON.parse(record.body); + res.end(JSON.stringify({ jsonrpc: '2.0', id, result: { ok: true } })); + }); + + await runProxy( + t, + { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_CC_API_KEY: 'sm_test_key_0123456789abcdef', + SUPERMEMORY_MCP_URL: `${stub.url}/mcp`, + }, + [ + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_memory', + arguments: { query: 'auth', containerTag: 'other_space' }, + }, + }, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'set-active-tag', arguments: { containerTag: 'picked' } }, + }, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'whoAmI' }, + }, + { jsonrpc: '2.0', id: 4, method: 'tools/list' }, + ], + { cwd: repo }, + ); + + const forwarded = stub.requests.map((r) => JSON.parse(r.body)); + assert.equal(forwarded[0].params.arguments.containerTag, 'other_space'); + assert.equal(forwarded[1].params.arguments.containerTag, 'picked'); + assert.equal(forwarded[2].params.arguments, undefined); + assert.equal(forwarded[3].method, 'tools/list'); + assert.equal(forwarded[3].params, undefined); + }); }); describe('statusline state', () => {