Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebase the version bump before pushing main

When multiple commits reach main before their release runs finish, each run checks out its own event SHA and creates a different bump commit; after one run or a later merge advances main, this unconditional push can be rejected as non-fast-forward. Git permits ordinary branch updates only when the new tip descends from the remote tip (git-push documentation), so the workflow can fail and omit the promised per-merge version bump. Cancel stale runs or fetch/rebase and regenerate the bump before retrying the push.

Useful? React with 👍 / 👎.

2 changes: 1 addition & 1 deletion plugin/agents/context-gatherer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 48 additions & 5 deletions plugin/hooks/mcp-proxy.js
Original file line number Diff line number Diff line change
@@ -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`);
}
Expand Down Expand Up @@ -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 });
Expand All @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion plugin/hooks/recall-directive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</supermemory-recall>`;
}

Expand Down
103 changes: 102 additions & 1 deletion test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading