Skip to content
Open
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
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export SUPERMEMORY_CC_API_KEY="sm_..."

## How It Works

- **Reasoned recall** — Before each turn, Claude decides whether recalling memory would actually help your current message, and only searches when it's worth it — every turn, once in a while, or not at all. The search runs automatically (no permission prompt), just like auto-capture. Searching only when needed also keeps more usage on your plan
- **Automatic recall** — Every substantive prompt searches the repository and configured recall containers, then injects globally ranked, deduplicated matches within the configured context budget
- **supermemory-search** — Ask about past work or previous sessions, Claude searches your memories
- **supermemory-save** — Ask to save something important, Claude saves it for the team

Expand Down Expand Up @@ -85,24 +85,42 @@ SUPERMEMORY_DEBUG=true # Optional: enable debug logging

**Global Settings** — `~/.supermemory-claude/settings.json`

Claude reads only the six recall options below from
`~/.codex/supermemory.json` when that file exists. Claude-specific settings
override those shared values; unrelated Codex options never alter Claude.
When both clients use the same saved API key, Claude also uses Codex's saved
API base URL. Environment and project-specific URL overrides still take priority.

```json
{
"maxProfileItems": 5,
"maxMemories": 15,
"maxProfileItems": 15,
"maxRecallTokens": 5000,
"maxPromptRecallTokens": 2000,
"autoRecallContainers": true,
"customContainers": [
{ "tag": "coding_personal", "description": "Cross-project coding preferences." }
],
"signalExtraction": true,
"signalKeywords": ["remember", "architecture", "decision", "bug", "fix"],
"signalTurnsBefore": 3,
"includeTools": ["Edit", "Write"]
}
```

| Option | Description |
| ------------------- | --------------------------------------------- |
| `maxProfileItems` | Max memories in context (default: 5) |
| `recallDirective` | Override the built-in reasoned-recall instruction Claude is given |
| `signalExtraction` | Only capture important turns (default: false) |
| `signalKeywords` | Keywords that trigger capture |
| `signalTurnsBefore` | Context turns before signal (default: 3) |
| `includeTools` | Tools to explicitly capture |
| Option | Description |
| ------------------------- | ----------- |
| `maxMemories` | Maximum globally ranked prompt matches across all searched containers (default: 5) |
| `maxProfileItems` | Maximum static and dynamic profile items per section (default: 5) |
| `maxRecallTokens` | Approximate whole-context SessionStart budget (default: 2500) |
| `maxPromptRecallTokens` | Approximate whole-context prompt-recall budget (default: 500) |
| `autoRecallContainers` | Search every valid `customContainers` entry automatically (default: false) |
| `customContainers` | Additional recall containers with `tag` and `description` fields |
| `recallDirective` | Replace automatic prompt recall with a custom advisory instruction |
| `signalExtraction` | Only capture important turns (default: false) |
| `signalKeywords` | Keywords that trigger capture |
| `signalTurnsBefore` | Context turns before signal (default: 3) |
| `includeTools` | Tools to explicitly capture |

**Project Config** — `.claude/.supermemory-claude/config.json`

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["src/**", "scripts/**", "!src/lib/validate.js"]
"includes": ["plugin/**", "test/**", "plugin-inspector.ts", "package.json"]
},
"formatter": {
"enabled": true,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"private": true,
"type": "commonjs",
"scripts": {
"test": "node --test test/unit.mjs",
"test": "node --test test/unit.mjs test/recall.mjs test/status.mjs test/session-start.mjs test/capture.mjs",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
Expand Down
84 changes: 52 additions & 32 deletions plugin-inspector.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';

const ROOT = import.meta.dir;
const PLUGIN = join(ROOT, "plugin");
const PLUGIN = join(ROOT, 'plugin');

function parseFrontmatter(text: string) {
const m = text.match(/^---\n([\s\S]*?)\n---/);
const fm: Record<string, string> = {};
if (m) {
for (const line of m[1].split("\n")) {
for (const line of m[1].split('\n')) {
const kv = line.match(/^(\S+?):\s*(.*)$/);
if (kv) fm[kv[1]] = kv[2];
}
}
return fm;
}

function listFiles(dir: string, prefix = ""): { path: string; size: number }[] {
function listFiles(dir: string, prefix = ''): { path: string; size: number }[] {
const out: { path: string; size: number }[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
Expand All @@ -27,17 +27,26 @@ function listFiles(dir: string, prefix = ""): { path: string; size: number }[] {
}

async function inspect() {
const manifest = await Bun.file(join(PLUGIN, ".claude-plugin/plugin.json")).json();
const hooksJson = await Bun.file(join(PLUGIN, "hooks/hooks.json")).json();
const mcpJson = await Bun.file(join(PLUGIN, ".mcp.json")).json();
const manifest = await Bun.file(
join(PLUGIN, '.claude-plugin/plugin.json'),
).json();
const hooksJson = (await Bun.file(
join(PLUGIN, 'hooks/hooks.json'),
).json()) as {
hooks: Record<
string,
{ matcher?: string; hooks: { command: string; timeout: number }[] }[]
>;
};
const mcpJson = await Bun.file(join(PLUGIN, '.mcp.json')).json();

const hooks = Object.entries(hooksJson.hooks).flatMap(([event, groups]: [string, any]) =>
groups.flatMap((g: any) =>
g.hooks.map((h: any) => {
const script = h.command.match(/hooks\/[\w-]+\.js/)?.[0] ?? "";
const hooks = Object.entries(hooksJson.hooks).flatMap(([event, groups]) =>
groups.flatMap((g) =>
g.hooks.map((h) => {
const script = h.command.match(/hooks\/[\w-]+\.js/)?.[0] ?? '';
return {
event,
matcher: g.matcher ?? "*",
matcher: g.matcher ?? '*',
script,
timeout: h.timeout,
exists: script ? statSyncSafe(join(PLUGIN, script)) !== null : false,
Expand All @@ -47,31 +56,45 @@ async function inspect() {
);

const commands = await Promise.all(
readdirSync(join(PLUGIN, "commands")).map(async (f) => {
const content = await Bun.file(join(PLUGIN, "commands", f)).text();
readdirSync(join(PLUGIN, 'commands')).map(async (f) => {
const content = await Bun.file(join(PLUGIN, 'commands', f)).text();
const fm = parseFrontmatter(content);
return { name: f.replace(".md", ""), description: fm.description ?? "", content };
return {
name: f.replace('.md', ''),
description: fm.description ?? '',
content,
};
}),
);

const agents = await Promise.all(
readdirSync(join(PLUGIN, "agents")).map(async (f) => {
const content = await Bun.file(join(PLUGIN, "agents", f)).text();
readdirSync(join(PLUGIN, 'agents')).map(async (f) => {
const content = await Bun.file(join(PLUGIN, 'agents', f)).text();
const fm = parseFrontmatter(content);
return { name: f.replace(".md", ""), description: fm.description ?? "", content };
return {
name: f.replace('.md', ''),
description: fm.description ?? '',
content,
};
}),
);

const directiveSrc = await Bun.file(join(PLUGIN, "hooks/recall-directive.js")).text();
const directive = directiveSrc.match(/return `([\s\S]*?)`;/)?.[1] ?? "";
const approveSrc = await Bun.file(join(PLUGIN, "hooks/recall-approve.js")).text();
const readOnlyTools = [...approveSrc.matchAll(/^\s*'([\w-]+)',$/gm)].map((m) => m[1]);
const approveSrc = await Bun.file(
join(PLUGIN, 'hooks/recall-approve.js'),
).text();
const readOnlyTools = [...approveSrc.matchAll(/^\s*'([\w-]+)',$/gm)].map(
(m) => m[1],
);

let git = { branch: "unknown", commit: "unknown" };
let git = { branch: 'unknown', commit: 'unknown' };
try {
git = {
branch: (await Bun.$`git branch --show-current`.cwd(ROOT).quiet().text()).trim(),
commit: (await Bun.$`git log -1 --format=%h %s`.cwd(ROOT).quiet().text()).trim(),
branch: (
await Bun.$`git branch --show-current`.cwd(ROOT).quiet().text()
).trim(),
commit: (
await Bun.$`git log -1 --format=%h %s`.cwd(ROOT).quiet().text()
).trim(),
};
} catch {}

Expand All @@ -82,7 +105,6 @@ async function inspect() {
hooks,
commands,
agents,
directive,
readOnlyTools,
files: listFiles(PLUGIN),
};
Expand Down Expand Up @@ -123,7 +145,6 @@ const html = `<!doctype html>
<div id="badges"></div>
<h2>hooks</h2><table id="hooks"></table>
<h2>mcp server + auto-approved (read-only) tools</h2><div id="mcp"></div>
<h2>recall directive — injected every prompt</h2><pre id="directive"></pre>
<h2>agents</h2><div id="agents"></div>
<h2>commands</h2><div id="commands"></div>
<h2>plugin files (all committed source, no build)</h2><table id="files"></table>
Expand All @@ -132,7 +153,7 @@ const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'
const kb = n => n < 1024 ? n + ' b' : (n / 1024).toFixed(1) + ' kb';
const HOOK_NOTES = {
SessionStart: 'profile fetch → context + "N memories loaded" + welcome-back; auth bootstrap; statusline symlink upkeep',
UserPromptSubmit: 'injects recall directive with active container tag (local, no network)',
UserPromptSubmit: 'bounded automatic recall from the active and configured containers',
PreToolUse: 'auto-approves read-only supermemory MCP tools + "recalling: <query>" message',
Stop: 'captures transcript delta with entityContext; writes statusline state',
};
Expand All @@ -153,7 +174,6 @@ fetch('/api/inspect').then(r => r.json()).then(d => {
'<p class="dim">server "' + esc(server[0]) + '": ' + esc(server[1].command + ' ' + server[1].args.join(' ')) +
' (proxy \\u2192 mcp.supermemory.ai, authed via credentials.json)</p>' +
d.readOnlyTools.map(t => '<span class="badge ok">' + esc(t) + '</span>').join('');
document.getElementById('directive').textContent = d.directive;
const fileSection = items => items.map(i =>
'<details><summary>' + esc(i.name) + '<span class="dim">' + esc(i.description) + '</span></summary><pre>' + esc(i.content) + '</pre></details>').join('');
document.getElementById('agents').innerHTML = fileSection(d.agents);
Expand All @@ -170,8 +190,8 @@ fetch('/api/inspect').then(r => r.json()).then(d => {
const server = Bun.serve({
port: 4747,
routes: {
"/": () => new Response(html, { headers: { "Content-Type": "text/html" } }),
"/api/inspect": async () => Response.json(await inspect()),
'/': () => new Response(html, { headers: { 'Content-Type': 'text/html' } }),
'/api/inspect': async () => Response.json(await inspect()),
},
});

Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
"ai",
"context"
]
}
}
15 changes: 5 additions & 10 deletions plugin/commands/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,10 @@ allowed-tools: ["Bash", "Read"]

Report the user's Supermemory status:

1. Read `~/.supermemory-claude/credentials.json` (may not exist). Never print the full API key — show at most the first 6 and last 4 characters. The key source is env `SUPERMEMORY_CC_API_KEY` when set, otherwise the credentials file.
2. **Probe real connectivity** — a stored key proves nothing by itself. With the resolved key, run:
```
curl -sS -o /dev/null -w '%{http_code}' -m 8 -X POST "${SUPERMEMORY_API_URL:-https://api.supermemory.ai}/v4/profile" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "x-sm-source: claude-code" \
-d '{"containerTag":"<this project's container tag>","q":"connectivity probe"}'
```
Interpret loudly: `200` → reachable and the key works; `401`/`403` → reachable but the key is invalid or revoked (say so explicitly — this is the silent-failure case the probe exists to catch); timeout / connection error / `5xx` → API unreachable, report the exact error.
3. Call the `whoAmI` MCP tool if the supermemory MCP server is connected, and say whether the MCP path works too.
4. Report: authenticated or not, key source, the active project container tag, API reachability (with the probe's HTTP status), and MCP reachability.
1. From the active project directory, run `node "${CLAUDE_PLUGIN_ROOT}/hooks/status-check.js"`. The probe uses the same credential, project configuration, endpoint precedence, and container-tag implementations as the runtime hooks. It never prints the API key.

2. Interpret the probe loudly: `200` means reachable and authenticated. `401` or `403` means reachable but the key is invalid or revoked. A timeout, connection error, or `5xx` means the API is unavailable; report the exact result.
3. Call the `whoAmI` MCP tool if the Supermemory MCP server is connected. Report whether the MCP path works.
4. Report authentication, key source, active endpoint, active project container tag, API HTTP status, and MCP reachability.

If not authenticated, tell the user a new session will open the browser login automatically, or they can set `SUPERMEMORY_CC_API_KEY`.
6 changes: 4 additions & 2 deletions plugin/hooks/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async function main() {
return;
}

const baseUrl = getBaseUrl(cwd, projectConfig);
const baseUrl = getBaseUrl(cwd, projectConfig, apiKey);
const containerTag = getContainerTag(cwd);

const captured = readState(sessionId).capture?.count || 0;
Expand Down Expand Up @@ -87,7 +87,9 @@ async function main() {
} catch {}
}

debugLog(settings, 'Session turn saved', { length: delta.formatted.length });
debugLog(settings, 'Session turn saved', {
length: delta.formatted.length,
});
writeOutput({ continue: true });
} catch (err) {
const friendly = getUserFriendlyError(err);
Expand Down
63 changes: 57 additions & 6 deletions plugin/hooks/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ SKIP:
// Callers treat failure as "no memory this time", not a blocker.
const REQUEST_TIMEOUT_MS = 3000;

async function post(baseUrl, apiKey, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
async function post(
baseUrl,
apiKey,
path,
body,
timeoutMs = REQUEST_TIMEOUT_MS,
expectedStatus,
) {
const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${path}`, {
method: 'POST',
headers: {
Expand All @@ -34,24 +41,68 @@ async function post(baseUrl, apiKey, path, body, timeoutMs = REQUEST_TIMEOUT_MS)
'x-sm-source': 'claude-code',
},
body: JSON.stringify(body),
redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
});

if (!response.ok) {
if (
!response.ok ||
(expectedStatus !== undefined && response.status !== expectedStatus)
) {
const text = await response.text().catch(() => '');
throw Object.assign(
new Error(`Supermemory API ${response.status}: ${text.slice(0, 200)}`),
{ status: response.status },
);
}
return response.json();
return response.json().catch((error) => {
throw Object.assign(error, { status: response.status });
});
}

function getProfile(baseUrl, apiKey, containerTag, query, options = {}) {
return post(baseUrl, apiKey, '/v4/profile', { containerTag, q: query }, options.timeoutMs);
return post(
baseUrl,
apiKey,
'/v4/profile',
{ containerTag, q: query },
options.timeoutMs,
200,
);
}

async function getProfiles(
baseUrl,
apiKey,
containerTags,
query,
options = {},
) {
const settled = await Promise.allSettled(
[...new Set(containerTags.filter(Boolean))].map((containerTag) =>
getProfile(baseUrl, apiKey, containerTag, query, options),
),
);
const profiles = settled
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
if (profiles.length === 0) {
const failures = settled
.filter((result) => result.status === 'rejected')
.map((result) => result.reason);
throw failures.find((failure) => failure?.status !== 404) || failures[0];
}
return profiles;
}

function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {}) {
function addMemory(
baseUrl,
apiKey,
content,
containerTag,
metadata,
options = {},
) {
const body = {
content,
containerTag,
Expand All @@ -62,4 +113,4 @@ function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {
return post(baseUrl, apiKey, '/v3/documents', body, options.timeoutMs);
}

module.exports = { AGENT_ENTITY_CONTEXT, getProfile, addMemory };
module.exports = { AGENT_ENTITY_CONTEXT, getProfile, getProfiles, addMemory };
3 changes: 2 additions & 1 deletion plugin/hooks/lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const SETTINGS_DIR = path.join(os.homedir(), '.supermemory-claude');
const CREDENTIALS_FILE = path.join(SETTINGS_DIR, 'credentials.json');

const AUTH_BASE_URL =
process.env.SUPERMEMORY_AUTH_URL || 'https://console.supermemory.ai/auth/connect';
process.env.SUPERMEMORY_AUTH_URL ||
'https://console.supermemory.ai/auth/connect';
const AUTH_PORT = 19876;
const AUTH_TIMEOUT = 25000;

Expand Down
Loading