diff --git a/README.md b/README.md index 6de17cc..c0b8154 100644 --- a/README.md +++ b/README.md @@ -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 @@ -85,9 +85,22 @@ 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, @@ -95,14 +108,19 @@ SUPERMEMORY_DEBUG=true # Optional: enable debug logging } ``` -| 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` diff --git a/biome.json b/biome.json index 3f16c30..a5aa415 100644 --- a/biome.json +++ b/biome.json @@ -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, diff --git a/package.json b/package.json index 8a88868..7d5f31a 100644 --- a/package.json +++ b/package.json @@ -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 ." diff --git a/plugin-inspector.ts b/plugin-inspector.ts index ecbdf53..4facd40 100644 --- a/plugin-inspector.ts +++ b/plugin-inspector.ts @@ -1,14 +1,14 @@ -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 = {}; 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]; } @@ -16,7 +16,7 @@ function parseFrontmatter(text: string) { 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; @@ -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, @@ -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 {} @@ -82,7 +105,6 @@ async function inspect() { hooks, commands, agents, - directive, readOnlyTools, files: listFiles(PLUGIN), }; @@ -123,7 +145,6 @@ const html = `

hooks

mcp server + auto-approved (read-only) tools

-

recall directive — injected every prompt


 

agents

commands

plugin files (all committed source, no build)

@@ -132,7 +153,7 @@ const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':' 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: " message', Stop: 'captures transcript delta with entityContext; writes statusline state', }; @@ -153,7 +174,6 @@ fetch('/api/inspect').then(r => r.json()).then(d => { '

server "' + esc(server[0]) + '": ' + esc(server[1].command + ' ' + server[1].args.join(' ')) + ' (proxy \\u2192 mcp.supermemory.ai, authed via credentials.json)

' + d.readOnlyTools.map(t => '' + esc(t) + '').join(''); - document.getElementById('directive').textContent = d.directive; const fileSection = items => items.map(i => '
' + esc(i.name) + '' + esc(i.description) + '
' + esc(i.content) + '
').join(''); document.getElementById('agents').innerHTML = fileSection(d.agents); @@ -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()), }, }); diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b6de6ea..176c839 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -19,4 +19,4 @@ "ai", "context" ] -} \ No newline at end of file +} diff --git a/plugin/commands/status.md b/plugin/commands/status.md index 3ab6756..4a8583e 100644 --- a/plugin/commands/status.md +++ b/plugin/commands/status.md @@ -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":"","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`. diff --git a/plugin/hooks/capture.js b/plugin/hooks/capture.js index 90edd78..da8ec0f 100644 --- a/plugin/hooks/capture.js +++ b/plugin/hooks/capture.js @@ -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; @@ -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); diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js index b5c3e0d..231a1ed 100644 --- a/plugin/hooks/lib/api.js +++ b/plugin/hooks/lib/api.js @@ -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: { @@ -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, @@ -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 }; diff --git a/plugin/hooks/lib/auth.js b/plugin/hooks/lib/auth.js index bdf776f..bc21fc5 100644 --- a/plugin/hooks/lib/auth.js +++ b/plugin/hooks/lib/auth.js @@ -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; diff --git a/plugin/hooks/lib/context.js b/plugin/hooks/lib/context.js new file mode 100644 index 0000000..6962c2d --- /dev/null +++ b/plugin/hooks/lib/context.js @@ -0,0 +1,254 @@ +const RECALL_MIN_SIMILARITY = 0.55; +const CHARS_PER_TOKEN = 4; + +function singleLine(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function resultText(result) { + return ( + [ + result?.memory, + result?.chunk, + result?.content, + result?.text, + result?.context, + ] + .find((value) => typeof value === 'string' && value.trim()) + ?.trim() || '' + ); +} + +function stringValue(...values) { + return values + .find((value) => typeof value === 'string' && value.trim().length > 0) + ?.trim(); +} + +function provenance(result) { + const metadata = + result?.metadata && typeof result.metadata === 'object' + ? result.metadata + : {}; + return { + title: stringValue(result?.title, metadata.title), + filepath: stringValue( + result?.filepath, + result?.filePath, + result?.path, + metadata.filepath, + metadata.filePath, + metadata.path, + ), + }; +} + +function normalizeText(value) { + return singleLine(value).toLowerCase(); +} + +function dedupe(items, keyFor) { + const seen = new Set(); + return items.filter((item) => { + const key = normalizeText(keyFor(item)); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function score(result) { + if (Number.isFinite(result.similarity)) return result.similarity; + if (Number.isFinite(result.score)) return result.score; + return null; +} + +function mergeProfileResults(responses, maxMemories) { + const staticFacts = dedupe( + responses.flatMap((response) => response?.profile?.static || []), + (fact) => fact, + ); + const staticKeys = new Set(staticFacts.map(normalizeText)); + const dynamicFacts = dedupe( + responses.flatMap((response) => response?.profile?.dynamic || []), + (fact) => fact, + ).filter((fact) => !staticKeys.has(normalizeText(fact))); + + const searchResults = dedupe( + responses + .flatMap((response) => response?.searchResults?.results || []) + .filter((result) => resultText(result)) + .filter((result) => { + const relevance = score(result); + return relevance === null || relevance >= RECALL_MIN_SIMILARITY; + }) + .sort((a, b) => { + const relevance = (score(b) ?? -1) - (score(a) ?? -1); + if (relevance !== 0) return relevance; + return Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0); + }), + (result) => resultText(result) || result.id, + ) + .slice(0, Math.max(0, maxMemories)) + .map((result) => ({ + ...result, + memory: resultText(result), + ...provenance(result), + })); + + return { + profile: { static: staticFacts, dynamic: dynamicFacts }, + searchResults: { results: searchResults }, + }; +} + +function getRecallContainerTags(containerTag, config) { + return [ + ...new Set([ + containerTag, + ...(config.autoRecallContainers === true + ? config.customContainers.map((container) => container.tag.trim()) + : []), + ]), + ]; +} + +function formatBoundedItems(items, maxTokens, limitName, render) { + if (!Number.isFinite(maxTokens) || maxTokens <= 0) { + throw new RangeError(`${limitName} must be a positive number`); + } + const maxChars = Math.floor(maxTokens * CHARS_PER_TOKEN); + if (render('').length > maxChars) { + throw new RangeError(`${limitName} is too small for fixed recall context`); + } + + let body = ''; + const newFacts = []; + for (const item of items) { + const fullBody = `${body}${item.before}${item.text}`; + if (render(fullBody).length <= maxChars) { + body = fullBody; + if (item.fact) newFacts.push(item.fact); + continue; + } + + const fixedBody = `${body}${item.before}`; + const available = maxChars - render(fixedBody).length; + if (available > 1) { + const truncated = (item.truncateText || item.text).slice( + 0, + available - 1, + ); + const emitted = `${truncated}…`; + body = `${fixedBody}${emitted}`; + if (item.fact && truncated.length > (item.factOffset || 0)) { + newFacts.push(`${truncated.slice(item.factOffset || 0)}…`); + } + } + break; + } + return { text: newFacts.length > 0 ? render(body) : '', newFacts }; +} + +function formatRecallContext(results, options) { + const customContainers = options.customContainers || []; + const render = (body) => ` +◪ Recalled from supermemory for this prompt (relevance-ranked): +${body} + +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 or launch the context-gatherer agent. +`; + const items = results.map((result, index) => { + const memory = singleLine(result.memory); + const title = singleLine(result.title); + const filepath = singleLine(result.filepath); + const factPrefix = '- ◪ '; + return { + before: index === 0 ? '' : '\n', + fact: memory, + text: `- ◪ ${title && !memory.startsWith(title) ? `${title} — ` : ''}${memory}${filepath ? ` (${filepath})` : ''}`, + truncateText: `${factPrefix}${memory}`, + factOffset: factPrefix.length, + }; + }); + items.push({ + before: '\n\n', + fact: null, + text: `Recall container: ${singleLine(options.containerTag)}`, + }); + if (customContainers.length) { + items.push({ + before: '\n', + fact: null, + text: 'Configured automatic recall containers:', + }); + items.push( + ...customContainers.map((container) => ({ + before: '\n', + fact: null, + text: `- ${singleLine(container.tag)}: ${singleLine(container.description)}`, + })), + ); + } + return formatBoundedItems( + items, + options.maxTokens, + 'maxPromptRecallTokens', + render, + ); +} + +function formatSessionContext(result, options) { + const take = (facts) => + facts + .map((fact) => singleLine(fact)) + .filter(Boolean) + .slice(0, Math.max(0, options.maxProfileItems)); + const facts = [ + ...take(result?.profile?.static || []), + ...take(result?.profile?.dynamic || []), + ]; + const render = (body) => ` +Recalled memory for this project. Every line marked ◪ comes from supermemory — when citing one, keep the mark and phrase it naturally. If you name the source, say "from supermemory" — never "from memory". + +${body} +`; + const items = facts.map((fact, index) => { + const factPrefix = `${index + 1}. ◪ `; + return { + before: index === 0 ? '[Memory Profile]\n' : '\n', + fact, + text: `${factPrefix}${fact}`, + factOffset: factPrefix.length, + }; + }); + items.push( + { + before: '\n\n', + fact: null, + text: `Project: ${singleLine(options.projectName)}`, + }, + { + before: '\n', + fact: null, + text: `Memory container: ${singleLine(options.containerTag)}`, + }, + ); + return formatBoundedItems( + items, + options.maxTokens, + 'maxRecallTokens', + render, + ); +} + +module.exports = { + formatRecallContext, + formatSessionContext, + getRecallContainerTags, + mergeProfileResults, + normalizeText, + resultText, +}; diff --git a/plugin/hooks/lib/settings.js b/plugin/hooks/lib/settings.js index 14907c3..d99920e 100644 --- a/plugin/hooks/lib/settings.js +++ b/plugin/hooks/lib/settings.js @@ -7,10 +7,34 @@ const { loadProjectConfig } = require('./project-config'); const BASE_URL = 'https://api.supermemory.ai'; const SETTINGS_DIR = path.join(os.homedir(), '.supermemory-claude'); const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json'); +const SHARED_SETTINGS_FILE = path.join( + os.homedir(), + '.codex', + 'supermemory.json', +); +const SHARED_CREDENTIALS_FILE = path.join( + os.homedir(), + '.codex', + 'supermemory', + 'credentials.json', +); +const SHARED_RECALL_KEYS = [ + 'maxMemories', + 'maxProfileItems', + 'maxRecallTokens', + 'maxPromptRecallTokens', + 'autoRecallContainers', + 'customContainers', +]; const DEFAULT_SETTINGS = { includeTools: [], + maxMemories: 5, maxProfileItems: 5, + maxRecallTokens: 2500, + maxPromptRecallTokens: 500, + autoRecallContainers: false, + customContainers: [], debug: false, injectProfile: true, recallDirective: null, @@ -37,15 +61,43 @@ const DEFAULT_SETTINGS = { signalTurnsBefore: 3, }; +function readSettings(file) { + try { + if (!fs.existsSync(file)) return {}; + const value = JSON.parse(fs.readFileSync(file, 'utf-8')); + return value && typeof value === 'object' && !Array.isArray(value) + ? value + : {}; + } catch { + console.error(`Settings: Failed to load ${file}`); + return {}; + } +} + function loadSettings() { + const shared = readSettings(SHARED_SETTINGS_FILE); const settings = { ...DEFAULT_SETTINGS }; - try { - if (fs.existsSync(SETTINGS_FILE)) { - Object.assign(settings, JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8'))); + for (const key of SHARED_RECALL_KEYS) { + if (Object.hasOwn(shared, key) && shared[key] != null) { + settings[key] = shared[key]; } - } catch (err) { - console.error(`Settings: Failed to load ${SETTINGS_FILE}: ${err.message}`); } + Object.assign(settings, readSettings(SETTINGS_FILE)); + settings.autoRecallContainers = settings.autoRecallContainers === true; + settings.customContainers = Array.isArray(settings.customContainers) + ? settings.customContainers + .filter( + (container) => + container && + typeof container.tag === 'string' && + container.tag.trim() && + typeof container.description === 'string', + ) + .map((container) => ({ + tag: container.tag.trim(), + description: container.description.trim(), + })) + : []; if (process.env.SUPERMEMORY_DEBUG === 'true') settings.debug = true; return settings; } @@ -76,10 +128,18 @@ function normalizeBaseUrl(baseUrl) { } } -function getBaseUrl(cwd, projectConfig) { +function getBaseUrl(cwd, projectConfig, apiKey) { projectConfig = projectConfig || loadProjectConfig(cwd || process.cwd()); + const sharedCredentials = readSettings(SHARED_CREDENTIALS_FILE); + const sharedBaseUrl = + apiKey && sharedCredentials.apiKey === apiKey + ? normalizeBaseUrl(sharedCredentials.apiBaseUrl) + : null; const configured = - process.env.SUPERMEMORY_API_URL || projectConfig?.baseUrl || BASE_URL; + process.env.SUPERMEMORY_API_URL || + projectConfig?.baseUrl || + sharedBaseUrl || + BASE_URL; const normalized = normalizeBaseUrl(configured); if (!normalized) { throw new Error('Invalid baseUrl: expected an absolute http(s) URL'); @@ -139,14 +199,6 @@ function getSignalConfig(cwd) { return { enabled, keywords, turnsBefore }; } -function getRecallConfig(cwd) { - const settings = loadSettings(); - const projectConfig = loadProjectConfig(cwd || process.cwd()); - return { - directive: projectConfig?.recallDirective || settings.recallDirective || null, - }; -} - module.exports = { SETTINGS_DIR, SETTINGS_FILE, @@ -158,5 +210,4 @@ module.exports = { getIncludeTools, shouldIncludeTool, getSignalConfig, - getRecallConfig, }; diff --git a/plugin/hooks/lib/statusline-state.js b/plugin/hooks/lib/statusline-state.js index b3da9c5..6d657f9 100644 --- a/plugin/hooks/lib/statusline-state.js +++ b/plugin/hooks/lib/statusline-state.js @@ -11,7 +11,9 @@ const SESSION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // Fixed location: hooks and the statusline renderer run in different process // environments, so neither may trust env vars to find the other's state. function resolveStatuslineDataDir(explicitDir) { - return explicitDir || path.join(os.homedir(), '.supermemory-claude', 'statusline'); + return ( + explicitDir || path.join(os.homedir(), '.supermemory-claude', 'statusline') + ); } function hashValue(value) { diff --git a/plugin/hooks/lib/stdin.js b/plugin/hooks/lib/stdin.js index 24e6a6e..25be7cc 100644 --- a/plugin/hooks/lib/stdin.js +++ b/plugin/hooks/lib/stdin.js @@ -31,7 +31,10 @@ async function readStdin(timeoutMs = STDIN_TIMEOUT_MS) { finish(resolve, JSON.parse(value)); } catch (err) { if (final) { - finish(reject, new Error(`Failed to parse stdin JSON: ${err.message}`)); + finish( + reject, + new Error(`Failed to parse stdin JSON: ${err.message}`), + ); } } }; diff --git a/plugin/hooks/mcp-proxy.js b/plugin/hooks/mcp-proxy.js index 5add792..1941858 100644 --- a/plugin/hooks/mcp-proxy.js +++ b/plugin/hooks/mcp-proxy.js @@ -105,7 +105,11 @@ async function main() { try { await forward(message, apiKey); } catch (err) { - sendError(message.id, -32000, `Supermemory MCP proxy error: ${err.message}`); + sendError( + message.id, + -32000, + `Supermemory MCP proxy error: ${err.message}`, + ); } }); }); diff --git a/plugin/hooks/recall-approve.js b/plugin/hooks/recall-approve.js index 3fe9168..91f328d 100644 --- a/plugin/hooks/recall-approve.js +++ b/plugin/hooks/recall-approve.js @@ -7,7 +7,8 @@ const { readStdin, writeOutput } = require('./lib/stdin'); // config), mcp__plugin_supermemory_supermemory__ (plugin-scoped), or // mcp__claude_ai_supermemory__ (claude.ai connector). Only read-only // tools run without a prompt; writes (add_memory, save-memory, ...) still ask. -const TOOL_NAME_RE = /^mcp__(?:plugin_supermemory_|claude_ai_)?supermemory__(.+)$/; +const TOOL_NAME_RE = + /^mcp__(?:plugin_supermemory_|claude_ai_)?supermemory__(.+)$/; const READ_ONLY_TOOLS = new Set([ 'search_memory', 'listSpaces', diff --git a/plugin/hooks/recall-directive.js b/plugin/hooks/recall-directive.js index 89d8cb6..ac365b4 100644 --- a/plugin/hooks/recall-directive.js +++ b/plugin/hooks/recall-directive.js @@ -2,9 +2,16 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const { getProfile } = require('./lib/api'); +const { getProfiles } = require('./lib/api'); const { BRAND, gray, red } = require('./lib/colors'); const { getContainerTag } = require('./lib/container-tag'); +const { + formatRecallContext, + getRecallContainerTags, + mergeProfileResults, + normalizeText, + resultText, +} = require('./lib/context'); const { getUserFriendlyError } = require('./lib/error-helpers'); const { loadProjectConfig } = require('./lib/project-config'); const { @@ -12,7 +19,6 @@ const { getApiKey, getBaseUrl, debugLog, - getRecallConfig, } = require('./lib/settings'); const { atomicWriteJson, @@ -28,9 +34,6 @@ const { readStdin, writeOutput } = require('./lib/stdin'); // to spend a tool call. A configured recallDirective restores advisory mode. const MIN_PROMPT_LENGTH = 12; const MAX_QUERY_LENGTH = 500; -const MAX_RESULTS = 5; -const MAX_RESULT_CHARS = 300; -const MIN_SIMILARITY = 0.55; const SEARCH_TIMEOUT_MS = 4000; const MAX_SEEN_HASHES = 500; @@ -39,16 +42,6 @@ function shouldSkip(prompt) { return ['/', '!', '#'].includes(prompt[0]); } -// Search hits are memory-shaped (.memory) or document/chunk-shaped -// (.chunk/.content/.text, usually with a filepath) — read whichever carries -// the text. -function resultText(r) { - const text = [r?.memory, r?.chunk, r?.content, r?.text].find( - (v) => typeof v === 'string' && v.trim(), - ); - return text || null; -} - // A memory injected once this session stays in the conversation, so // re-injecting it wastes context and makes the banner repeat the same // number every turn. The seen set lives next to the statusline state and @@ -56,7 +49,7 @@ function resultText(r) { function hashText(text) { return crypto .createHash('sha256') - .update(text.replace(/\s+/g, ' ').trim()) + .update(normalizeText(text)) .digest('hex') .slice(0, 16); } @@ -66,29 +59,12 @@ function readSeenHashes(sessionDir) { const list = JSON.parse( fs.readFileSync(path.join(sessionDir, 'recalled.json'), 'utf8'), ); - return Array.isArray(list) - ? list.filter((h) => typeof h === 'string') - : []; + return Array.isArray(list) ? list.filter((h) => typeof h === 'string') : []; } catch { return []; } } -function formatRecall(results, containerTag) { - const lines = results.map((r) => { - const text = resultText(r).replace(/\s+/g, ' ').slice(0, MAX_RESULT_CHARS); - const title = typeof r.title === 'string' && r.title.trim() ? r.title.trim() : null; - const prefix = title && !text.startsWith(title) ? `${title} — ` : ''; - return `- ◪ ${prefix}${text}${typeof r.filepath === 'string' && r.filepath ? ` (${r.filepath})` : ''}`; - }); - return ` -◪ 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. -`; -} - async function main() { const settings = loadSettings(); @@ -96,7 +72,9 @@ async function main() { const input = await readStdin(); const cwd = input.cwd || process.cwd(); const prompt = (input.prompt || '').trim(); - const { directive } = getRecallConfig(cwd); + const projectConfig = loadProjectConfig(cwd); + const directive = + projectConfig?.recallDirective || settings.recallDirective || null; if (directive) { writeOutput({ @@ -113,7 +91,6 @@ async function main() { return; } - const projectConfig = loadProjectConfig(cwd); let apiKey; try { apiKey = getApiKey(cwd, projectConfig); @@ -123,41 +100,49 @@ async function main() { } const containerTag = getContainerTag(cwd); - const response = await getProfile( - getBaseUrl(cwd, projectConfig), + const containerTags = getRecallContainerTags(containerTag, settings); + const responses = await getProfiles( + getBaseUrl(cwd, projectConfig, apiKey), apiKey, - containerTag, + containerTags, prompt.slice(0, MAX_QUERY_LENGTH), { timeoutMs: SEARCH_TIMEOUT_MS }, ); - - const results = (response?.searchResults?.results || []) - .filter((r) => resultText(r)) - .filter((r) => !Number.isFinite(r.similarity) || r.similarity >= MIN_SIMILARITY) - .slice(0, MAX_RESULTS); + const results = mergeProfileResults(responses, settings.maxMemories) + .searchResults.results; const sessionDir = getSessionDir(input.session_id); const seen = sessionDir ? readSeenHashes(sessionDir) : []; const seenSet = new Set(seen); - const fresh = results.filter((r) => !seenSet.has(hashText(resultText(r)))); + const fresh = results.filter( + (result) => !seenSet.has(hashText(resultText(result))), + ); const repeats = results.length - fresh.length; + const { text: context, newFacts } = formatRecallContext(fresh, { + containerTag, + maxTokens: settings.maxPromptRecallTokens, + customContainers: settings.autoRecallContainers + ? settings.customContainers + : [], + }); if (input.session_id) { const prev = readState(input.session_id).search || {}; writeState(input.session_id, 'search', { - results: fresh.length, + results: newFacts.length, count: (prev.count || 0) + 1, - memories: (prev.memories || 0) + fresh.length, + memories: (prev.memories || 0) + newFacts.length, }); } debugLog(settings, 'Prompt recall', { query: prompt.slice(0, 80), + containerTags, hits: results.length, - fresh: fresh.length, + fresh: newFacts.length, }); - if (fresh.length === 0) { + if (!context) { writeOutput({ continue: true, suppressOutput: true }); return; } @@ -166,19 +151,20 @@ async function main() { try { atomicWriteJson( path.join(sessionDir, 'recalled.json'), - [...seen, ...fresh.map((r) => hashText(resultText(r)))].slice(-MAX_SEEN_HASHES), + [...new Set([...seen, ...newFacts.map(hashText)])].slice( + -MAX_SEEN_HASHES, + ), ); } catch { // Dedup is best effort; recall itself must still go through. } } - const context = formatRecall(fresh, containerTag); // ~4 chars/token: close enough to show what the injection costs. const tok = gray(`(${Math.round(context.length / 4)} tok)`); const label = repeats - ? `recalled ${fresh.length} new ${tok}${gray(` · ${repeats} already in context`)}` - : `recalled ${fresh.length} ${fresh.length === 1 ? 'memory' : 'memories'} ${tok}`; + ? `recalled ${newFacts.length} new ${tok}${gray(` · ${repeats} already in context`)}` + : `recalled ${newFacts.length} ${newFacts.length === 1 ? 'memory' : 'memories'} ${tok}`; writeOutput({ systemMessage: `${BRAND} ${gray('·')} ${label}`, hookSpecificOutput: { diff --git a/plugin/hooks/session-start.js b/plugin/hooks/session-start.js index 598f9d9..d6d0c05 100644 --- a/plugin/hooks/session-start.js +++ b/plugin/hooks/session-start.js @@ -1,8 +1,13 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { getProfile } = require('./lib/api'); +const { getProfiles } = require('./lib/api'); const { getContainerTag, getProjectName } = require('./lib/container-tag'); +const { + formatSessionContext, + getRecallContainerTags, + mergeProfileResults, +} = require('./lib/context'); const { loadProjectConfig } = require('./lib/project-config'); const { loadSettings, @@ -118,38 +123,15 @@ function welcomeBackNotice(containerTag) { const hours = (Date.now() - new Date(last.savedAt).getTime()) / 3600000; if (hours < 6) return null; const ago = - hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`; + hours < 48 + ? `${Math.round(hours)}h ago` + : `${Math.round(hours / 24)}d ago`; return `welcome back — last session here ${ago}`; } catch { return null; } } -function formatContext(profileResult, maxItems, containerTag, projectName) { - const statics = (profileResult?.profile?.static || []).slice(0, maxItems); - const dynamics = (profileResult?.profile?.dynamic || []).slice(0, maxItems); - if (statics.length === 0 && dynamics.length === 0) return null; - - const sections = []; - if (statics.length > 0) { - sections.push( - `## User Profile (Persistent)\n${statics.map((f) => `- ◪ ${f}`).join('\n')}`, - ); - } - if (dynamics.length > 0) { - sections.push( - `## Recent Context\n${dynamics.map((f) => `- ◪ ${f}`).join('\n')}`, - ); - } - - return ` -Recalled memory for this project (${projectName}). Every line marked ◪ comes from supermemory — when citing one, keep the mark and phrase it naturally (e.g. "◪ last week you told me about X"). If you name the source, say "from supermemory" — never "from memory". -This project's memory container: ${containerTag} - -${sections.join('\n\n')} -`; -} - function output(additionalContext, systemMessageParts) { const systemMessage = systemMessageParts.filter(Boolean).join('\n'); writeOutput({ @@ -172,13 +154,22 @@ async function main() { refreshStatuslineLink(); pruneState({ dataDir: resolveStatuslineDataDir() }); - writeState(sessionId, 'context', { status: 'loading', memoryItemsLoaded: 0 }); + writeState(sessionId, 'context', { + status: 'loading', + memoryItemsLoaded: 0, + }); const projectConfig = loadProjectConfig(cwd); const projectName = getProjectName(cwd); const containerTag = getContainerTag(cwd); + const containerTags = getRecallContainerTags(containerTag, settings); - debugLog(settings, 'SessionStart', { cwd, projectName, containerTag }); + debugLog(settings, 'SessionStart', { + cwd, + projectName, + containerTag, + containerTags, + }); let apiKey; try { @@ -187,7 +178,10 @@ async function main() { try { apiKey = await startAuthFlow(); } catch (authErr) { - writeState(sessionId, 'context', { status: 'error', memoryItemsLoaded: 0 }); + writeState(sessionId, 'context', { + status: 'error', + memoryItemsLoaded: 0, + }); output( ` ${authErr.message === 'AUTH_TIMEOUT' ? 'Authentication timed out. Please complete login in the browser window.' : 'Authentication failed.'} @@ -200,12 +194,18 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable. } } - const baseUrl = getBaseUrl(cwd, projectConfig); + const baseUrl = getBaseUrl(cwd, projectConfig, apiKey); let profileResult = null; let apiError = null; try { - profileResult = await getProfile(baseUrl, apiKey, containerTag, projectName); + const responses = await getProfiles( + baseUrl, + apiKey, + containerTags, + undefined, + ); + profileResult = mergeProfileResults(responses, settings.maxMemories); } catch (err) { // Fail open, but never silently: a network failure must not be dressed // up as "this project has no memories". Only 404 means genuinely empty. @@ -213,15 +213,13 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable. debugLog(settings, 'Profile fetch failed', { error: err.message }); } - const context = formatContext( - profileResult, - settings.maxProfileItems, + const { text: context, newFacts } = formatSessionContext(profileResult, { + maxProfileItems: settings.maxProfileItems, + maxTokens: settings.maxRecallTokens, containerTag, projectName, - ); - const loaded = - Math.min(profileResult?.profile?.static?.length || 0, settings.maxProfileItems) + - Math.min(profileResult?.profile?.dynamic?.length || 0, settings.maxProfileItems); + }); + const loaded = newFacts.length; writeState(sessionId, 'context', { status: apiError ? 'error' : 'ready', @@ -234,7 +232,9 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable. : null; output( - (apiError ? `\n${apiError}\n\n` : '') + + (apiError + ? `\n${apiError}\n\n` + : '') + (context || (apiError ? ` diff --git a/plugin/hooks/status-check.js b/plugin/hooks/status-check.js new file mode 100644 index 0000000..eb4ee95 --- /dev/null +++ b/plugin/hooks/status-check.js @@ -0,0 +1,50 @@ +const { getContainerTag } = require('./lib/container-tag'); +const { getProfile } = require('./lib/api'); +const { loadProjectConfig } = require('./lib/project-config'); +const { getApiKey, getBaseUrl } = require('./lib/settings'); + +async function main() { + const cwd = process.cwd(); + const projectConfig = loadProjectConfig(cwd); + const apiKey = getApiKey(cwd, projectConfig); + const baseUrl = getBaseUrl(cwd, projectConfig, apiKey); + const containerTag = getContainerTag(cwd); + const keySource = process.env.SUPERMEMORY_CC_API_KEY + ? 'SUPERMEMORY_CC_API_KEY' + : projectConfig?.apiKey + ? 'project config' + : '~/.supermemory-claude/credentials.json'; + let httpStatus; + try { + await getProfile(baseUrl, apiKey, containerTag, 'connectivity probe', { + timeoutMs: 8000, + }); + httpStatus = 200; + } catch (error) { + if (!Number.isInteger(error.status)) throw error; + httpStatus = error.status; + } + console.log( + JSON.stringify({ + authenticated: + httpStatus === 200 + ? true + : [401, 403].includes(httpStatus) + ? false + : null, + keySource, + baseUrl, + containerTag, + httpStatus, + }), + ); +} + +main().catch((error) => { + console.error( + error.name === 'AbortError' || error.name === 'TimeoutError' + ? 'API probe timed out' + : error.cause?.message || error.message, + ); + process.exit(1); +}); diff --git a/plugin/statusline.js b/plugin/statusline.js index ac20171..45625db 100644 --- a/plugin/statusline.js +++ b/plugin/statusline.js @@ -108,7 +108,9 @@ function isFresh(record, ttl, now, contextUpdatedAt = 0) { // Transient states (saving, errors) briefly take over. function getStatus(state, now) { const { context, capture, search } = state; - const generation = Number.isFinite(context?.updatedAt) ? context.updatedAt : 0; + const generation = Number.isFinite(context?.updatedAt) + ? context.updatedAt + : 0; if ( capture?.status === 'saving' && @@ -184,7 +186,8 @@ function renderStatusline(state, options = {}) { // Rotate real content, not just paint: the tally pane alternates with live // relative ages that tick upward, so the words themselves keep changing. const panes = [null]; - if (status.savedAt) panes.push(`saved ${formatAge(now - status.savedAt)} ago`); + if (status.savedAt) + panes.push(`saved ${formatAge(now - status.savedAt)} ago`); if (status.recalledAt) { panes.push(`recalled ${formatAge(now - status.recalledAt)} ago`); } @@ -193,7 +196,9 @@ function renderStatusline(state, options = {}) { const emphasized = Math.floor(tick / EMPHASIS_TICKS) % status.parts.length; const parts = status.parts.map((part, i) => - i === emphasized ? `${WHITE}${BOLD}${part}${RESET}` : `${GRAY}${part}${RESET}`, + i === emphasized + ? `${WHITE}${BOLD}${part}${RESET}` + : `${GRAY}${part}${RESET}`, ); return `${brand} ${WHITE}·${RESET} ${parts.join(`${GRAY} · ${RESET}`)}`; } diff --git a/test/capture.mjs b/test/capture.mjs new file mode 100644 index 0000000..69734d3 --- /dev/null +++ b/test/capture.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + makeAuthedHome, + makeRepo, + makeTempDir, + runHook, + startStubServer, +} from './helpers.mjs'; + +const require = createRequire(import.meta.url); +const { readState } = require('../plugin/hooks/lib/statusline-state.js'); + +describe('capture hook', () => { + test('uses the same-key mirrored Codex endpoint for writes', async (t) => { + const { repo } = makeRepo(t); + const apiKey = 'sm_test_key_0123456789abcdef'; + const home = makeAuthedHome(t, apiKey); + const transcript = join( + makeTempDir(t, 'mirrored-capture'), + 'session.jsonl', + ); + writeFileSync( + transcript, + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-09-02T08:00:00Z', + message: { content: 'Remember the mirrored capture endpoint' }, + }), + ); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ id: 'doc_mirrored', status: 'queued' })); + }); + const sharedDir = join(home, '.codex', 'supermemory'); + mkdirSync(sharedDir, { recursive: true }); + writeFileSync( + join(sharedDir, 'credentials.json'), + JSON.stringify({ apiKey, apiBaseUrl: stub.url }), + ); + + const { code, stderr } = await runHook( + 'capture.js', + { + session_id: 'sess-mirrored-capture', + cwd: repo, + transcript_path: transcript, + }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: '' }, + ); + assert.equal(code, 0, stderr); + assert.equal(stub.requests.length, 1); + assert.equal(stub.requests[0].url, '/v3/documents'); + }); + + test('saves the transcript delta with scope metadata and entity context', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const transcript = join(makeTempDir(t, 'transcript'), 'session.jsonl'); + writeFileSync( + transcript, + [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-08-18T20:00:00Z', + message: { + content: 'Please fix the statusline symlink handling in the plugin', + }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + message: { + content: [ + { + type: 'text', + text: 'Fixed: the symlink now re-points each session.', + }, + ], + }, + }), + ].join('\n'), + ); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ id: 'doc_123', status: 'queued' })); + }); + + const { code } = await runHook( + 'capture.js', + { session_id: 'sess-2', cwd: repo, transcript_path: transcript }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + assert.equal(code, 0); + assert.equal(stub.requests.length, 1); + assert.equal(stub.requests[0].url, '/v3/documents'); + const body = JSON.parse(stub.requests[0].body); + assert.match(body.content, /statusline symlink/); + assert.match(body.containerTag, /^repo_example_project__/); + assert.equal(body.metadata.sm_scope, 'personal'); + assert.equal(body.customId, 'sess-2'); + assert.match(body.entityContext, /EXTRACT/); + + const state = readState('sess-2', { + dataDir: join(home, '.supermemory-claude', 'statusline'), + }); + assert.equal(state.capture.status, 'saved'); + }); + + test('a failed save does not advance the cursor; the retry recaptures (issue #96)', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const transcript = join( + makeTempDir(t, 'transcript-retry'), + 'session.jsonl', + ); + writeFileSync( + transcript, + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-08-18T20:00:00Z', + message: { + content: 'Remember: we chose Drizzle over Prisma for performance', + }, + }), + ); + let failing = true; + const stub = await startStubServer(t, (_record, res) => { + res.statusCode = failing ? 500 : 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(failing ? { error: 'boom' } : { id: 'doc_9' })); + }); + const env = { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + }; + const input = { + session_id: 'sess-retry', + cwd: repo, + transcript_path: transcript, + }; + + await runHook('capture.js', input, env); + const dataDir = join(home, '.supermemory-claude', 'statusline'); + assert.equal(readState('sess-retry', { dataDir }).capture.status, 'error'); + + failing = false; + await runHook('capture.js', input, env); + assert.equal(stub.requests.length, 2); + assert.match( + JSON.parse(stub.requests[1].body).content, + /Drizzle over Prisma/, + ); + assert.equal(readState('sess-retry', { dataDir }).capture.status, 'saved'); + + await runHook('capture.js', input, env); + assert.equal(stub.requests.length, 2); + }); +}); diff --git a/test/helpers.mjs b/test/helpers.mjs new file mode 100644 index 0000000..e055d87 --- /dev/null +++ b/test/helpers.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const HOOKS_DIR = join(process.cwd(), 'plugin', 'hooks'); + +export function hash16(input) { + return createHash('sha256').update(input).digest('hex').slice(0, 16); +} + +export function plain(value) { + return typeof value === 'string' + ? // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI and OSC escapes are the input. + value.replace(/\x1b(\[[0-9;]*m|\]8;;[^\x07]*\x07)/g, '') + : value; +} + +export function makeTempDir(t, prefix) { + const root = join( + tmpdir(), + `claude-sm-${prefix}-${Date.now()}-${Math.random()}`, + ); + mkdirSync(root, { recursive: true }); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +export function makeRepo(t, name = 'Example Project') { + const root = join(tmpdir(), `claude-sm-${Date.now()}-${Math.random()}`); + const repo = join(root, name); + const home = join(root, 'home'); + mkdirSync(repo, { recursive: true }); + mkdirSync(home, { recursive: true }); + const git = (args) => { + const result = spawnSync('git', args, { cwd: repo, encoding: 'utf-8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + git(['init']); + git(['config', 'user.email', 'test@example.com']); + git(['config', 'user.name', 'Test User']); + git(['remote', 'add', 'origin', 'git@github.com:acme/Example.Project.git']); + writeFileSync(join(repo, 'README.md'), '# example\n'); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { repo, git, home }; +} + +export function runHook(name, input, env = {}) { + return new Promise((resolve, reject) => { + const child = spawn('node', [join(HOOKS_DIR, name)], { + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(JSON.stringify(input)); + }); +} + +export function startStubServer(t, handler) { + return new Promise((resolve) => { + const requests = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const record = { + method: req.method, + url: req.url, + headers: req.headers, + body, + }; + requests.push(record); + handler(record, res); + }); + }); + server.listen(0, '127.0.0.1', () => { + t.after(() => server.close()); + resolve({ url: `http://127.0.0.1:${server.address().port}`, requests }); + }); + }); +} + +export function makeAuthedHome(t, apiKey = 'sm_test_key_0123456789abcdef') { + const home = makeTempDir(t, 'home'); + mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); + writeFileSync( + join(home, '.supermemory-claude', 'credentials.json'), + JSON.stringify({ apiKey }), + ); + return home; +} diff --git a/test/recall.mjs b/test/recall.mjs new file mode 100644 index 0000000..5481d30 --- /dev/null +++ b/test/recall.mjs @@ -0,0 +1,827 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + HOOKS_DIR, + hash16, + makeAuthedHome, + makeRepo, + makeTempDir, + plain, + runHook, + startStubServer, +} from './helpers.mjs'; + +const require = createRequire(import.meta.url); +const { + getSessionDir, + readState, +} = require('../plugin/hooks/lib/statusline-state.js'); +const { + formatRecallContext, + formatSessionContext, + getRecallContainerTags, + mergeProfileResults, +} = require('../plugin/hooks/lib/context.js'); +const { getProfiles } = require('../plugin/hooks/lib/api.js'); + +function runSettings( + home, + { apiKey = 'sm_shared', projectConfig = null, apiUrl = '' } = {}, +) { + const modulePath = join(HOOKS_DIR, 'lib', 'settings.js'); + const script = ` + const settings = require(${JSON.stringify(modulePath)}); + console.log(JSON.stringify({ + settings: settings.loadSettings(), + signal: settings.getSignalConfig(process.cwd()), + includeTools: settings.getIncludeTools(process.cwd()), + baseUrl: settings.getBaseUrl( + process.cwd(), + ${JSON.stringify(projectConfig)}, + ${JSON.stringify(apiKey)}, + ), + })); + `; + const result = spawnSync('node', ['-e', script], { + encoding: 'utf-8', + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: apiUrl, + }, + }); + return { + ...result, + loaded: result.status === 0 ? JSON.parse(result.stdout) : null, + }; +} + +function readSettings(home, apiKey = 'sm_shared', options = {}) { + const result = runSettings(home, { apiKey, ...options }); + assert.equal(result.status, 0, result.stderr); + return result.loaded; +} + +describe('recall settings and merging', () => { + test('shares only recall settings and applies Claude overrides', (t) => { + const home = makeTempDir(t, 'settings'); + mkdirSync(join(home, '.codex'), { recursive: true }); + mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); + writeFileSync( + join(home, '.codex', 'supermemory.json'), + JSON.stringify({ + maxMemories: 15, + maxProfileItems: 15, + maxRecallTokens: 5000, + maxPromptRecallTokens: 2000, + autoRecallContainers: true, + customContainers: [ + { tag: 'coding_personal', description: 'Personal.' }, + ], + debug: true, + includeTools: ['Bash'], + recallDirective: 'Codex-only directive', + signalExtraction: true, + }), + ); + mkdirSync(join(home, '.codex', 'supermemory'), { recursive: true }); + writeFileSync( + join(home, '.codex', 'supermemory', 'credentials.json'), + JSON.stringify({ + apiKey: 'sm_shared', + apiBaseUrl: 'http://127.0.0.1:6767', + }), + ); + writeFileSync( + join(home, '.supermemory-claude', 'settings.json'), + JSON.stringify({ maxMemories: 2 }), + ); + + const loaded = readSettings(home); + assert.equal(loaded.settings.maxMemories, 2); + assert.equal(loaded.settings.maxProfileItems, 15); + assert.equal(loaded.settings.maxRecallTokens, 5000); + assert.equal(loaded.settings.maxPromptRecallTokens, 2000); + assert.equal(loaded.settings.autoRecallContainers, true); + assert.equal(loaded.settings.debug, false); + assert.equal(loaded.settings.recallDirective, null); + assert.equal(loaded.signal.enabled, false); + assert.deepEqual(loaded.includeTools, []); + assert.equal(loaded.baseUrl, 'http://127.0.0.1:6767'); + assert.equal( + readSettings(home, 'sm_other').baseUrl, + 'https://api.supermemory.ai', + ); + }); + + test('tolerates non-object shared JSON and redacts malformed credentials', (t) => { + const home = makeTempDir(t, 'malformed-shared'); + const sharedDir = join(home, '.codex', 'supermemory'); + mkdirSync(sharedDir, { recursive: true }); + + for (const value of [null, [], 'unrelated', 7]) { + writeFileSync( + join(home, '.codex', 'supermemory.json'), + JSON.stringify(value), + ); + writeFileSync(join(sharedDir, 'credentials.json'), JSON.stringify(value)); + const result = runSettings(home); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.loaded.settings.maxMemories, 5); + assert.equal(result.loaded.baseUrl, 'https://api.supermemory.ai'); + } + + writeFileSync( + join(home, '.codex', 'supermemory.json'), + JSON.stringify({ + maxMemories: null, + maxProfileItems: null, + maxRecallTokens: null, + maxPromptRecallTokens: null, + }), + ); + const loaded = readSettings(home).settings; + assert.equal(loaded.maxMemories, 5); + assert.equal(loaded.maxProfileItems, 5); + assert.equal(loaded.maxRecallTokens, 2500); + assert.equal(loaded.maxPromptRecallTokens, 500); + + const sentinel = 'sm_SECRET_MUST_NOT_REACH_STDERR'; + writeFileSync(join(sharedDir, 'credentials.json'), sentinel); + const result = runSettings(home); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.loaded.baseUrl, 'https://api.supermemory.ai'); + assert.match(result.stderr, /Failed to load/); + assert.doesNotMatch(result.stderr, new RegExp(sentinel)); + }); + + test('keeps explicit endpoint precedence and ignores invalid mirrored URLs', (t) => { + const home = makeTempDir(t, 'endpoint-precedence'); + const sharedDir = join(home, '.codex', 'supermemory'); + mkdirSync(sharedDir, { recursive: true }); + writeFileSync( + join(sharedDir, 'credentials.json'), + JSON.stringify({ + apiKey: 'sm_shared', + apiBaseUrl: 'http://127.0.0.1:6767', + }), + ); + + assert.equal( + readSettings(home, 'sm_shared', { apiUrl: 'http://127.0.0.1:7001' }) + .baseUrl, + 'http://127.0.0.1:7001', + ); + assert.equal( + readSettings(home, 'sm_shared', { + projectConfig: { baseUrl: 'http://127.0.0.1:7002' }, + }).baseUrl, + 'http://127.0.0.1:7002', + ); + + writeFileSync( + join(sharedDir, 'credentials.json'), + JSON.stringify({ apiKey: 'sm_shared', apiBaseUrl: 'not-a-url' }), + ); + assert.equal(readSettings(home).baseUrl, 'https://api.supermemory.ai'); + }); + + test('normalizes custom containers without requiring a description', (t) => { + const home = makeTempDir(t, 'container-normalization'); + mkdirSync(join(home, '.codex'), { recursive: true }); + writeFileSync( + join(home, '.codex', 'supermemory.json'), + JSON.stringify({ + autoRecallContainers: true, + customContainers: [ + { tag: ' coding_personal ', description: '' }, + { tag: ' copla_company ', description: ' Company knowledge. ' }, + { tag: '', description: 'invalid' }, + { tag: 'missing_description' }, + ], + }), + ); + + const loaded = readSettings(home).settings; + assert.deepEqual(loaded.customContainers, [ + { tag: 'coding_personal', description: '' }, + { tag: 'copla_company', description: 'Company knowledge.' }, + ]); + assert.deepEqual(getRecallContainerTags('repo_test', loaded), [ + 'repo_test', + 'coding_personal', + 'copla_company', + ]); + }); + + test('keeps status and inspector descriptions aligned with automatic recall', () => { + const status = readFileSync( + join(process.cwd(), 'plugin', 'commands', 'status.md'), + 'utf8', + ); + assert.match(status, /status-check\.js/); + assert.doesNotMatch(status, /SUPERMEMORY_API_URL:-/); + + const inspector = readFileSync( + join(process.cwd(), 'plugin-inspector.ts'), + 'utf8', + ); + assert.doesNotMatch(inspector, /directiveSrc|id="directive"/); + assert.match(inspector, /bounded automatic recall/); + assert.doesNotMatch(inspector, /local, no network/); + }); + + test('requires a literal boolean to search custom containers', () => { + const customContainers = [ + { tag: 'coding_personal', description: 'Personal.' }, + ]; + assert.deepEqual( + getRecallContainerTags('repo_test', { + autoRecallContainers: 'false', + customContainers, + }), + ['repo_test'], + ); + assert.deepEqual( + getRecallContainerTags('repo_test', { + autoRecallContainers: true, + customContainers, + }), + ['repo_test', 'coding_personal'], + ); + }); + + test('dedupes whitespace-equivalent results before the global cap', () => { + const merged = mergeProfileResults( + [ + { + searchResults: { + results: [ + { + memory: 'Use the shared settings loader', + similarity: 0.8, + title: 'lower', + }, + ], + }, + }, + { + searchResults: { + results: [ + { + memory: 'Use the shared\nsettings loader', + similarity: 0.9, + title: 'higher', + }, + ], + }, + }, + ], + 15, + ); + assert.equal(merged.searchResults.results.length, 1); + assert.equal(merged.searchResults.results[0].similarity, 0.9); + assert.equal(merged.searchResults.results[0].title, 'higher'); + }); + + test('rejects finite negative relevance but keeps unscored results', () => { + const merged = mergeProfileResults( + [ + { + searchResults: { + results: [ + { memory: 'negative similarity', similarity: -0.5 }, + { memory: 'negative score', score: -0.25 }, + { memory: 'unscored result' }, + ], + }, + }, + ], + 15, + ); + assert.deepEqual( + merged.searchResults.results.map((result) => result.memory), + ['unscored result'], + ); + }); + + test('caps static and dynamic profile facts independently', () => { + const merged = mergeProfileResults( + [ + { + profile: { static: ['s1', 's2', 's3'], dynamic: ['d1', 'd2', 'd3'] }, + }, + ], + 15, + ); + const { newFacts } = formatSessionContext(merged, { + maxProfileItems: 2, + maxTokens: 1000, + containerTag: 'repo_test', + projectName: 'Test', + }); + assert.deepEqual(newFacts, ['s1', 's2', 'd1', 'd2']); + }); + + test('keeps SessionStart wrappers complete at the whole-context budget', () => { + const { text, newFacts } = formatSessionContext( + { profile: { static: ['x'.repeat(4000)], dynamic: [] } }, + { + maxProfileItems: 15, + maxTokens: 120, + containerTag: 'repo_test', + projectName: 'Test', + }, + ); + assert.ok(text.length <= 480); + assert.match(text, /…/); + assert.match(text, /<\/supermemory-context>$/); + assert.equal(newFacts.length, 1); + }); + + test('surfaces a non-404 failure when every container request fails', async (t) => { + const stub = await startStubServer(t, (record, res) => { + const { containerTag } = JSON.parse(record.body); + res.statusCode = containerTag === 'missing' ? 404 : 503; + res.end(containerTag); + }); + await assert.rejects( + getProfiles(stub.url, 'sm_test', ['missing', 'unavailable']), + (error) => error.status === 503, + ); + await assert.rejects( + getProfiles(stub.url, 'sm_test', ['missing']), + (error) => error.status === 404, + ); + }); +}); + +describe('recall-directive hook', () => { + test('searches with the prompt and injects the top matches', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + searchResults: { + results: [ + { memory: 'Chose Drizzle over Prisma', similarity: 0.82 }, + { + chunk: 'export const db = drizzle(client)', + filepath: 'src/db.ts', + similarity: 0.74, + }, + { memory: 'Errors must be loud and obvious', similarity: 0.71 }, + { + title: 'Migration plan', + content: 'Use expand-contract migrations', + similarity: 0.7, + }, + { memory: 'irrelevant low-similarity hit', similarity: 0.2 }, + ], + }, + }), + ); + }); + + const { code, stdout } = await runHook( + 'recall-directive.js', + { + session_id: 's1', + cwd: repo, + prompt: 'continue the database work from before', + }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + assert.equal(code, 0); + const output = JSON.parse(stdout); + const context = output.hookSpecificOutput.additionalContext; + assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); + assert.match(context, //); + assert.match(context, /- ◪ Chose Drizzle over Prisma/); + assert.match( + context, + /- ◪ export const db = drizzle\(client\) \(src\/db\.ts\)/, + ); + assert.match(context, /- ◪ Errors must be loud and obvious/); + assert.match( + context, + /- ◪ Migration plan — Use expand-contract migrations/, + ); + assert.doesNotMatch(context, /irrelevant low-similarity hit/); + assert.match(context, /repo_example_project__/); + assert.match( + plain(output.systemMessage), + /^◪ supermemory · recalled \d+ memories \(\d+ tok\)$/, + ); + assert.equal(stub.requests[0].url, '/v4/profile'); + assert.equal( + JSON.parse(stub.requests[0].body).q, + 'continue the database work from before', + ); + + const state = readState('s1', { + dataDir: join(home, '.supermemory-claude', 'statusline'), + }); + assert.equal(state.search.count, 1); + assert.equal(state.search.results, 4); + assert.equal(state.search.memories, 4); + }); + + test('mirrors shared Codex limits and globally ranks automatic containers', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + mkdirSync(join(home, '.codex'), { recursive: true }); + writeFileSync( + join(home, '.codex', 'supermemory.json'), + JSON.stringify({ + maxMemories: 15, + maxPromptRecallTokens: 2000, + autoRecallContainers: true, + customContainers: [ + { tag: 'coding_personal', description: 'Personal coding decisions.' }, + { tag: 'copla_company', description: 'Company knowledge.' }, + { tag: 'unavailable', description: 'Temporarily unavailable.' }, + ], + }), + ); + const results = { + coding_personal: Array.from({ length: 8 }, (_, index) => ({ + memory: + index === 0 + ? 'Tomauskasz GitHub account preference' + : `coding-${index}`, + similarity: 0.99 - index / 100, + })), + copla_company: Array.from({ length: 8 }, (_, index) => ({ + memory: `Copla company knowledge workflow ${index}`, + similarity: 0.985 - index / 100, + })), + }; + const stub = await startStubServer(t, (record, res) => { + const { containerTag } = JSON.parse(record.body); + if (containerTag === 'unavailable') { + res.statusCode = 503; + res.end('unavailable'); + return; + } + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + searchResults: { + results: + results[containerTag] || + Array.from({ length: 8 }, (_, index) => ({ + memory: `repo-${index}`, + similarity: 0.97 - index / 100, + })), + }, + }), + ); + }); + + const { stdout } = await runHook( + 'recall-directive.js', + { + session_id: 's-shared-config', + cwd: repo, + prompt: + 'recall personal GitHub preferences and Copla company workflows', + }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + const context = JSON.parse(stdout).hookSpecificOutput.additionalContext; + const tags = stub.requests.map( + (request) => JSON.parse(request.body).containerTag, + ); + assert.deepEqual( + new Set(tags), + new Set([ + `repo_example_project__${hash16('github.com/acme/example.project')}`, + 'coding_personal', + 'copla_company', + 'unavailable', + ]), + ); + assert.equal((context.match(/^- ◪ /gm) || []).length, 15); + assert.ok(context.indexOf('Tomauskasz') < context.indexOf('repo-0')); + assert.match(context, /Copla company knowledge workflow/); + assert.match(context, /Configured automatic recall containers:/); + assert.ok(context.length <= 8000); + assert.match(context, /<\/supermemory-recall>$/); + }); + + test('preserves complete recall wrappers at the token budget', () => { + const { text, newFacts } = formatRecallContext( + [ + { + memory: 'short memory', + title: 't'.repeat(4000), + filepath: 'p'.repeat(4000), + }, + ], + { + containerTag: 'repo_test', + maxTokens: 200, + customContainers: [ + { tag: 'coding_personal', description: 'd'.repeat(4000) }, + ], + }, + ); + assert.ok(text.length <= 800); + assert.match(text, /short memory/); + assert.match(text, /…/); + assert.match(text, /<\/supermemory-recall>$/); + assert.equal(newFacts.length, 1); + }); + + test('does not count a prefix-only truncated memory as emitted', () => { + const options = { + containerTag: 'repo_test', + customContainers: [], + }; + let minimumTokens = null; + for (let tokens = 0.25; tokens < 500; tokens += 0.25) { + try { + formatRecallContext([], { ...options, maxTokens: tokens }); + minimumTokens = tokens; + break; + } catch {} + } + assert.notEqual(minimumTokens, null); + + const result = formatRecallContext([{ memory: 'must remain eligible' }], { + ...options, + maxTokens: minimumTokens + 0.5, + }); + assert.equal(result.text, ''); + assert.deepEqual(result.newFacts, []); + }); + + test('budgets the automatic-container catalog as variable context', () => { + const { text, newFacts } = formatRecallContext( + [{ memory: 'short memory' }], + { + containerTag: 'repo_test', + maxTokens: 200, + customContainers: [ + { tag: 'coding_personal', description: 'd'.repeat(4000) }, + ], + }, + ); + assert.ok(text.length <= 800); + assert.match(text, /short memory/); + assert.match(text, /Configured automatic recall containers:/); + assert.match(text, /…/); + assert.match(text, /<\/supermemory-recall>$/); + assert.equal(newFacts.length, 1); + }); + + test('keeps the compatibility prompt budget when settings are absent', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + searchResults: { + results: Array.from({ length: 5 }, (_, index) => ({ + memory: `${index}:${'x'.repeat(4000)}`, + similarity: 0.9 - index / 100, + })), + }, + }), + ); + }); + + const { stdout } = await runHook( + 'recall-directive.js', + { + session_id: 's-default-budget', + cwd: repo, + prompt: 'recall the previous implementation', + }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + const context = JSON.parse(stdout).hookSpecificOutput.additionalContext; + assert.ok(context.length <= 2000); + assert.match(context, /<\/supermemory-recall>$/); + }); + + test('persists only the emitted fragment of a truncated memory', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + writeFileSync( + join(home, '.supermemory-claude', 'settings.json'), + JSON.stringify({ maxMemories: 3, maxPromptRecallTokens: 150 }), + ); + const hits = ['A', 'B', 'C'].map((prefix, index) => ({ + memory: `${prefix}:${prefix.repeat(1000)}`, + similarity: 0.9 - index / 100, + })); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ searchResults: { results: hits } })); + }); + const input = { + session_id: 's-emitted-only', + cwd: repo, + prompt: 'recall the long ordered memories', + }; + const env = { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + }; + + const formatted = formatRecallContext(hits, { + containerTag: 'repo_example_project', + customContainers: [], + maxTokens: 150, + }); + assert.equal(formatted.newFacts.length, 1); + assert.match(formatted.newFacts[0], /^A:A+…$/); + assert.notEqual(formatted.newFacts[0], hits[0].memory); + + const first = JSON.parse( + (await runHook('recall-directive.js', input, env)).stdout, + ); + assert.match(first.hookSpecificOutput.additionalContext, /A:AAA/); + assert.doesNotMatch(first.hookSpecificOutput.additionalContext, /B:BBB/); + + const sessionDir = getSessionDir( + input.session_id, + join(home, '.supermemory-claude', 'statusline'), + ); + const seen = JSON.parse( + readFileSync(join(sessionDir, 'recalled.json'), 'utf8'), + ); + assert.ok(seen.includes(hash16(formatted.newFacts[0].toLowerCase()))); + assert.ok(!seen.includes(hash16(hits[0].memory.toLowerCase()))); + + const second = JSON.parse( + (await runHook('recall-directive.js', input, env)).stdout, + ); + assert.match(second.hookSpecificOutput.additionalContext, /A:AAA/); + }); + + test('does not persist a prefix-only memory as seen', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const formatterOptions = { + containerTag: 'repo_test', + customContainers: [], + }; + let minimumTokens = null; + for (let tokens = 0.25; tokens < 500; tokens += 0.25) { + try { + formatRecallContext([], { ...formatterOptions, maxTokens: tokens }); + minimumTokens = tokens; + break; + } catch {} + } + assert.notEqual(minimumTokens, null); + writeFileSync( + join(home, '.supermemory-claude', 'settings.json'), + JSON.stringify({ maxPromptRecallTokens: minimumTokens + 0.5 }), + ); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + searchResults: { + results: [{ memory: 'must remain eligible', similarity: 0.9 }], + }, + }), + ); + }); + const input = { + session_id: 's-prefix-only', + cwd: repo, + prompt: 'recall the still eligible memory', + }; + const env = { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + }; + + for (let attempt = 0; attempt < 2; attempt += 1) { + const output = JSON.parse( + (await runHook('recall-directive.js', input, env)).stdout, + ); + assert.equal(output.hookSpecificOutput, undefined); + } + const sessionDir = getSessionDir( + input.session_id, + join(home, '.supermemory-claude', 'statusline'), + ); + assert.equal(existsSync(join(sessionDir, 'recalled.json')), false); + assert.equal(stub.requests.length, 2); + }); + + test('skips trivial prompts and slash commands without an API call', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const stub = await startStubServer(t, (_record, res) => res.end('{}')); + for (const prompt of ['hi', '/supermemory:status', '!ls', undefined]) { + const { stdout } = await runHook( + 'recall-directive.js', + { session_id: 's1', cwd: repo, prompt }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + assert.equal(JSON.parse(stdout).hookSpecificOutput, undefined); + } + assert.equal(stub.requests.length, 0); + }); + + test('dedupes across the session: repeats go silent, mixes are labeled', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + let hits = [ + { memory: 'Chose Drizzle over Prisma', similarity: 0.82 }, + { memory: 'Errors must be loud and obvious', similarity: 0.71 }, + ]; + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ searchResults: { results: hits } })); + }); + const env = { + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: stub.url, + }; + const input = { + session_id: 's-dedup', + cwd: repo, + prompt: 'continue the database work', + }; + + const first = JSON.parse( + (await runHook('recall-directive.js', input, env)).stdout, + ); + assert.match( + plain(first.systemMessage), + /^◪ supermemory · recalled 2 memories \(\d+ tok\)$/, + ); + + const second = JSON.parse( + (await runHook('recall-directive.js', input, env)).stdout, + ); + assert.equal(second.systemMessage, undefined); + assert.equal(second.hookSpecificOutput, undefined); + + hits = [...hits, { memory: 'New fact about migrations', similarity: 0.8 }]; + const third = JSON.parse( + (await runHook('recall-directive.js', input, env)).stdout, + ); + assert.match( + plain(third.systemMessage), + /^◪ supermemory · recalled 1 new \(\d+ tok\) · 2 already in context$/, + ); + assert.match( + third.hookSpecificOutput.additionalContext, + /New fact about migrations/, + ); + assert.doesNotMatch( + third.hookSpecificOutput.additionalContext, + /Chose Drizzle over Prisma/, + ); + + const state = readState('s-dedup', { + dataDir: join(home, '.supermemory-claude', 'statusline'), + }); + assert.equal(state.search.count, 3); + assert.equal(state.search.results, 1); + assert.equal(state.search.memories, 3); + }); + + test('a configured recallDirective restores advisory mode verbatim', async (t) => { + const { repo, git, home } = makeRepo(t); + const configDir = join( + git(['rev-parse', '--show-toplevel']), + '.claude', + '.supermemory-claude', + ); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, 'config.json'), + JSON.stringify({ recallDirective: 'CUSTOM DIRECTIVE' }), + ); + const { stdout } = await runHook( + 'recall-directive.js', + { session_id: 's1', cwd: repo, prompt: 'a long substantive prompt here' }, + { HOME: home, USERPROFILE: home }, + ); + assert.equal( + JSON.parse(stdout).hookSpecificOutput.additionalContext, + 'CUSTOM DIRECTIVE', + ); + }); +}); diff --git a/test/session-start.mjs b/test/session-start.mjs new file mode 100644 index 0000000..e9a01cc --- /dev/null +++ b/test/session-start.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + makeAuthedHome, + makeRepo, + plain, + runHook, + startStubServer, +} from './helpers.mjs'; + +const require = createRequire(import.meta.url); +const { readState } = require('../plugin/hooks/lib/statusline-state.js'); + +describe('session-start hook', () => { + test('injects profile memories and announces the count', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + const stub = await startStubServer(t, (_record, res) => { + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + profile: { static: ['Uses Bun'], dynamic: ['Working on statusline'] }, + }), + ); + }); + + const { code, stdout } = await runHook( + 'session-start.js', + { session_id: 'sess-1', cwd: repo }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + assert.equal(code, 0); + const output = JSON.parse(stdout); + assert.match(output.hookSpecificOutput.additionalContext, /Uses Bun/); + assert.match( + output.hookSpecificOutput.additionalContext, + /Working on statusline/, + ); + assert.match( + plain(output.systemMessage), + /◪ supermemory · 2 memories loaded for Example\.Project/, + ); + assert.equal(stub.requests[0].url, '/v4/profile'); + assert.match(stub.requests[0].headers.authorization, /^Bearer sm_test/); + + const state = readState('sess-1', { + dataDir: join(home, '.supermemory-claude', 'statusline'), + }); + assert.equal(state.context.status, 'ready'); + assert.equal(state.context.memoryItemsLoaded, 2); + }); + + test('loads profile facts from shared automatic containers', async (t) => { + const { repo } = makeRepo(t); + const home = makeAuthedHome(t); + mkdirSync(join(home, '.codex'), { recursive: true }); + writeFileSync( + join(home, '.codex', 'supermemory.json'), + JSON.stringify({ + maxProfileItems: 15, + maxRecallTokens: 5000, + autoRecallContainers: true, + customContainers: [ + { tag: 'coding_personal', description: 'Personal coding decisions.' }, + { tag: 'copla_company', description: 'Company knowledge.' }, + ], + }), + ); + const stub = await startStubServer(t, (record, res) => { + const { containerTag } = JSON.parse(record.body); + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + profile: { + static: [`static:${containerTag}`], + dynamic: [`dynamic:${containerTag}`], + }, + }), + ); + }); + + const { stdout } = await runHook( + 'session-start.js', + { session_id: 'sess-shared-config', cwd: repo }, + { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, + ); + const output = JSON.parse(stdout); + const context = output.hookSpecificOutput.additionalContext; + assert.equal(stub.requests.length, 3); + assert.match(context, /static:coding_personal/); + assert.match(context, /dynamic:copla_company/); + assert.ok(context.length <= 20000); + assert.match(context, /<\/supermemory-context>$/); + assert.match(plain(output.systemMessage), /6 memories loaded/); + }); +}); diff --git a/test/status.mjs b/test/status.mjs new file mode 100644 index 0000000..42e8e0a --- /dev/null +++ b/test/status.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { + HOOKS_DIR, + makeAuthedHome, + makeRepo, + startStubServer, +} from './helpers.mjs'; + +async function runStatus(t, httpStatus, responseBody) { + const { repo } = makeRepo(t); + const apiKey = 'sm_status_secret_0123456789'; + const home = makeAuthedHome(t, apiKey); + const stub = await startStubServer(t, (record, res) => { + if (httpStatus === 302 && record.url === '/v4/profile') { + res.statusCode = 302; + res.setHeader('Location', '/redirect-target'); + res.end(); + return; + } + const responseStatus = httpStatus === 302 ? 200 : httpStatus; + res.statusCode = responseStatus; + res.setHeader('Content-Type', 'application/json'); + res.end( + responseBody ?? + JSON.stringify( + responseStatus === 200 + ? { profile: { static: [], dynamic: [] } } + : { error: 'probe failed' }, + ), + ); + }); + const sharedDir = join(home, '.codex', 'supermemory'); + mkdirSync(sharedDir, { recursive: true }); + writeFileSync( + join(sharedDir, 'credentials.json'), + JSON.stringify({ apiKey, apiBaseUrl: `${stub.url}/` }), + ); + + const result = await new Promise((resolve, reject) => { + const child = spawn('node', [join(HOOKS_DIR, 'status-check.js')], { + cwd: repo, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + SUPERMEMORY_API_URL: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); + return { apiKey, output: JSON.parse(result.stdout), result, stub }; +} + +describe('status check', () => { + test('uses the mirrored runtime transport without printing the key', async (t) => { + const { apiKey, output, result, stub } = await runStatus(t, 200); + + assert.equal(result.code, 0, result.stderr); + assert.doesNotMatch(result.stdout, new RegExp(apiKey)); + assert.equal(output.authenticated, true); + assert.equal(output.keySource, '~/.supermemory-claude/credentials.json'); + assert.equal(output.baseUrl, `${stub.url}/`); + assert.equal(output.httpStatus, 200); + assert.equal(stub.requests.length, 1); + assert.equal(stub.requests[0].url, '/v4/profile'); + assert.equal(stub.requests[0].headers.authorization, `Bearer ${apiKey}`); + }); + + test('separates rejected credentials from indeterminate failures', async (t) => { + for (const [httpStatus, authenticated] of [ + [201, null], + [204, null], + [302, null], + [401, false], + [403, false], + [429, null], + [503, null], + ]) { + const { apiKey, output, result, stub } = await runStatus(t, httpStatus); + assert.equal(result.code, 0, result.stderr); + assert.doesNotMatch(result.stdout, new RegExp(apiKey)); + assert.doesNotMatch(result.stderr, new RegExp(apiKey)); + assert.equal(output.authenticated, authenticated); + assert.equal(output.httpStatus, httpStatus); + if (httpStatus === 302) { + assert.deepEqual( + stub.requests.map((request) => request.url), + ['/v4/profile'], + ); + } + } + }); + + test('preserves a direct 200 when the response body is malformed', async (t) => { + const { apiKey, output, result, stub } = await runStatus(t, 200, '{'); + + assert.equal(result.code, 0, result.stderr); + assert.doesNotMatch(result.stdout, new RegExp(apiKey)); + assert.doesNotMatch(result.stderr, new RegExp(apiKey)); + assert.equal(output.authenticated, true); + assert.equal(output.httpStatus, 200); + assert.deepEqual( + stub.requests.map((request) => request.url), + ['/v4/profile'], + ); + }); +}); diff --git a/test/unit.mjs b/test/unit.mjs index c883f95..d0f9fe2 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -1,20 +1,26 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readdirSync, - rmSync, statSync, utimesSync, writeFileSync, } from 'node:fs'; -import http from 'node:http'; +import { createRequire } from 'node:module'; import { basename, join } from 'node:path'; -import { tmpdir } from 'node:os'; import { describe, test } from 'node:test'; -import { createRequire } from 'node:module'; +import { + HOOKS_DIR, + hash16, + makeAuthedHome, + makeRepo, + makeTempDir, + plain, + runHook, + startStubServer, +} from './helpers.mjs'; const require = createRequire(import.meta.url); const { @@ -31,45 +37,6 @@ const { getStatusLabel, renderStatusline, } = require('../plugin/statusline.js'); - -const HOOKS_DIR = join(process.cwd(), 'plugin', 'hooks'); - -function hash16(input) { - return createHash('sha256').update(input).digest('hex').slice(0, 16); -} - -// Banners and frames carry ANSI color and OSC-8 links; assertions compare plain text. -function plain(s) { - return typeof s === 'string' ? s.replace(/\x1b(\[[0-9;]*m|\]8;;[^\x07]*\x07)/g, '') : s; -} - -function makeTempDir(t, prefix) { - const root = join(tmpdir(), `claude-sm-${prefix}-${Date.now()}-${Math.random()}`); - mkdirSync(root, { recursive: true }); - t.after(() => rmSync(root, { recursive: true, force: true })); - return root; -} - -function makeRepo(t, name = 'Example Project') { - const root = join(tmpdir(), `claude-sm-${Date.now()}-${Math.random()}`); - const repo = join(root, name); - const home = join(root, 'home'); - mkdirSync(repo, { recursive: true }); - mkdirSync(home, { recursive: true }); - const git = (args) => { - const result = spawnSync('git', args, { cwd: repo, encoding: 'utf-8' }); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); - }; - git(['init']); - git(['config', 'user.email', 'test@example.com']); - git(['config', 'user.name', 'Test User']); - git(['remote', 'add', 'origin', 'git@github.com:acme/Example.Project.git']); - writeFileSync(join(repo, 'README.md'), '# example\n'); - t.after(() => rmSync(root, { recursive: true, force: true })); - return { repo, git, home }; -} - function readTags(cwd, home) { const modulePath = join(HOOKS_DIR, 'lib', 'container-tag.js'); const script = ` @@ -88,62 +55,14 @@ function readTags(cwd, home) { return JSON.parse(result.stdout); } -function runHook(name, input, env = {}) { - return new Promise((resolve, reject) => { - const child = spawn('node', [join(HOOKS_DIR, name)], { - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (chunk) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', (code) => resolve({ code, stdout, stderr })); - child.stdin.end(JSON.stringify(input)); - }); -} - -function startStubServer(t, handler) { - return new Promise((resolve) => { - const requests = []; - const server = http.createServer((req, res) => { - let body = ''; - req.on('data', (chunk) => { - body += chunk; - }); - req.on('end', () => { - const record = { method: req.method, url: req.url, headers: req.headers, body }; - requests.push(record); - handler(record, res); - }); - }); - server.listen(0, '127.0.0.1', () => { - t.after(() => server.close()); - resolve({ url: `http://127.0.0.1:${server.address().port}`, requests }); - }); - }); -} - -function makeAuthedHome(t, apiKey = 'sm_test_key_0123456789abcdef') { - const home = makeTempDir(t, 'home'); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey }), - ); - return home; -} - describe('container tags', () => { test('derives one canonical repo tag from the git remote', (t) => { const { repo, home } = makeRepo(t); const { tag, projectName } = readTags(repo, home); - assert.equal(tag, `repo_example_project__${hash16('github.com/acme/example.project')}`); + assert.equal( + tag, + `repo_example_project__${hash16('github.com/acme/example.project')}`, + ); assert.equal(projectName, 'Example.Project'); }); @@ -158,141 +77,17 @@ describe('container tags', () => { test('honors the project-config override', (t) => { const { repo, git, home } = makeRepo(t); - const configDir = join(git(['rev-parse', '--show-toplevel']), '.claude', '.supermemory-claude'); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.json'), JSON.stringify({ repoContainerTag: 'team_tag' })); - assert.equal(readTags(repo, home).tag, 'team_tag'); - }); -}); - -describe('recall-directive hook', () => { - test('searches with the prompt and injects the top matches', async (t) => { - const { repo, home } = makeRepo(t); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), + const configDir = join( + git(['rev-parse', '--show-toplevel']), + '.claude', + '.supermemory-claude', ); - const stub = await startStubServer(t, (record, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ - searchResults: { - results: [ - { memory: 'Chose Drizzle over Prisma', similarity: 0.82 }, - { chunk: 'export const db = drizzle(client)', filepath: 'src/db.ts', similarity: 0.74 }, - { memory: 'Errors must be loud and obvious', similarity: 0.71 }, - { title: 'Migration plan', content: 'Use expand-contract migrations', similarity: 0.7 }, - { memory: 'irrelevant low-similarity hit', similarity: 0.2 }, - ], - }, - }), - ); - }); - - const { code, stdout } = await runHook( - 'recall-directive.js', - { session_id: 's1', cwd: repo, prompt: 'continue the database work from before' }, - { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, - ); - assert.equal(code, 0); - const output = JSON.parse(stdout); - const context = output.hookSpecificOutput.additionalContext; - assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); - assert.match(context, //); - assert.match(context, /- ◪ Chose Drizzle over Prisma/); - assert.match(context, /- ◪ export const db = drizzle\(client\) \(src\/db\.ts\)/); - assert.match(context, /- ◪ Errors must be loud and obvious/); - assert.match(context, /- ◪ Migration plan — Use expand-contract migrations/); - assert.doesNotMatch(context, /irrelevant low-similarity hit/); - assert.match(context, /repo_example_project__/); - assert.match(plain(output.systemMessage), /^◪ supermemory · recalled \d+ memories \(\d+ tok\)$/); - assert.equal(stub.requests[0].url, '/v4/profile'); - assert.equal( - JSON.parse(stub.requests[0].body).q, - 'continue the database work from before', - ); - - const state = readState('s1', { - dataDir: join(home, '.supermemory-claude', 'statusline'), - }); - assert.equal(state.search.count, 1); - assert.equal(state.search.results, 4); - assert.equal(state.search.memories, 4); - }); - - test('skips trivial prompts and slash commands without an API call', async (t) => { - const { repo, home } = makeRepo(t); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), - ); - const stub = await startStubServer(t, (record, res) => res.end('{}')); - for (const prompt of ['hi', '/supermemory:status', '!ls', undefined]) { - const { stdout } = await runHook( - 'recall-directive.js', - { session_id: 's1', cwd: repo, prompt }, - { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, - ); - assert.equal(JSON.parse(stdout).hookSpecificOutput, undefined); - } - assert.equal(stub.requests.length, 0); - }); - - test('dedupes across the session: repeats go silent, mixes are labeled', async (t) => { - const { repo, home } = makeRepo(t); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), - ); - let hits = [ - { memory: 'Chose Drizzle over Prisma', similarity: 0.82 }, - { memory: 'Errors must be loud and obvious', similarity: 0.71 }, - ]; - const stub = await startStubServer(t, (record, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ searchResults: { results: hits } })); - }); - const env = { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }; - const input = { session_id: 's-dedup', cwd: repo, prompt: 'continue the database work' }; - - const first = JSON.parse((await runHook('recall-directive.js', input, env)).stdout); - assert.match(plain(first.systemMessage), /^◪ supermemory · recalled 2 memories \(\d+ tok\)$/); - - const second = JSON.parse((await runHook('recall-directive.js', input, env)).stdout); - assert.equal(second.systemMessage, undefined); - assert.equal(second.hookSpecificOutput, undefined); - - hits = [...hits, { memory: 'New fact about migrations', similarity: 0.8 }]; - const third = JSON.parse((await runHook('recall-directive.js', input, env)).stdout); - assert.match(plain(third.systemMessage), /^◪ supermemory · recalled 1 new \(\d+ tok\) · 2 already in context$/); - assert.match(third.hookSpecificOutput.additionalContext, /New fact about migrations/); - assert.doesNotMatch(third.hookSpecificOutput.additionalContext, /Chose Drizzle over Prisma/); - - const state = readState('s-dedup', { - dataDir: join(home, '.supermemory-claude', 'statusline'), - }); - assert.equal(state.search.count, 3); - assert.equal(state.search.results, 1); - assert.equal(state.search.memories, 3); - }); - - test('a configured recallDirective restores advisory mode verbatim', async (t) => { - const { repo, git, home } = makeRepo(t); - const configDir = join(git(['rev-parse', '--show-toplevel']), '.claude', '.supermemory-claude'); mkdirSync(configDir, { recursive: true }); writeFileSync( join(configDir, 'config.json'), - JSON.stringify({ recallDirective: 'CUSTOM DIRECTIVE' }), - ); - const { stdout } = await runHook( - 'recall-directive.js', - { session_id: 's1', cwd: repo, prompt: 'a long substantive prompt here' }, - { HOME: home, USERPROFILE: home }, + JSON.stringify({ repoContainerTag: 'team_tag' }), ); - assert.equal(JSON.parse(stdout).hookSpecificOutput.additionalContext, 'CUSTOM DIRECTIVE'); + assert.equal(readTags(repo, home).tag, 'team_tag'); }); }); @@ -344,13 +139,20 @@ describe('recall-approve hook', () => { ); const output = JSON.parse(stdout); assert.equal(output.hookSpecificOutput.permissionDecision, 'allow'); - assert.equal(plain(output.systemMessage), '◪ supermemory · recalling: auth flow decisions'); + assert.equal( + plain(output.systemMessage), + '◪ supermemory · recalling: auth flow decisions', + ); } }); test('lets write tools and unrelated tools fall through to normal permissions', async (t) => { const home = makeTempDir(t, 'approve-home2'); - for (const toolName of ['mcp__supermemory__add_memory', 'Bash', 'mcp__other__search_memory']) { + for (const toolName of [ + 'mcp__supermemory__add_memory', + 'Bash', + 'mcp__other__search_memory', + ]) { const { stdout } = await runHook( 'recall-approve.js', { session_id: 's1', tool_name: toolName, tool_input: {} }, @@ -363,141 +165,8 @@ describe('recall-approve hook', () => { }); }); -describe('session-start hook', () => { - test('injects profile memories and announces the count', async (t) => { - const { repo, home } = makeRepo(t); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), - ); - const stub = await startStubServer(t, (record, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ - profile: { static: ['Uses Bun'], dynamic: ['Working on statusline'] }, - }), - ); - }); - - const { code, stdout } = await runHook( - 'session-start.js', - { session_id: 'sess-1', cwd: repo }, - { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, - ); - assert.equal(code, 0); - const output = JSON.parse(stdout); - assert.match(output.hookSpecificOutput.additionalContext, /Uses Bun/); - assert.match(output.hookSpecificOutput.additionalContext, /Working on statusline/); - assert.match(plain(output.systemMessage), /◪ supermemory · 2 memories loaded for Example\.Project/); - assert.equal(stub.requests[0].url, '/v4/profile'); - assert.match(stub.requests[0].headers.authorization, /^Bearer sm_test/); - - const state = readState('sess-1', { - dataDir: join(home, '.supermemory-claude', 'statusline'), - }); - assert.equal(state.context.status, 'ready'); - assert.equal(state.context.memoryItemsLoaded, 2); - }); -}); - -describe('capture hook', () => { - test('saves the transcript delta with scope metadata and entity context', async (t) => { - const { repo, home } = makeRepo(t); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), - ); - const transcript = join(makeTempDir(t, 'transcript'), 'session.jsonl'); - writeFileSync( - transcript, - [ - JSON.stringify({ - type: 'user', - uuid: 'u1', - timestamp: '2026-08-18T20:00:00Z', - message: { content: 'Please fix the statusline symlink handling in the plugin' }, - }), - JSON.stringify({ - type: 'assistant', - uuid: 'a1', - message: { - content: [{ type: 'text', text: 'Fixed: the symlink now re-points each session.' }], - }, - }), - ].join('\n'), - ); - const stub = await startStubServer(t, (record, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ id: 'doc_123', status: 'queued' })); - }); - - const { code } = await runHook( - 'capture.js', - { session_id: 'sess-2', cwd: repo, transcript_path: transcript }, - { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }, - ); - assert.equal(code, 0); - assert.equal(stub.requests.length, 1); - assert.equal(stub.requests[0].url, '/v3/documents'); - const body = JSON.parse(stub.requests[0].body); - assert.match(body.content, /statusline symlink/); - assert.match(body.containerTag, /^repo_example_project__/); - assert.equal(body.metadata.sm_scope, 'personal'); - assert.equal(body.customId, 'sess-2'); - assert.match(body.entityContext, /EXTRACT/); - - const state = readState('sess-2', { - dataDir: join(home, '.supermemory-claude', 'statusline'), - }); - assert.equal(state.capture.status, 'saved'); - }); - - test('a failed save does not advance the cursor; the retry recaptures (issue #96)', async (t) => { - const { repo, home } = makeRepo(t); - mkdirSync(join(home, '.supermemory-claude'), { recursive: true }); - writeFileSync( - join(home, '.supermemory-claude', 'credentials.json'), - JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }), - ); - const transcript = join(makeTempDir(t, 'transcript-retry'), 'session.jsonl'); - writeFileSync( - transcript, - JSON.stringify({ - type: 'user', - uuid: 'u1', - timestamp: '2026-08-18T20:00:00Z', - message: { content: 'Remember: we chose Drizzle over Prisma for performance' }, - }), - ); - let failing = true; - const stub = await startStubServer(t, (record, res) => { - res.statusCode = failing ? 500 : 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(failing ? { error: 'boom' } : { id: 'doc_9' })); - }); - const env = { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url }; - const input = { session_id: 'sess-retry', cwd: repo, transcript_path: transcript }; - - await runHook('capture.js', input, env); - const dataDir = join(home, '.supermemory-claude', 'statusline'); - assert.equal(readState('sess-retry', { dataDir }).capture.status, 'error'); - - failing = false; - await runHook('capture.js', input, env); - assert.equal(stub.requests.length, 2); - assert.match(JSON.parse(stub.requests[1].body).content, /Drizzle over Prisma/); - assert.equal(readState('sess-retry', { dataDir }).capture.status, 'saved'); - - // Cursor advanced after success: a third run finds nothing new. - await runHook('capture.js', input, env); - assert.equal(stub.requests.length, 2); - }); -}); - describe('mcp proxy', () => { - function runProxy(t, env, lines) { + function runProxy(_t, env, lines) { return new Promise((resolve, reject) => { const child = spawn('node', [join(HOOKS_DIR, 'mcp-proxy.js')], { env: { ...process.env, ...env }, @@ -509,7 +178,13 @@ describe('mcp proxy', () => { }); child.on('error', reject); child.on('close', () => - resolve(stdout.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))), + resolve( + stdout + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)), + ), ); for (const line of lines) child.stdin.write(`${JSON.stringify(line)}\n`); child.stdin.end(); @@ -533,7 +208,10 @@ describe('mcp proxy', () => { { jsonrpc: '2.0', id: 2, method: 'tools/list' }, ], ); - assert.deepEqual(messages.map((m) => m.id), [1, 2]); + assert.deepEqual( + messages.map((m) => m.id), + [1, 2], + ); assert.match(stub.requests[0].headers.authorization, /^Bearer sm_test/); assert.equal(stub.requests[0].headers['mcp-session-id'], undefined); assert.equal(stub.requests[1].headers['mcp-session-id'], 'mcp-sess-9'); @@ -544,7 +222,9 @@ describe('mcp proxy', () => { const stub = await startStubServer(t, (record, res) => { res.setHeader('Content-Type', 'text/event-stream'); const { id } = JSON.parse(record.body); - res.end(`event: message\ndata: {"jsonrpc":"2.0","id":${id},"result":{"via":"sse"}}\n\n`); + res.end( + `event: message\ndata: {"jsonrpc":"2.0","id":${id},"result":{"via":"sse"}}\n\n`, + ); }); const messages = await runProxy( @@ -552,7 +232,9 @@ describe('mcp proxy', () => { { HOME: home, USERPROFILE: home, SUPERMEMORY_MCP_URL: `${stub.url}/mcp` }, [{ jsonrpc: '2.0', id: 7, method: 'tools/list' }], ); - assert.deepEqual(messages, [{ jsonrpc: '2.0', id: 7, result: { via: 'sse' } }]); + assert.deepEqual(messages, [ + { jsonrpc: '2.0', id: 7, result: { via: 'sse' } }, + ]); }); test('answers with a clear JSON-RPC error when unauthenticated', async (t) => { @@ -576,11 +258,26 @@ describe('statusline state', () => { test('isolates sessions and writes private atomic event files', (t) => { const dataDir = makeTempDir(t, 'status-state'); assert.equal( - writeState('../../session-a', 'context', { status: 'ready', memoryItemsLoaded: 4 }, { dataDir, now: 1000 }), + writeState( + '../../session-a', + 'context', + { status: 'ready', memoryItemsLoaded: 4 }, + { dataDir, now: 1000 }, + ), true, ); - writeState('session-a', 'search', { results: 2, query: 'must not be stored' }, { dataDir, now: 1100 }); - writeState('session-b', 'context', { status: 'ready', memoryItemsLoaded: 9 }, { dataDir, now: 1200 }); + writeState( + 'session-a', + 'search', + { results: 2, query: 'must not be stored' }, + { dataDir, now: 1100 }, + ); + writeState( + 'session-b', + 'context', + { status: 'ready', memoryItemsLoaded: 9 }, + { dataDir, now: 1200 }, + ); const first = readState('../../session-a', { dataDir }); const second = readState('session-b', { dataDir }); @@ -593,9 +290,15 @@ describe('statusline state', () => { assert.match(basename(traversalDir), /^[a-f0-9]{64}$/); if (process.platform !== 'win32') { assert.equal(statSync(traversalDir).mode & 0o777, 0o700); - assert.equal(statSync(join(traversalDir, 'context.json')).mode & 0o777, 0o600); + assert.equal( + statSync(join(traversalDir, 'context.json')).mode & 0o777, + 0o600, + ); } - assert.equal(readdirSync(traversalDir).some((name) => name.endsWith('.tmp')), false); + assert.equal( + readdirSync(traversalDir).some((name) => name.endsWith('.tmp')), + false, + ); }); test('ignores corrupt state without breaking the renderer', (t) => { @@ -613,7 +316,12 @@ describe('statusline state', () => { test('prunes only stale hashed session directories', (t) => { const dataDir = makeTempDir(t, 'status-prune'); - writeState('stale-session', 'context', { status: 'ready', memoryItemsLoaded: 1 }, { dataDir }); + writeState( + 'stale-session', + 'context', + { status: 'ready', memoryItemsLoaded: 1 }, + { dataDir }, + ); const sessionDir = getSessionDir('stale-session', dataDir); utimesSync(join(sessionDir, 'context.json'), new Date(0), new Date(0)); utimesSync(sessionDir, new Date(0), new Date(0)); @@ -644,7 +352,10 @@ describe('statusline rendering', () => { assert.equal(getStatusLabel({ context }, now), '3 loaded'); assert.equal( getStatusLabel( - { context, capture: { status: 'saved', count: 7, updatedAt: now + 10 } }, + { + context, + capture: { status: 'saved', count: 7, updatedAt: now + 10 }, + }, now + 20, ), '3 loaded · 7 captured', @@ -692,28 +403,40 @@ describe('statusline rendering', () => { test('transient states briefly take over the tally', () => { assert.equal( getStatusLabel( - { context, capture: { status: 'saving', count: 7, updatedAt: now + 15 } }, + { + context, + capture: { status: 'saving', count: 7, updatedAt: now + 15 }, + }, now + 20, ), 'saving session', ); assert.equal( getStatusLabel( - { context, capture: { status: 'saving', count: 7, updatedAt: now + 15 } }, + { + context, + capture: { status: 'saving', count: 7, updatedAt: now + 15 }, + }, now + 15 + SAVING_TTL_MS, ), '3 loaded · 7 captured', ); assert.equal( getStatusLabel( - { context, capture: { status: 'error', count: 7, updatedAt: now + 15 } }, + { + context, + capture: { status: 'error', count: 7, updatedAt: now + 15 }, + }, now + 20, ), 'session sync failed', ); assert.equal( getStatusLabel( - { context, capture: { status: 'error', count: 7, updatedAt: now + 15 } }, + { + context, + capture: { status: 'error', count: 7, updatedAt: now + 15 }, + }, now + 15 + ERROR_TTL_MS, ), '3 loaded · 7 captured', @@ -722,7 +445,10 @@ describe('statusline rendering', () => { test('animates: no frame repeats within any 10s window', () => { const states = { - saving: { context, capture: { status: 'saving', count: 2, updatedAt: now } }, + saving: { + context, + capture: { status: 'saving', count: 2, updatedAt: now }, + }, tally: { context, capture: { status: 'saved', count: 7, updatedAt: now + 10 }, @@ -734,7 +460,11 @@ describe('statusline rendering', () => { const frames = Array.from({ length: 10 }, (_, i) => renderStatusline(state, { now: now + 20 + i * TICK_MS }), ); - assert.equal(new Set(frames).size, frames.length, `${name} frames repeat`); + assert.equal( + new Set(frames).size, + frames.length, + `${name} frames repeat`, + ); } }); @@ -747,9 +477,18 @@ describe('statusline rendering', () => { const frames = Array.from({ length: 12 }, (_, i) => plain(renderStatusline(state, { now: now + 60_000 + i * TICK_MS })), ); - assert.ok(frames.some((f) => f.includes('7 captured')), 'tally pane missing'); - assert.ok(frames.some((f) => /saved \d+[smh] ago/.test(f)), 'save age pane missing'); - assert.ok(frames.some((f) => /recalled \d+[smh] ago/.test(f)), 'recall age pane missing'); + assert.ok( + frames.some((f) => f.includes('7 captured')), + 'tally pane missing', + ); + assert.ok( + frames.some((f) => /saved \d+[smh] ago/.test(f)), + 'save age pane missing', + ); + assert.ok( + frames.some((f) => /recalled \d+[smh] ago/.test(f)), + 'recall age pane missing', + ); }); test('suppresses counts from before the current session context', () => { @@ -764,7 +503,10 @@ describe('statusline rendering', () => { ), '3 loaded', ); - assert.equal(getStatusLabel({ context: { ...context, status: 'error' } }, now), null); + assert.equal( + getStatusLabel({ context: { ...context, status: 'error' } }, now), + null, + ); assert.equal( getStatusLabel({ context: { ...context, memoryItemsLoaded: 0 } }, now), 'ready',