From a0003e82d6d638f88f85db7f79509187f63ebe4a Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 08:20:41 +0300
Subject: [PATCH 1/9] feat(recall): mirror Codex recall settings
Context:
Allow Claude's SessionStart and prompt hooks to consume shared Codex recall settings and apply them across the repository and configured custom containers.
Changes:
- Load optional shared settings before Claude-specific overrides and expose memory counts, context budgets, and automatic-container configuration.
- Fan out profile requests, tolerate partial container failures, globally rank and deduplicate prompt matches, and merge profile facts.
- Bound complete prompt and SessionStart contexts, retain prompt filtering and session deduplication, and mark only emitted memories as recalled.
- Document the configuration precedence and add multi-container, budget, partial-failure, and profile-merge coverage.
Impact:
Claude can use the same maxMemories, maxProfileItems, maxRecallTokens, maxPromptRecallTokens, autoRecallContainers, and customContainers values as Codex. Existing defaults remain five memories, five profile items per section, a 2,500-token context budget, and disabled automatic custom-container recall.
Validation:
- npm test: 27 tests passed.
- Node syntax checks passed for all changed hook modules.
- git diff --staged --check passed.
- npm run lint exited successfully; the repository Biome configuration processed only biome.json and reported a schema-version information message.
Notes:
Prompt recall still uses /v4/profile. Open PR #114 independently proposes /v3/search and may require conflict resolution before merge.
Signed-off-by: Tomas <180413002+Tomauskasz@users.noreply.github.com>
---
README.md | 35 +++--
plugin/hooks/lib/api.js | 17 ++-
plugin/hooks/lib/context.js | 211 +++++++++++++++++++++++++++++++
plugin/hooks/lib/settings.js | 40 +++++-
plugin/hooks/recall-directive.js | 85 ++++++-------
plugin/hooks/session-start.js | 65 +++++-----
test/unit.mjs | 147 +++++++++++++++++++++
7 files changed, 501 insertions(+), 99 deletions(-)
create mode 100644 plugin/hooks/lib/context.js
diff --git a/README.md b/README.md
index 6de17cc..59aa69a 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,19 @@ SUPERMEMORY_DEBUG=true # Optional: enable debug logging
**Global Settings** — `~/.supermemory-claude/settings.json`
+Claude first loads shared recall settings from `~/.codex/supermemory.json` when
+that file exists. Claude-specific settings override the shared values.
+
```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 +105,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; defaults to `maxRecallTokens` |
+| `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/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js
index b5c3e0d..0c5b606 100644
--- a/plugin/hooks/lib/api.js
+++ b/plugin/hooks/lib/api.js
@@ -51,6 +51,21 @@ function getProfile(baseUrl, apiKey, containerTag, query, options = {}) {
return post(baseUrl, apiKey, '/v4/profile', { containerTag, q: query }, options.timeoutMs);
}
+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) {
+ throw settled.find((result) => result.status === 'rejected')?.reason;
+ }
+ return profiles;
+}
+
function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {}) {
const body = {
content,
@@ -62,4 +77,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/context.js b/plugin/hooks/lib/context.js
new file mode 100644
index 0000000..571742c
--- /dev/null
+++ b/plugin/hooks/lib/context.js
@@ -0,0 +1,211 @@
+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 normalize(value) {
+ return String(value || '')
+ .toLowerCase()
+ .trim();
+}
+
+function dedupe(items, keyFor) {
+ const seen = new Set();
+ return items.filter((item) => {
+ const key = normalize(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 -1;
+}
+
+function mergeProfileResults(responses, maxMemories) {
+ const staticFacts = dedupe(
+ responses.flatMap((response) => response?.profile?.static || []),
+ (fact) => fact,
+ );
+ const staticKeys = new Set(staticFacts.map(normalize));
+ const dynamicFacts = dedupe(
+ responses.flatMap((response) => response?.profile?.dynamic || []),
+ (fact) => fact,
+ ).filter((fact) => !staticKeys.has(normalize(fact)));
+
+ const searchResults = dedupe(
+ responses
+ .flatMap((response) => response?.searchResults?.results || [])
+ .filter((result) => resultText(result))
+ .filter((result) => {
+ const relevance = score(result);
+ return relevance < 0 || relevance >= RECALL_MIN_SIMILARITY;
+ })
+ .sort((a, b) => {
+ const relevance = score(b) - score(a);
+ 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
+ ? 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.prefix}${item.text}${item.suffix}`;
+ if (render(fullBody).length <= maxChars) {
+ body = fullBody;
+ newFacts.push(item.text);
+ continue;
+ }
+
+ const fixedBody = `${body}${item.before}${item.prefix}${item.suffix}`;
+ const available = maxChars - render(fixedBody).length;
+ if (available > 1) {
+ const emitted = `${item.text.slice(0, available - 1)}…`;
+ body = `${body}${item.before}${item.prefix}${emitted}${item.suffix}`;
+ newFacts.push(emitted);
+ }
+ break;
+ }
+ return { text: newFacts.length > 0 ? render(body) : '', newFacts };
+}
+
+function formatRecallContext(results, options) {
+ const customContainers = options.customContainers || [];
+ const catalog = customContainers.length
+ ? `\n\nConfigured automatic recall containers:\n${customContainers
+ .map(
+ (container) =>
+ `- ${singleLine(container.tag)}: ${singleLine(container.description)}`,
+ )
+ .join('\n')}`
+ : '';
+ const render = (body) => `
+◪ Recalled from supermemory for this prompt (relevance-ranked):
+${body}${catalog}
+
+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: "${options.containerTag}") or launch the context-gatherer agent.
+`;
+ const items = results.slice(0, Math.max(0, options.maxMemories)).map((result, index) => {
+ const memory = singleLine(result.memory);
+ const title = singleLine(result.title);
+ const filepath = singleLine(result.filepath);
+ return {
+ before: index === 0 ? '' : '\n',
+ prefix: `- ◪ ${title && !memory.startsWith(title) ? `${title} — ` : ''}`,
+ text: memory,
+ suffix: filepath ? ` (${filepath})` : '',
+ };
+ });
+ return formatBoundedItems(
+ items,
+ options.maxTokens,
+ 'maxPromptRecallTokens',
+ render,
+ );
+}
+
+function formatSessionContext(result, options) {
+ const take = (facts) =>
+ dedupe(facts, (fact) => fact)
+ .map((fact) => String(fact).trim())
+ .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 (${options.projectName}). 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".
+This project's memory container: ${options.containerTag}
+
+${body}
+`;
+ const items = facts.map((fact, index) => ({
+ before: index === 0 ? '[Memory Profile]\n' : '\n',
+ prefix: `${index + 1}. ◪ `,
+ text: fact,
+ suffix: '',
+ }));
+ return formatBoundedItems(items, options.maxTokens, 'maxRecallTokens', render);
+}
+
+module.exports = {
+ formatRecallContext,
+ formatSessionContext,
+ getRecallContainerTags,
+ mergeProfileResults,
+ resultText,
+};
diff --git a/plugin/hooks/lib/settings.js b/plugin/hooks/lib/settings.js
index 14907c3..3c6e7df 100644
--- a/plugin/hooks/lib/settings.js
+++ b/plugin/hooks/lib/settings.js
@@ -7,10 +7,20 @@ 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 DEFAULT_SETTINGS = {
includeTools: [],
+ maxMemories: 5,
maxProfileItems: 5,
+ maxRecallTokens: 2500,
+ maxPromptRecallTokens: null,
+ autoRecallContainers: false,
+ customContainers: [],
debug: false,
injectProfile: true,
recallDirective: null,
@@ -39,13 +49,26 @@ const DEFAULT_SETTINGS = {
function loadSettings() {
const settings = { ...DEFAULT_SETTINGS };
- try {
- if (fs.existsSync(SETTINGS_FILE)) {
- Object.assign(settings, JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8')));
+ for (const file of [SHARED_SETTINGS_FILE, SETTINGS_FILE]) {
+ try {
+ if (fs.existsSync(file)) {
+ Object.assign(settings, JSON.parse(fs.readFileSync(file, 'utf-8')));
+ }
+ } catch (err) {
+ console.error(`Settings: Failed to load ${file}: ${err.message}`);
}
- } catch (err) {
- console.error(`Settings: Failed to load ${SETTINGS_FILE}: ${err.message}`);
}
+ settings.maxPromptRecallTokens ??= settings.maxRecallTokens;
+ settings.customContainers = Array.isArray(settings.customContainers)
+ ? settings.customContainers.filter(
+ (container) =>
+ container &&
+ typeof container.tag === 'string' &&
+ container.tag.trim() &&
+ typeof container.description === 'string' &&
+ container.description.trim(),
+ )
+ : [];
if (process.env.SUPERMEMORY_DEBUG === 'true') settings.debug = true;
return settings;
}
@@ -144,12 +167,19 @@ function getRecallConfig(cwd) {
const projectConfig = loadProjectConfig(cwd || process.cwd());
return {
directive: projectConfig?.recallDirective || settings.recallDirective || null,
+ maxMemories: settings.maxMemories,
+ maxProfileItems: settings.maxProfileItems,
+ maxRecallTokens: settings.maxRecallTokens,
+ maxPromptRecallTokens: settings.maxPromptRecallTokens,
+ autoRecallContainers: settings.autoRecallContainers,
+ customContainers: settings.customContainers,
};
}
module.exports = {
SETTINGS_DIR,
SETTINGS_FILE,
+ SHARED_SETTINGS_FILE,
DEFAULT_SETTINGS,
loadSettings,
getApiKey,
diff --git a/plugin/hooks/recall-directive.js b/plugin/hooks/recall-directive.js
index 89d8cb6..7342e5d 100644
--- a/plugin/hooks/recall-directive.js
+++ b/plugin/hooks/recall-directive.js
@@ -2,9 +2,15 @@ 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,
+ resultText,
+} = require('./lib/context');
const { getUserFriendlyError } = require('./lib/error-helpers');
const { loadProjectConfig } = require('./lib/project-config');
const {
@@ -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
@@ -74,21 +67,6 @@ function readSeenHashes(sessionDir) {
}
}
-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,13 +74,13 @@ async function main() {
const input = await readStdin();
const cwd = input.cwd || process.cwd();
const prompt = (input.prompt || '').trim();
- const { directive } = getRecallConfig(cwd);
+ const recallConfig = getRecallConfig(cwd);
- if (directive) {
+ if (recallConfig.directive) {
writeOutput({
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
- additionalContext: directive,
+ additionalContext: recallConfig.directive,
},
});
return;
@@ -123,62 +101,73 @@ async function main() {
}
const containerTag = getContainerTag(cwd);
- const response = await getProfile(
+ const containerTags = getRecallContainerTags(containerTag, recallConfig);
+ const responses = await getProfiles(
getBaseUrl(cwd, projectConfig),
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,
+ recallConfig.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,
+ maxMemories: recallConfig.maxMemories,
+ maxTokens: recallConfig.maxPromptRecallTokens,
+ customContainers: recallConfig.autoRecallContainers
+ ? recallConfig.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;
}
if (sessionDir) {
try {
+ const emitted = fresh.slice(0, newFacts.length);
atomicWriteJson(
path.join(sessionDir, 'recalled.json'),
- [...seen, ...fresh.map((r) => hashText(resultText(r)))].slice(-MAX_SEEN_HASHES),
+ [...seen, ...emitted.map((result) => hashText(resultText(result)))].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..186f633 100644
--- a/plugin/hooks/session-start.js
+++ b/plugin/hooks/session-start.js
@@ -1,14 +1,20 @@
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,
getApiKey,
getBaseUrl,
debugLog,
+ getRecallConfig,
} = require('./lib/settings');
const { BRAND, MARK, bold, gray } = require('./lib/colors');
const { readStdin, writeOutput } = require('./lib/stdin');
@@ -125,31 +131,6 @@ function welcomeBackNotice(containerTag) {
}
}
-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({
@@ -177,8 +158,15 @@ async function main() {
const projectConfig = loadProjectConfig(cwd);
const projectName = getProjectName(cwd);
const containerTag = getContainerTag(cwd);
+ const recallConfig = getRecallConfig(cwd);
+ const containerTags = getRecallContainerTags(containerTag, recallConfig);
- debugLog(settings, 'SessionStart', { cwd, projectName, containerTag });
+ debugLog(settings, 'SessionStart', {
+ cwd,
+ projectName,
+ containerTag,
+ containerTags,
+ });
let apiKey;
try {
@@ -205,7 +193,13 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable.
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, recallConfig.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 +207,16 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable.
debugLog(settings, 'Profile fetch failed', { error: err.message });
}
- const context = formatContext(
+ const { text: context, newFacts } = formatSessionContext(
profileResult,
- settings.maxProfileItems,
- containerTag,
- projectName,
+ {
+ maxProfileItems: recallConfig.maxProfileItems,
+ maxTokens: recallConfig.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',
diff --git a/test/unit.mjs b/test/unit.mjs
index c883f95..49eeb05 100644
--- a/test/unit.mjs
+++ b/test/unit.mjs
@@ -31,6 +31,7 @@ const {
getStatusLabel,
renderStatusline,
} = require('../plugin/statusline.js');
+const { formatRecallContext } = require('../plugin/hooks/lib/context.js');
const HOOKS_DIR = join(process.cwd(), 'plugin', 'hooks');
@@ -221,6 +222,102 @@ describe('recall-directive hook', () => {
assert.equal(state.search.memories, 4);
});
+ test('mirrors shared Codex limits and globally ranks automatic containers', async (t) => {
+ const { repo, home } = makeRepo(t);
+ mkdirSync(join(home, '.supermemory-claude'), { recursive: true });
+ mkdirSync(join(home, '.codex'), { recursive: true });
+ writeFileSync(
+ join(home, '.supermemory-claude', 'credentials.json'),
+ JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }),
+ );
+ writeFileSync(
+ join(home, '.codex', 'supermemory.json'),
+ JSON.stringify({
+ maxMemories: 15,
+ maxProfileItems: 15,
+ maxRecallTokens: 5000,
+ 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: 'x'.repeat(4000) }],
+ {
+ containerTag: 'repo_test',
+ maxMemories: 15,
+ maxTokens: 200,
+ customContainers: [],
+ },
+ );
+ assert.ok(text.length <= 800);
+ assert.match(text, /…/);
+ assert.match(text, /<\/supermemory-recall>$/);
+ assert.equal(newFacts.length, 1);
+ });
+
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 });
@@ -399,6 +496,56 @@ describe('session-start hook', () => {
assert.equal(state.context.status, 'ready');
assert.equal(state.context.memoryItemsLoaded, 2);
});
+
+ test('loads profile facts from shared automatic containers', async (t) => {
+ const { repo, home } = makeRepo(t);
+ mkdirSync(join(home, '.supermemory-claude'), { recursive: true });
+ mkdirSync(join(home, '.codex'), { recursive: true });
+ writeFileSync(
+ join(home, '.supermemory-claude', 'credentials.json'),
+ JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }),
+ );
+ writeFileSync(
+ join(home, '.codex', 'supermemory.json'),
+ JSON.stringify({
+ maxMemories: 15,
+ maxProfileItems: 15,
+ maxRecallTokens: 5000,
+ maxPromptRecallTokens: 2000,
+ 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/);
+ });
});
describe('capture hook', () => {
From 4263502b4d5c1a36fb3fe4b4184568b83ad2e270 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 09:13:52 +0300
Subject: [PATCH 2/9] fix(recall): harden mirrored auto-recall
Context:
Claude now mirrors Codex recall limits, automatic containers, and the saved API endpoint when both clients use the same key. Claude-specific settings and explicit environment or project endpoint overrides remain authoritative.
Changes:
- Allowlist the six shared recall options and require a literal boolean for automatic container search.
- Globally rank and deduplicate search results, cap profile sections independently, and budget complete rendered prompt and SessionStart context.
- Track only emitted memories as seen and preserve complete wrappers when the final item is truncated.
- Prefer actionable non-404 failures when all container requests fail.
- Reuse loaded settings and project configuration across recall hooks.
- Mirror Codex's saved API base URL only for an identical active key.
- Document the shared configuration contract and add regression coverage for precedence, caps, deduplication, failure selection, endpoint matching, and context budgets.
Impact:
Claude can use the same expanded recall configuration and self-hosted Supermemory endpoint as Codex without importing unrelated Codex behavior or redirecting a different Claude credential.
Validation:
- `npm test`: 36 tests passed.
- Local marketplace `npm test`: 21 tests passed.
- Node syntax checks passed for all changed hook files.
- `npm run lint` exited successfully.
- `git diff --staged --check` passed.
- Live prompt recall returned 2,525 characters and 15 memories from both configured custom containers.
- Live SessionStart returned 4,491 characters and 30 profile facts from both configured custom containers.
Notes:
Biome reports its existing schema-version informational mismatch; no lint error was reported.
Signed-off-by: Tomas <180413002+Tomauskasz@users.noreply.github.com>
---
README.md | 9 +-
plugin/hooks/capture.js | 2 +-
plugin/hooks/lib/api.js | 5 +-
plugin/hooks/lib/context.js | 90 ++++++-----
plugin/hooks/lib/settings.js | 68 ++++----
plugin/hooks/recall-directive.js | 26 ++--
plugin/hooks/session-start.js | 12 +-
test/unit.mjs | 257 ++++++++++++++++++++++++++++---
8 files changed, 363 insertions(+), 106 deletions(-)
diff --git a/README.md b/README.md
index 59aa69a..c0b8154 100644
--- a/README.md
+++ b/README.md
@@ -85,8 +85,11 @@ SUPERMEMORY_DEBUG=true # Optional: enable debug logging
**Global Settings** — `~/.supermemory-claude/settings.json`
-Claude first loads shared recall settings from `~/.codex/supermemory.json` when
-that file exists. Claude-specific settings override the shared values.
+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
{
@@ -110,7 +113,7 @@ that file exists. Claude-specific settings override the shared values.
| `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; defaults to `maxRecallTokens` |
+| `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 |
diff --git a/plugin/hooks/capture.js b/plugin/hooks/capture.js
index 90edd78..679c5b8 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;
diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js
index 0c5b606..56109e8 100644
--- a/plugin/hooks/lib/api.js
+++ b/plugin/hooks/lib/api.js
@@ -61,7 +61,10 @@ async function getProfiles(baseUrl, apiKey, containerTags, query, options = {})
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
if (profiles.length === 0) {
- throw settled.find((result) => result.status === 'rejected')?.reason;
+ const failures = settled
+ .filter((result) => result.status === 'rejected')
+ .map((result) => result.reason);
+ throw failures.find((failure) => failure?.status !== 404) || failures[0];
}
return profiles;
}
diff --git a/plugin/hooks/lib/context.js b/plugin/hooks/lib/context.js
index 571742c..2df3b03 100644
--- a/plugin/hooks/lib/context.js
+++ b/plugin/hooks/lib/context.js
@@ -39,16 +39,14 @@ function provenance(result) {
};
}
-function normalize(value) {
- return String(value || '')
- .toLowerCase()
- .trim();
+function normalizeText(value) {
+ return singleLine(value).toLowerCase();
}
function dedupe(items, keyFor) {
const seen = new Set();
return items.filter((item) => {
- const key = normalize(keyFor(item));
+ const key = normalizeText(keyFor(item));
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
@@ -66,11 +64,11 @@ function mergeProfileResults(responses, maxMemories) {
responses.flatMap((response) => response?.profile?.static || []),
(fact) => fact,
);
- const staticKeys = new Set(staticFacts.map(normalize));
+ const staticKeys = new Set(staticFacts.map(normalizeText));
const dynamicFacts = dedupe(
responses.flatMap((response) => response?.profile?.dynamic || []),
(fact) => fact,
- ).filter((fact) => !staticKeys.has(normalize(fact)));
+ ).filter((fact) => !staticKeys.has(normalizeText(fact)));
const searchResults = dedupe(
responses
@@ -104,7 +102,7 @@ function getRecallContainerTags(containerTag, config) {
return [
...new Set([
containerTag,
- ...(config.autoRecallContainers
+ ...(config.autoRecallContainers === true
? config.customContainers.map((container) => container.tag.trim())
: []),
]),
@@ -123,19 +121,19 @@ function formatBoundedItems(items, maxTokens, limitName, render) {
let body = '';
const newFacts = [];
for (const item of items) {
- const fullBody = `${body}${item.before}${item.prefix}${item.text}${item.suffix}`;
+ const fullBody = `${body}${item.before}${item.text}`;
if (render(fullBody).length <= maxChars) {
body = fullBody;
- newFacts.push(item.text);
+ if (item.fact) newFacts.push(item.fact);
continue;
}
- const fixedBody = `${body}${item.before}${item.prefix}${item.suffix}`;
+ const fixedBody = `${body}${item.before}`;
const available = maxChars - render(fixedBody).length;
if (available > 1) {
- const emitted = `${item.text.slice(0, available - 1)}…`;
- body = `${body}${item.before}${item.prefix}${emitted}${item.suffix}`;
- newFacts.push(emitted);
+ const emitted = `${(item.truncateText || item.text).slice(0, available - 1)}…`;
+ body = `${fixedBody}${emitted}`;
+ if (item.fact) newFacts.push(item.fact);
}
break;
}
@@ -144,31 +142,42 @@ function formatBoundedItems(items, maxTokens, limitName, render) {
function formatRecallContext(results, options) {
const customContainers = options.customContainers || [];
- const catalog = customContainers.length
- ? `\n\nConfigured automatic recall containers:\n${customContainers
- .map(
- (container) =>
- `- ${singleLine(container.tag)}: ${singleLine(container.description)}`,
- )
- .join('\n')}`
- : '';
const render = (body) => `
◪ Recalled from supermemory for this prompt (relevance-ranked):
-${body}${catalog}
+${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 (containerTag: "${options.containerTag}") or launch the context-gatherer agent.
+When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool or launch the context-gatherer agent.
`;
- const items = results.slice(0, Math.max(0, options.maxMemories)).map((result, index) => {
+ const items = results.map((result, index) => {
const memory = singleLine(result.memory);
const title = singleLine(result.title);
const filepath = singleLine(result.filepath);
return {
before: index === 0 ? '' : '\n',
- prefix: `- ◪ ${title && !memory.startsWith(title) ? `${title} — ` : ''}`,
- text: memory,
- suffix: filepath ? ` (${filepath})` : '',
+ fact: memory,
+ text: `- ◪ ${title && !memory.startsWith(title) ? `${title} — ` : ''}${memory}${filepath ? ` (${filepath})` : ''}`,
+ truncateText: `- ◪ ${memory}`,
};
});
+ 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,
@@ -179,8 +188,8 @@ When one of these shapes your answer, credit it naturally with the ◪ prefix (e
function formatSessionContext(result, options) {
const take = (facts) =>
- dedupe(facts, (fact) => fact)
- .map((fact) => String(fact).trim())
+ facts
+ .map((fact) => singleLine(fact))
.filter(Boolean)
.slice(0, Math.max(0, options.maxProfileItems));
const facts = [
@@ -188,17 +197,27 @@ function formatSessionContext(result, options) {
...take(result?.profile?.dynamic || []),
];
const render = (body) => `
-Recalled memory for this project (${options.projectName}). 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".
-This project's memory container: ${options.containerTag}
+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) => ({
before: index === 0 ? '[Memory Profile]\n' : '\n',
- prefix: `${index + 1}. ◪ `,
- text: fact,
- suffix: '',
+ fact,
+ text: `${index + 1}. ◪ ${fact}`,
}));
+ 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);
}
@@ -207,5 +226,6 @@ module.exports = {
formatSessionContext,
getRecallContainerTags,
mergeProfileResults,
+ normalizeText,
resultText,
};
diff --git a/plugin/hooks/lib/settings.js b/plugin/hooks/lib/settings.js
index 3c6e7df..000044e 100644
--- a/plugin/hooks/lib/settings.js
+++ b/plugin/hooks/lib/settings.js
@@ -12,13 +12,27 @@ const SHARED_SETTINGS_FILE = path.join(
'.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: null,
+ maxPromptRecallTokens: 500,
autoRecallContainers: false,
customContainers: [],
debug: false,
@@ -47,18 +61,27 @@ const DEFAULT_SETTINGS = {
signalTurnsBefore: 3,
};
+function readSettings(file) {
+ try {
+ return fs.existsSync(file)
+ ? JSON.parse(fs.readFileSync(file, 'utf-8'))
+ : {};
+ } catch (err) {
+ console.error(`Settings: Failed to load ${file}: ${err.message}`);
+ return {};
+ }
+}
+
function loadSettings() {
+ const shared = readSettings(SHARED_SETTINGS_FILE);
const settings = { ...DEFAULT_SETTINGS };
- for (const file of [SHARED_SETTINGS_FILE, SETTINGS_FILE]) {
- try {
- if (fs.existsSync(file)) {
- Object.assign(settings, JSON.parse(fs.readFileSync(file, 'utf-8')));
- }
- } catch (err) {
- console.error(`Settings: Failed to load ${file}: ${err.message}`);
+ for (const key of SHARED_RECALL_KEYS) {
+ if (Object.hasOwn(shared, key)) {
+ settings[key] = shared[key];
}
}
- settings.maxPromptRecallTokens ??= settings.maxRecallTokens;
+ Object.assign(settings, readSettings(SETTINGS_FILE));
+ settings.autoRecallContainers = settings.autoRecallContainers === true;
settings.customContainers = Array.isArray(settings.customContainers)
? settings.customContainers.filter(
(container) =>
@@ -99,10 +122,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
+ ? 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');
@@ -162,20 +193,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,
- maxMemories: settings.maxMemories,
- maxProfileItems: settings.maxProfileItems,
- maxRecallTokens: settings.maxRecallTokens,
- maxPromptRecallTokens: settings.maxPromptRecallTokens,
- autoRecallContainers: settings.autoRecallContainers,
- customContainers: settings.customContainers,
- };
-}
-
module.exports = {
SETTINGS_DIR,
SETTINGS_FILE,
@@ -188,5 +205,4 @@ module.exports = {
getIncludeTools,
shouldIncludeTool,
getSignalConfig,
- getRecallConfig,
};
diff --git a/plugin/hooks/recall-directive.js b/plugin/hooks/recall-directive.js
index 7342e5d..d24ac01 100644
--- a/plugin/hooks/recall-directive.js
+++ b/plugin/hooks/recall-directive.js
@@ -9,6 +9,7 @@ const {
formatRecallContext,
getRecallContainerTags,
mergeProfileResults,
+ normalizeText,
resultText,
} = require('./lib/context');
const { getUserFriendlyError } = require('./lib/error-helpers');
@@ -18,7 +19,6 @@ const {
getApiKey,
getBaseUrl,
debugLog,
- getRecallConfig,
} = require('./lib/settings');
const {
atomicWriteJson,
@@ -49,7 +49,7 @@ function shouldSkip(prompt) {
function hashText(text) {
return crypto
.createHash('sha256')
- .update(text.replace(/\s+/g, ' ').trim())
+ .update(normalizeText(text))
.digest('hex')
.slice(0, 16);
}
@@ -74,13 +74,15 @@ async function main() {
const input = await readStdin();
const cwd = input.cwd || process.cwd();
const prompt = (input.prompt || '').trim();
- const recallConfig = getRecallConfig(cwd);
+ const projectConfig = loadProjectConfig(cwd);
+ const directive =
+ projectConfig?.recallDirective || settings.recallDirective || null;
- if (recallConfig.directive) {
+ if (directive) {
writeOutput({
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
- additionalContext: recallConfig.directive,
+ additionalContext: directive,
},
});
return;
@@ -91,7 +93,6 @@ async function main() {
return;
}
- const projectConfig = loadProjectConfig(cwd);
let apiKey;
try {
apiKey = getApiKey(cwd, projectConfig);
@@ -101,9 +102,9 @@ async function main() {
}
const containerTag = getContainerTag(cwd);
- const containerTags = getRecallContainerTags(containerTag, recallConfig);
+ const containerTags = getRecallContainerTags(containerTag, settings);
const responses = await getProfiles(
- getBaseUrl(cwd, projectConfig),
+ getBaseUrl(cwd, projectConfig, apiKey),
apiKey,
containerTags,
prompt.slice(0, MAX_QUERY_LENGTH),
@@ -111,7 +112,7 @@ async function main() {
);
const results = mergeProfileResults(
responses,
- recallConfig.maxMemories,
+ settings.maxMemories,
).searchResults.results;
const sessionDir = getSessionDir(input.session_id);
@@ -121,10 +122,9 @@ async function main() {
const repeats = results.length - fresh.length;
const { text: context, newFacts } = formatRecallContext(fresh, {
containerTag,
- maxMemories: recallConfig.maxMemories,
- maxTokens: recallConfig.maxPromptRecallTokens,
- customContainers: recallConfig.autoRecallContainers
- ? recallConfig.customContainers
+ maxTokens: settings.maxPromptRecallTokens,
+ customContainers: settings.autoRecallContainers
+ ? settings.customContainers
: [],
});
diff --git a/plugin/hooks/session-start.js b/plugin/hooks/session-start.js
index 186f633..750b218 100644
--- a/plugin/hooks/session-start.js
+++ b/plugin/hooks/session-start.js
@@ -14,7 +14,6 @@ const {
getApiKey,
getBaseUrl,
debugLog,
- getRecallConfig,
} = require('./lib/settings');
const { BRAND, MARK, bold, gray } = require('./lib/colors');
const { readStdin, writeOutput } = require('./lib/stdin');
@@ -158,8 +157,7 @@ async function main() {
const projectConfig = loadProjectConfig(cwd);
const projectName = getProjectName(cwd);
const containerTag = getContainerTag(cwd);
- const recallConfig = getRecallConfig(cwd);
- const containerTags = getRecallContainerTags(containerTag, recallConfig);
+ const containerTags = getRecallContainerTags(containerTag, settings);
debugLog(settings, 'SessionStart', {
cwd,
@@ -188,7 +186,7 @@ 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;
@@ -199,7 +197,7 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable.
containerTags,
undefined,
);
- profileResult = mergeProfileResults(responses, recallConfig.maxMemories);
+ 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.
@@ -210,8 +208,8 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable.
const { text: context, newFacts } = formatSessionContext(
profileResult,
{
- maxProfileItems: recallConfig.maxProfileItems,
- maxTokens: recallConfig.maxRecallTokens,
+ maxProfileItems: settings.maxProfileItems,
+ maxTokens: settings.maxRecallTokens,
containerTag,
projectName,
},
diff --git a/test/unit.mjs b/test/unit.mjs
index 49eeb05..9eb3532 100644
--- a/test/unit.mjs
+++ b/test/unit.mjs
@@ -31,7 +31,13 @@ const {
getStatusLabel,
renderStatusline,
} = require('../plugin/statusline.js');
-const { formatRecallContext } = require('../plugin/hooks/lib/context.js');
+const {
+ formatRecallContext,
+ formatSessionContext,
+ getRecallContainerTags,
+ mergeProfileResults,
+} = require('../plugin/hooks/lib/context.js');
+const { getProfiles } = require('../plugin/hooks/lib/api.js');
const HOOKS_DIR = join(process.cwd(), 'plugin', 'hooks');
@@ -140,6 +146,148 @@ function makeAuthedHome(t, apiKey = 'sm_test_key_0123456789abcdef') {
return home;
}
+function readSettings(home, apiKey = 'sm_shared') {
+ 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(), null, ${JSON.stringify(apiKey)}),
+ }));
+ `;
+ const result = spawnSync('node', ['-e', script], {
+ encoding: 'utf-8',
+ env: { ...process.env, HOME: home, USERPROFILE: home },
+ });
+ assert.equal(result.status, 0, result.stderr);
+ return JSON.parse(result.stdout);
+}
+
+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('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\nsettings loader', similarity: 0.9 }] } },
+ { searchResults: { results: [{ memory: 'Use the shared settings loader', similarity: 0.8 }] } },
+ ],
+ 15,
+ );
+ assert.equal(merged.searchResults.results.length, 1);
+ });
+
+ 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('container tags', () => {
test('derives one canonical repo tag from the git remote', (t) => {
const { repo, home } = makeRepo(t);
@@ -223,19 +371,13 @@ describe('recall-directive hook', () => {
});
test('mirrors shared Codex limits and globally ranks automatic containers', async (t) => {
- const { repo, home } = makeRepo(t);
- mkdirSync(join(home, '.supermemory-claude'), { recursive: true });
+ const { repo } = makeRepo(t);
+ const home = makeAuthedHome(t);
mkdirSync(join(home, '.codex'), { recursive: true });
- writeFileSync(
- join(home, '.supermemory-claude', 'credentials.json'),
- JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }),
- );
writeFileSync(
join(home, '.codex', 'supermemory.json'),
JSON.stringify({
maxMemories: 15,
- maxProfileItems: 15,
- maxRecallTokens: 5000,
maxPromptRecallTokens: 2000,
autoRecallContainers: true,
customContainers: [
@@ -304,20 +446,101 @@ describe('recall-directive hook', () => {
test('preserves complete recall wrappers at the token budget', () => {
const { text, newFacts } = formatRecallContext(
- [{ memory: 'x'.repeat(4000) }],
+ [{
+ memory: 'short memory',
+ title: 't'.repeat(4000),
+ filepath: 'p'.repeat(4000),
+ }],
{
containerTag: 'repo_test',
- maxMemories: 15,
maxTokens: 200,
- customContainers: [],
+ 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('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('marks only memories emitted within the prompt budget as seen', 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 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 second = JSON.parse((await runHook('recall-directive.js', input, env)).stdout);
+ assert.match(second.hookSpecificOutput.additionalContext, /B:BBB/);
+ assert.doesNotMatch(second.hookSpecificOutput.additionalContext, /A:AAA/);
+ });
+
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 });
@@ -498,20 +721,14 @@ describe('session-start hook', () => {
});
test('loads profile facts from shared automatic containers', async (t) => {
- const { repo, home } = makeRepo(t);
- mkdirSync(join(home, '.supermemory-claude'), { recursive: true });
+ const { repo } = makeRepo(t);
+ const home = makeAuthedHome(t);
mkdirSync(join(home, '.codex'), { recursive: true });
- writeFileSync(
- join(home, '.supermemory-claude', 'credentials.json'),
- JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }),
- );
writeFileSync(
join(home, '.codex', 'supermemory.json'),
JSON.stringify({
- maxMemories: 15,
maxProfileItems: 15,
maxRecallTokens: 5000,
- maxPromptRecallTokens: 2000,
autoRecallContainers: true,
customContainers: [
{ tag: 'coding_personal', description: 'Personal coding decisions.' },
From 07af87ac4301066228e3f331a966af2f03468af3 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:29:01 +0300
Subject: [PATCH 3/9] fix(recall): harden shared config
Context:
Shared Codex configuration and credentials now fail open at the optional-file boundary without exposing parser input, and bounded recall records only memory text that reached the emitted context.
Changes:
- Validate shared JSON as a non-array object and redact parse diagnostics.
- Ignore invalid mirrored endpoints, preserve explicit endpoint precedence, and normalize Codex-valid custom containers with empty descriptions.
- Track truncated facts only after fact content is emitted.
- Route the status command through the runtime credential and endpoint resolvers and remove the inspector's stale directive projection.
- Add credential, endpoint, capture, status, deduplication, and minimum-budget coverage, split across focused test files with shared fixtures.
Impact:
Malformed optional Codex files no longer disable Claude recall or capture. Matching credentials reuse only valid mirrored endpoints. Status probes the same endpoint as recall and writes. Prefix-only context cannot suppress an un-emitted memory.
Validation:
- `npm test`: 44 tests passed.
- `npx biome ci .`: passed.
- Node syntax checks passed for changed JavaScript and test modules.
- Bun bundled `plugin-inspector.ts` successfully.
- `git diff --staged --check`: passed.
Notes:
None.
---
package.json | 2 +-
plugin-inspector.ts | 7 +-
plugin/commands/status.md | 15 +-
plugin/hooks/lib/context.js | 25 +-
plugin/hooks/lib/settings.js | 33 +-
plugin/hooks/status-check.js | 49 ++
test/helpers.mjs | 96 ++++
test/recall.mjs | 912 +++++++++++++++++++++++++++++++++++
test/unit.mjs | 732 +---------------------------
9 files changed, 1110 insertions(+), 761 deletions(-)
create mode 100644 plugin/hooks/status-check.js
create mode 100644 test/helpers.mjs
create mode 100644 test/recall.mjs
diff --git a/package.json b/package.json
index 8a88868..631ad3e 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",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
diff --git a/plugin-inspector.ts b/plugin-inspector.ts
index ecbdf53..0bf3000 100644
--- a/plugin-inspector.ts
+++ b/plugin-inspector.ts
@@ -62,8 +62,6 @@ async function inspect() {
}),
);
- 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]);
@@ -82,7 +80,6 @@ async function inspect() {
hooks,
commands,
agents,
- directive,
readOnlyTools,
files: listFiles(PLUGIN),
};
@@ -123,7 +120,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 +128,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 +149,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);
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/lib/context.js b/plugin/hooks/lib/context.js
index 2df3b03..9c8e0ef 100644
--- a/plugin/hooks/lib/context.js
+++ b/plugin/hooks/lib/context.js
@@ -131,9 +131,12 @@ function formatBoundedItems(items, maxTokens, limitName, render) {
const fixedBody = `${body}${item.before}`;
const available = maxChars - render(fixedBody).length;
if (available > 1) {
- const emitted = `${(item.truncateText || item.text).slice(0, available - 1)}…`;
+ const truncated = (item.truncateText || item.text).slice(0, available - 1);
+ const emitted = `${truncated}…`;
body = `${fixedBody}${emitted}`;
- if (item.fact) newFacts.push(item.fact);
+ if (item.fact && truncated.length > (item.factOffset || 0)) {
+ newFacts.push(item.fact);
+ }
}
break;
}
@@ -152,11 +155,13 @@ When one of these shapes your answer, credit it naturally with the ◪ prefix (e
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: `- ◪ ${memory}`,
+ truncateText: `${factPrefix}${memory}`,
+ factOffset: factPrefix.length,
};
});
items.push({
@@ -201,11 +206,15 @@ Recalled memory for this project. Every line marked ◪ comes from supermemory
${body}
`;
- const items = facts.map((fact, index) => ({
- before: index === 0 ? '[Memory Profile]\n' : '\n',
- fact,
- text: `${index + 1}. ◪ ${fact}`,
- }));
+ 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',
diff --git a/plugin/hooks/lib/settings.js b/plugin/hooks/lib/settings.js
index 000044e..87034ed 100644
--- a/plugin/hooks/lib/settings.js
+++ b/plugin/hooks/lib/settings.js
@@ -63,11 +63,13 @@ const DEFAULT_SETTINGS = {
function readSettings(file) {
try {
- return fs.existsSync(file)
- ? JSON.parse(fs.readFileSync(file, 'utf-8'))
+ if (!fs.existsSync(file)) return {};
+ const value = JSON.parse(fs.readFileSync(file, 'utf-8'));
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value
: {};
- } catch (err) {
- console.error(`Settings: Failed to load ${file}: ${err.message}`);
+ } catch {
+ console.error(`Settings: Failed to load ${file}`);
return {};
}
}
@@ -83,14 +85,18 @@ function loadSettings() {
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' &&
- container.description.trim(),
- )
+ ? 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;
@@ -127,7 +133,7 @@ function getBaseUrl(cwd, projectConfig, apiKey) {
const sharedCredentials = readSettings(SHARED_CREDENTIALS_FILE);
const sharedBaseUrl =
apiKey && sharedCredentials.apiKey === apiKey
- ? sharedCredentials.apiBaseUrl
+ ? normalizeBaseUrl(sharedCredentials.apiBaseUrl)
: null;
const configured =
process.env.SUPERMEMORY_API_URL ||
@@ -196,7 +202,6 @@ function getSignalConfig(cwd) {
module.exports = {
SETTINGS_DIR,
SETTINGS_FILE,
- SHARED_SETTINGS_FILE,
DEFAULT_SETTINGS,
loadSettings,
getApiKey,
diff --git a/plugin/hooks/status-check.js b/plugin/hooks/status-check.js
new file mode 100644
index 0000000..55871b0
--- /dev/null
+++ b/plugin/hooks/status-check.js
@@ -0,0 +1,49 @@
+const { getContainerTag } = require('./lib/container-tag');
+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';
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 8000);
+
+ try {
+ const response = await fetch(`${baseUrl}/v4/profile`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ 'x-sm-source': 'claude-code',
+ },
+ body: JSON.stringify({ containerTag, q: 'connectivity probe' }),
+ signal: controller.signal,
+ });
+ console.log(JSON.stringify({
+ authenticated: response.status !== 401 && response.status !== 403,
+ keySource,
+ baseUrl,
+ containerTag,
+ httpStatus: response.status,
+ }));
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+main().catch((error) => {
+ console.error(
+ error.name === 'AbortError'
+ ? 'API probe timed out'
+ : error.cause?.message || error.message,
+ );
+ process.exit(1);
+});
diff --git a/test/helpers.mjs b/test/helpers.mjs
new file mode 100644
index 0000000..a5dfb28
--- /dev/null
+++ b/test/helpers.mjs
@@ -0,0 +1,96 @@
+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 { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+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'
+ ? 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..96713ca
--- /dev/null
+++ b/test/recall.mjs
@@ -0,0 +1,912 @@
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+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 { 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');
+ }
+
+ 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('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, 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({
+ 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('marks only memories emitted within the prompt budget as seen', 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 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 second = JSON.parse((await runHook('recall-directive.js', input, env)).stdout);
+ assert.match(second.hookSpecificOutput.additionalContext, /B:BBB/);
+ assert.doesNotMatch(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, 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 },
+ );
+ assert.equal(JSON.parse(stdout).hookSpecificOutput.additionalContext, 'CUSTOM DIRECTIVE');
+ });
+});
+
+describe('status check', () => {
+ test('probes the same-key mirrored endpoint without printing the key', async (t) => {
+ const { repo } = makeRepo(t);
+ const apiKey = 'sm_status_secret_0123456789';
+ const home = makeAuthedHome(t, apiKey);
+ const stub = await startStubServer(t, (record, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ profile: { static: [], dynamic: [] } }));
+ });
+ 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 }));
+ });
+
+ assert.equal(result.code, 0, result.stderr);
+ assert.doesNotMatch(result.stdout, new RegExp(apiKey));
+ const output = JSON.parse(result.stdout);
+ 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}`);
+ });
+});
+
+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);
+ });
+
+ 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/);
+ });
+});
+
+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, 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);
+ });
+});
+
diff --git a/test/unit.mjs b/test/unit.mjs
index 9eb3532..b66ff58 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 { 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,52 +37,6 @@ const {
getStatusLabel,
renderStatusline,
} = require('../plugin/statusline.js');
-const {
- formatRecallContext,
- formatSessionContext,
- getRecallContainerTags,
- mergeProfileResults,
-} = require('../plugin/hooks/lib/context.js');
-const { getProfiles } = require('../plugin/hooks/lib/api.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 = `
@@ -95,199 +55,6 @@ 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;
-}
-
-function readSettings(home, apiKey = 'sm_shared') {
- 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(), null, ${JSON.stringify(apiKey)}),
- }));
- `;
- const result = spawnSync('node', ['-e', script], {
- encoding: 'utf-8',
- env: { ...process.env, HOME: home, USERPROFILE: home },
- });
- assert.equal(result.status, 0, result.stderr);
- return JSON.parse(result.stdout);
-}
-
-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('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\nsettings loader', similarity: 0.9 }] } },
- { searchResults: { results: [{ memory: 'Use the shared settings loader', similarity: 0.8 }] } },
- ],
- 15,
- );
- assert.equal(merged.searchResults.results.length, 1);
- });
-
- 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('container tags', () => {
test('derives one canonical repo tag from the git remote', (t) => {
const { repo, home } = makeRepo(t);
@@ -314,308 +81,6 @@ describe('container tags', () => {
});
});
-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 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('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('marks only memories emitted within the prompt budget as seen', 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 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 second = JSON.parse((await runHook('recall-directive.js', input, env)).stdout);
- assert.match(second.hookSpecificOutput.additionalContext, /B:BBB/);
- assert.doesNotMatch(second.hookSpecificOutput.additionalContext, /A:AAA/);
- });
-
- 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 },
- );
- assert.equal(JSON.parse(stdout).hookSpecificOutput.additionalContext, 'CUSTOM DIRECTIVE');
- });
-});
-
describe('stdin handling', () => {
test('hooks finish even when stdin never emits end (issue #25)', async (t) => {
const home = makeTempDir(t, 'stdin-home');
@@ -683,183 +148,6 @@ 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);
- });
-
- 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/);
- });
-});
-
-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) {
return new Promise((resolve, reject) => {
From 5a02c930427b4e25e9ae7b445761f674505f1254 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:26:21 +0300
Subject: [PATCH 4/9] fix(recall): close residual review gaps
Context:
Prompt recall treated truncated memories as fully seen, accepted finite negative relevance scores, and let the status command use a divergent request path and binary authentication result. The Biome gate also excluded the repository paths changed by the pull request.
Changes:
- Persist the exact emitted recall fragment and reject finite scores below the relevance threshold.
- Reuse the shared profile transport in the status probe and report indeterminate HTTP failures with authenticated set to null.
- Split status, SessionStart, and capture tests into owner-specific modules and add regressions for truncation, score filtering, trailing-slash routing, and status classification.
- Point Biome at the plugin, tests, inspector, and package manifest, then apply its formatting and import organization to the collected files.
Impact:
Partially emitted memories remain eligible until their complete text fits. Automatic recall excludes negatively scored results. Status requests now match runtime URL handling and distinguish rejected credentials from unavailable or rate-limited APIs. CI now checks 34 repository files instead of skipping the changed implementation.
Validation:
- npm ci completed with zero vulnerabilities.
- npm test passed 46 tests with zero failures.
- npx biome ci . passed across 34 files.
- Node syntax checks passed for every changed JavaScript and MJS entrypoint.
- Bun bundled plugin-inspector.ts successfully.
- git diff --cached --check passed.
Notes:
GitHub Actions for first-time fork commits still requires upstream workflow approval; local commands match the CI workflow.
Signed-off-by: Tomas <180413002+Tomauskasz@users.noreply.github.com>
---
biome.json | 2 +-
package.json | 2 +-
plugin-inspector.ts | 77 ++--
plugin/.claude-plugin/plugin.json | 2 +-
plugin/hooks/capture.js | 4 +-
plugin/hooks/lib/api.js | 33 +-
plugin/hooks/lib/auth.js | 3 +-
plugin/hooks/lib/context.js | 48 ++-
plugin/hooks/lib/statusline-state.js | 4 +-
plugin/hooks/lib/stdin.js | 5 +-
plugin/hooks/mcp-proxy.js | 6 +-
plugin/hooks/recall-approve.js | 3 +-
plugin/hooks/recall-directive.js | 17 +-
plugin/hooks/session-start.js | 33 +-
plugin/hooks/status-check.js | 41 +-
plugin/statusline.js | 11 +-
test/capture.mjs | 174 ++++++++
test/helpers.mjs | 22 +-
test/recall.mjs | 586 ++++++++++++---------------
test/session-start.mjs | 103 +++++
test/status.mjs | 90 ++++
test/unit.mjs | 146 +++++--
22 files changed, 939 insertions(+), 473 deletions(-)
create mode 100644 test/capture.mjs
create mode 100644 test/session-start.mjs
create mode 100644 test/status.mjs
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 631ad3e..7d5f31a 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"private": true,
"type": "commonjs",
"scripts": {
- "test": "node --test test/unit.mjs test/recall.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 0bf3000..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,29 +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 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 {}
@@ -165,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/hooks/capture.js b/plugin/hooks/capture.js
index 679c5b8..da8ec0f 100644
--- a/plugin/hooks/capture.js
+++ b/plugin/hooks/capture.js
@@ -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 56109e8..8970213 100644
--- a/plugin/hooks/lib/api.js
+++ b/plugin/hooks/lib/api.js
@@ -25,7 +25,13 @@ 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,
+) {
const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${path}`, {
method: 'POST',
headers: {
@@ -48,10 +54,22 @@ async function post(baseUrl, apiKey, path, body, timeoutMs = REQUEST_TIMEOUT_MS)
}
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,
+ );
}
-async function getProfiles(baseUrl, apiKey, containerTags, query, options = {}) {
+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),
@@ -69,7 +87,14 @@ async function getProfiles(baseUrl, apiKey, containerTags, query, options = {})
return profiles;
}
-function addMemory(baseUrl, apiKey, content, containerTag, metadata, options = {}) {
+function addMemory(
+ baseUrl,
+ apiKey,
+ content,
+ containerTag,
+ metadata,
+ options = {},
+) {
const body = {
content,
containerTag,
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
index 9c8e0ef..6962c2d 100644
--- a/plugin/hooks/lib/context.js
+++ b/plugin/hooks/lib/context.js
@@ -8,24 +8,30 @@ function singleLine(value) {
}
function resultText(result) {
- return [
- result?.memory,
- result?.chunk,
- result?.content,
- result?.text,
- result?.context,
- ].find((value) => typeof value === 'string' && value.trim())?.trim() || '';
+ 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();
+ 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 : {};
+ result?.metadata && typeof result.metadata === 'object'
+ ? result.metadata
+ : {};
return {
title: stringValue(result?.title, metadata.title),
filepath: stringValue(
@@ -56,7 +62,7 @@ function dedupe(items, keyFor) {
function score(result) {
if (Number.isFinite(result.similarity)) return result.similarity;
if (Number.isFinite(result.score)) return result.score;
- return -1;
+ return null;
}
function mergeProfileResults(responses, maxMemories) {
@@ -76,10 +82,10 @@ function mergeProfileResults(responses, maxMemories) {
.filter((result) => resultText(result))
.filter((result) => {
const relevance = score(result);
- return relevance < 0 || relevance >= RECALL_MIN_SIMILARITY;
+ return relevance === null || relevance >= RECALL_MIN_SIMILARITY;
})
.sort((a, b) => {
- const relevance = score(b) - score(a);
+ const relevance = (score(b) ?? -1) - (score(a) ?? -1);
if (relevance !== 0) return relevance;
return Date.parse(b.updatedAt || 0) - Date.parse(a.updatedAt || 0);
}),
@@ -131,11 +137,14 @@ function formatBoundedItems(items, maxTokens, limitName, render) {
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 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(item.fact);
+ newFacts.push(`${truncated.slice(item.factOffset || 0)}…`);
}
}
break;
@@ -227,7 +236,12 @@ ${body}
text: `Memory container: ${singleLine(options.containerTag)}`,
},
);
- return formatBoundedItems(items, options.maxTokens, 'maxRecallTokens', render);
+ return formatBoundedItems(
+ items,
+ options.maxTokens,
+ 'maxRecallTokens',
+ render,
+ );
}
module.exports = {
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 d24ac01..ac365b4 100644
--- a/plugin/hooks/recall-directive.js
+++ b/plugin/hooks/recall-directive.js
@@ -59,9 +59,7 @@ 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 [];
}
@@ -110,15 +108,15 @@ async function main() {
prompt.slice(0, MAX_QUERY_LENGTH),
{ timeoutMs: SEARCH_TIMEOUT_MS },
);
- const results = mergeProfileResults(
- responses,
- settings.maxMemories,
- ).searchResults.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((result) => !seenSet.has(hashText(resultText(result))));
+ const fresh = results.filter(
+ (result) => !seenSet.has(hashText(resultText(result))),
+ );
const repeats = results.length - fresh.length;
const { text: context, newFacts } = formatRecallContext(fresh, {
containerTag,
@@ -151,10 +149,9 @@ async function main() {
if (sessionDir) {
try {
- const emitted = fresh.slice(0, newFacts.length);
atomicWriteJson(
path.join(sessionDir, 'recalled.json'),
- [...seen, ...emitted.map((result) => hashText(resultText(result)))].slice(
+ [...new Set([...seen, ...newFacts.map(hashText)])].slice(
-MAX_SEEN_HASHES,
),
);
diff --git a/plugin/hooks/session-start.js b/plugin/hooks/session-start.js
index 750b218..d6d0c05 100644
--- a/plugin/hooks/session-start.js
+++ b/plugin/hooks/session-start.js
@@ -123,7 +123,9 @@ 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;
@@ -152,7 +154,10 @@ 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);
@@ -173,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.'}
@@ -205,15 +213,12 @@ Or set the SUPERMEMORY_CC_API_KEY environment variable.
debugLog(settings, 'Profile fetch failed', { error: err.message });
}
- const { text: context, newFacts } = formatSessionContext(
- profileResult,
- {
- maxProfileItems: settings.maxProfileItems,
- maxTokens: settings.maxRecallTokens,
- containerTag,
- projectName,
- },
- );
+ const { text: context, newFacts } = formatSessionContext(profileResult, {
+ maxProfileItems: settings.maxProfileItems,
+ maxTokens: settings.maxRecallTokens,
+ containerTag,
+ projectName,
+ });
const loaded = newFacts.length;
writeState(sessionId, 'context', {
@@ -227,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
index 55871b0..eb4ee95 100644
--- a/plugin/hooks/status-check.js
+++ b/plugin/hooks/status-check.js
@@ -1,4 +1,5 @@
const { getContainerTag } = require('./lib/container-tag');
+const { getProfile } = require('./lib/api');
const { loadProjectConfig } = require('./lib/project-config');
const { getApiKey, getBaseUrl } = require('./lib/settings');
@@ -13,35 +14,35 @@ async function main() {
: projectConfig?.apiKey
? 'project config'
: '~/.supermemory-claude/credentials.json';
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 8000);
-
+ let httpStatus;
try {
- const response = await fetch(`${baseUrl}/v4/profile`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json',
- 'x-sm-source': 'claude-code',
- },
- body: JSON.stringify({ containerTag, q: 'connectivity probe' }),
- signal: controller.signal,
+ await getProfile(baseUrl, apiKey, containerTag, 'connectivity probe', {
+ timeoutMs: 8000,
});
- console.log(JSON.stringify({
- authenticated: response.status !== 401 && response.status !== 403,
+ 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: response.status,
- }));
- } finally {
- clearTimeout(timeout);
- }
+ httpStatus,
+ }),
+ );
}
main().catch((error) => {
console.error(
- error.name === 'AbortError'
+ error.name === 'AbortError' || error.name === 'TimeoutError'
? 'API probe timed out'
: error.cause?.message || error.message,
);
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..c9aebbe
--- /dev/null
+++ b/test/capture.mjs
@@ -0,0 +1,174 @@
+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, 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');
+
+ await runHook('capture.js', input, env);
+ assert.equal(stub.requests.length, 2);
+ });
+});
diff --git a/test/helpers.mjs b/test/helpers.mjs
index a5dfb28..e055d87 100644
--- a/test/helpers.mjs
+++ b/test/helpers.mjs
@@ -3,8 +3,8 @@ 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 { join } from 'node:path';
import { tmpdir } from 'node:os';
+import { join } from 'node:path';
export const HOOKS_DIR = join(process.cwd(), 'plugin', 'hooks');
@@ -14,12 +14,16 @@ export function hash16(input) {
export function plain(value) {
return typeof value === 'string'
- ? value.replace(/\x1b(\[[0-9;]*m|\]8;;[^\x07]*\x07)/g, '')
+ ? // 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()}`);
+ 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;
@@ -53,8 +57,12 @@ export function runHook(name, input, env = {}) {
});
let stdout = '';
let stderr = '';
- child.stdout.on('data', (chunk) => { stdout += chunk; });
- child.stderr.on('data', (chunk) => { stderr += chunk; });
+ 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));
@@ -66,7 +74,9 @@ export function startStubServer(t, handler) {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
- req.on('data', (chunk) => { body += chunk; });
+ req.on('data', (chunk) => {
+ body += chunk;
+ });
req.on('end', () => {
const record = {
method: req.method,
diff --git a/test/recall.mjs b/test/recall.mjs
index 96713ca..a79d541 100644
--- a/test/recall.mjs
+++ b/test/recall.mjs
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict';
-import { spawn, spawnSync } from 'node:child_process';
+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 { createRequire } from 'node:module';
import {
HOOKS_DIR,
hash16,
@@ -16,7 +16,10 @@ import {
} from './helpers.mjs';
const require = createRequire(import.meta.url);
-const { getSessionDir, readState } = require('../plugin/hooks/lib/statusline-state.js');
+const {
+ getSessionDir,
+ readState,
+} = require('../plugin/hooks/lib/statusline-state.js');
const {
formatRecallContext,
formatSessionContext,
@@ -25,7 +28,10 @@ const {
} = require('../plugin/hooks/lib/context.js');
const { getProfiles } = require('../plugin/hooks/lib/api.js');
-function runSettings(home, { apiKey = 'sm_shared', projectConfig = null, apiUrl = '' } = {}) {
+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)});
@@ -74,7 +80,9 @@ describe('recall settings and merging', () => {
maxRecallTokens: 5000,
maxPromptRecallTokens: 2000,
autoRecallContainers: true,
- customContainers: [{ tag: 'coding_personal', description: 'Personal.' }],
+ customContainers: [
+ { tag: 'coding_personal', description: 'Personal.' },
+ ],
debug: true,
includeTools: ['Bash'],
recallDirective: 'Codex-only directive',
@@ -105,7 +113,10 @@ describe('recall settings and merging', () => {
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');
+ assert.equal(
+ readSettings(home, 'sm_other').baseUrl,
+ 'https://api.supermemory.ai',
+ );
});
test('tolerates non-object shared JSON and redacts malformed credentials', (t) => {
@@ -114,7 +125,10 @@ describe('recall settings and merging', () => {
mkdirSync(sharedDir, { recursive: true });
for (const value of [null, [], 'unrelated', 7]) {
- writeFileSync(join(home, '.codex', 'supermemory.json'), JSON.stringify(value));
+ 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);
@@ -137,11 +151,15 @@ describe('recall settings and merging', () => {
mkdirSync(sharedDir, { recursive: true });
writeFileSync(
join(sharedDir, 'credentials.json'),
- JSON.stringify({ apiKey: 'sm_shared', apiBaseUrl: 'http://127.0.0.1:6767' }),
+ 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,
+ readSettings(home, 'sm_shared', { apiUrl: 'http://127.0.0.1:7001' })
+ .baseUrl,
'http://127.0.0.1:7001',
);
assert.equal(
@@ -204,7 +222,9 @@ describe('recall settings and merging', () => {
});
test('requires a literal boolean to search custom containers', () => {
- const customContainers = [{ tag: 'coding_personal', description: 'Personal.' }];
+ const customContainers = [
+ { tag: 'coding_personal', description: 'Personal.' },
+ ];
assert.deepEqual(
getRecallContainerTags('repo_test', {
autoRecallContainers: 'false',
@@ -224,8 +244,28 @@ describe('recall settings and merging', () => {
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' }] } },
+ {
+ 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,
);
@@ -234,9 +274,34 @@ describe('recall settings and merging', () => {
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'] } }],
+ [
+ {
+ profile: { static: ['s1', 's2', 's3'], dynamic: ['d1', 'd2', 'd3'] },
+ },
+ ],
15,
);
const { newFacts } = formatSessionContext(merged, {
@@ -289,16 +354,24 @@ describe('recall-directive hook', () => {
join(home, '.supermemory-claude', 'credentials.json'),
JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }),
);
- const stub = await startStubServer(t, (record, res) => {
+ 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 },
+ {
+ 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 },
+ {
+ title: 'Migration plan',
+ content: 'Use expand-contract migrations',
+ similarity: 0.7,
+ },
{ memory: 'irrelevant low-similarity hit', similarity: 0.2 },
],
},
@@ -308,7 +381,11 @@ describe('recall-directive hook', () => {
const { code, stdout } = await runHook(
'recall-directive.js',
- { session_id: 's1', cwd: repo, prompt: 'continue the database work from before' },
+ {
+ 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);
@@ -317,12 +394,21 @@ describe('recall-directive hook', () => {
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,
+ /- ◪ 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.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.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,
@@ -356,7 +442,10 @@ describe('recall-directive hook', () => {
);
const results = {
coding_personal: Array.from({ length: 8 }, (_, index) => ({
- memory: index === 0 ? 'Tomauskasz GitHub account preference' : `coding-${index}`,
+ memory:
+ index === 0
+ ? 'Tomauskasz GitHub account preference'
+ : `coding-${index}`,
similarity: 0.99 - index / 100,
})),
copla_company: Array.from({ length: 8 }, (_, index) => ({
@@ -391,18 +480,24 @@ describe('recall-directive hook', () => {
{
session_id: 's-shared-config',
cwd: repo,
- prompt: 'recall personal GitHub preferences and Copla company workflows',
+ 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',
- ]));
+ 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/);
@@ -413,11 +508,13 @@ describe('recall-directive hook', () => {
test('preserves complete recall wrappers at the token budget', () => {
const { text, newFacts } = formatRecallContext(
- [{
- memory: 'short memory',
- title: 't'.repeat(4000),
- filepath: 'p'.repeat(4000),
- }],
+ [
+ {
+ memory: 'short memory',
+ title: 't'.repeat(4000),
+ filepath: 'p'.repeat(4000),
+ },
+ ],
{
containerTag: 'repo_test',
maxTokens: 200,
@@ -448,10 +545,10 @@ describe('recall-directive hook', () => {
}
assert.notEqual(minimumTokens, null);
- const result = formatRecallContext(
- [{ memory: 'must remain eligible' }],
- { ...options, maxTokens: minimumTokens + 0.5 },
- );
+ const result = formatRecallContext([{ memory: 'must remain eligible' }], {
+ ...options,
+ maxTokens: minimumTokens + 0.5,
+ });
assert.equal(result.text, '');
assert.deepEqual(result.newFacts, []);
});
@@ -478,21 +575,27 @@ describe('recall-directive hook', () => {
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) => {
+ 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,
- })),
- },
- }));
+ 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' },
+ {
+ 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;
@@ -500,7 +603,7 @@ describe('recall-directive hook', () => {
assert.match(context, /<\/supermemory-recall>$/);
});
- test('marks only memories emitted within the prompt budget as seen', async (t) => {
+ test('persists only the emitted fragment of a truncated memory', async (t) => {
const { repo } = makeRepo(t);
const home = makeAuthedHome(t);
writeFileSync(
@@ -511,7 +614,7 @@ describe('recall-directive hook', () => {
memory: `${prefix}:${prefix.repeat(1000)}`,
similarity: 0.9 - index / 100,
}));
- const stub = await startStubServer(t, (record, res) => {
+ const stub = await startStubServer(t, (_record, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ searchResults: { results: hits } }));
});
@@ -520,21 +623,50 @@ describe('recall-directive hook', () => {
cwd: repo,
prompt: 'recall the long ordered memories',
};
- const env = { HOME: home, USERPROFILE: home, SUPERMEMORY_API_URL: stub.url };
+ const env = {
+ HOME: home,
+ USERPROFILE: home,
+ SUPERMEMORY_API_URL: stub.url,
+ };
- const first = JSON.parse((await runHook('recall-directive.js', input, env)).stdout);
+ 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 second = JSON.parse((await runHook('recall-directive.js', input, env)).stdout);
- assert.match(second.hookSpecificOutput.additionalContext, /B:BBB/);
- assert.doesNotMatch(second.hookSpecificOutput.additionalContext, /A:AAA/);
+ 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: [] };
+ const formatterOptions = {
+ containerTag: 'repo_test',
+ customContainers: [],
+ };
let minimumTokens = null;
for (let tokens = 0.25; tokens < 500; tokens += 0.25) {
try {
@@ -548,23 +680,31 @@ describe('recall-directive hook', () => {
join(home, '.supermemory-claude', 'settings.json'),
JSON.stringify({ maxPromptRecallTokens: minimumTokens + 0.5 }),
);
- const stub = await startStubServer(t, (record, res) => {
+ 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 }],
- },
- }));
+ 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 };
+ 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);
+ const output = JSON.parse(
+ (await runHook('recall-directive.js', input, env)).stdout,
+ );
assert.equal(output.hookSpecificOutput, undefined);
}
const sessionDir = getSessionDir(
@@ -582,7 +722,7 @@ describe('recall-directive hook', () => {
join(home, '.supermemory-claude', 'credentials.json'),
JSON.stringify({ apiKey: 'sm_test_key_0123456789abcdef' }),
);
- const stub = await startStubServer(t, (record, res) => res.end('{}'));
+ 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',
@@ -605,25 +745,51 @@ describe('recall-directive hook', () => {
{ 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) => {
+ 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 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 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);
+ 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 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'),
@@ -635,7 +801,11 @@ describe('recall-directive hook', () => {
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');
+ const configDir = join(
+ git(['rev-parse', '--show-toplevel']),
+ '.claude',
+ '.supermemory-claude',
+ );
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'config.json'),
@@ -646,267 +816,9 @@ describe('recall-directive hook', () => {
{ session_id: 's1', cwd: repo, prompt: 'a long substantive prompt here' },
{ HOME: home, USERPROFILE: home },
);
- assert.equal(JSON.parse(stdout).hookSpecificOutput.additionalContext, 'CUSTOM DIRECTIVE');
- });
-});
-
-describe('status check', () => {
- test('probes the same-key mirrored endpoint without printing the key', async (t) => {
- const { repo } = makeRepo(t);
- const apiKey = 'sm_status_secret_0123456789';
- const home = makeAuthedHome(t, apiKey);
- const stub = await startStubServer(t, (record, res) => {
- res.setHeader('Content-Type', 'application/json');
- res.end(JSON.stringify({ profile: { static: [], dynamic: [] } }));
- });
- 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 }));
- });
-
- assert.equal(result.code, 0, result.stderr);
- assert.doesNotMatch(result.stdout, new RegExp(apiKey));
- const output = JSON.parse(result.stdout);
- 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}`);
- });
-});
-
-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);
- });
-
- 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/);
- });
-});
-
-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, 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' },
- }),
+ assert.equal(
+ JSON.parse(stdout).hookSpecificOutput.additionalContext,
+ 'CUSTOM DIRECTIVE',
);
- 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);
});
});
-
diff --git a/test/session-start.mjs b/test/session-start.mjs
new file mode 100644
index 0000000..c94e19d
--- /dev/null
+++ b/test/session-start.mjs
@@ -0,0 +1,103 @@
+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, 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);
+ });
+
+ 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..80d882b
--- /dev/null
+++ b/test/status.mjs
@@ -0,0 +1,90 @@
+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) {
+ const { repo } = makeRepo(t);
+ const apiKey = 'sm_status_secret_0123456789';
+ const home = makeAuthedHome(t, apiKey);
+ const stub = await startStubServer(t, (_record, res) => {
+ res.statusCode = httpStatus;
+ res.setHeader('Content-Type', 'application/json');
+ res.end(
+ JSON.stringify(
+ httpStatus === 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 [
+ [401, false],
+ [403, false],
+ [429, null],
+ [503, null],
+ ]) {
+ const { apiKey, output, result } = 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);
+ }
+ });
+});
diff --git a/test/unit.mjs b/test/unit.mjs
index b66ff58..d0f9fe2 100644
--- a/test/unit.mjs
+++ b/test/unit.mjs
@@ -8,9 +8,9 @@ import {
utimesSync,
writeFileSync,
} from 'node:fs';
+import { createRequire } from 'node:module';
import { basename, join } from 'node:path';
import { describe, test } from 'node:test';
-import { createRequire } from 'node:module';
import {
HOOKS_DIR,
hash16,
@@ -59,7 +59,10 @@ 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');
});
@@ -74,9 +77,16 @@ 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');
+ 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' }));
+ writeFileSync(
+ join(configDir, 'config.json'),
+ JSON.stringify({ repoContainerTag: 'team_tag' }),
+ );
assert.equal(readTags(repo, home).tag, 'team_tag');
});
});
@@ -129,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: {} },
@@ -149,7 +166,7 @@ describe('recall-approve hook', () => {
});
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 },
@@ -161,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();
@@ -185,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');
@@ -196,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(
@@ -204,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) => {
@@ -228,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 });
@@ -245,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) => {
@@ -265,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));
@@ -296,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',
@@ -344,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',
@@ -374,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 },
@@ -386,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`,
+ );
}
});
@@ -399,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', () => {
@@ -416,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',
From 7ea5a57f7b0dca2a40bfd1026e3ea038dc01ee03 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:52:14 +0300
Subject: [PATCH 5/9] fix(status): preserve unexpected profile statuses
Require the profile endpoint to return its expected HTTP 200 status so the shared transport preserves 201 and 204 as indeterminate authentication results instead of rewriting them to success.
Add regressions for unexpected successful responses and keep the status probe tri-state contract intact.
Signed-off-by: Tomas <180413002+Tomauskasz@users.noreply.github.com>
---
plugin/hooks/lib/api.js | 7 ++++++-
test/status.mjs | 2 ++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js
index 8970213..3381a26 100644
--- a/plugin/hooks/lib/api.js
+++ b/plugin/hooks/lib/api.js
@@ -31,6 +31,7 @@ async function post(
path,
body,
timeoutMs = REQUEST_TIMEOUT_MS,
+ expectedStatus,
) {
const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${path}`, {
method: 'POST',
@@ -43,7 +44,10 @@ async function post(
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)}`),
@@ -60,6 +64,7 @@ function getProfile(baseUrl, apiKey, containerTag, query, options = {}) {
'/v4/profile',
{ containerTag, q: query },
options.timeoutMs,
+ 200,
);
}
diff --git a/test/status.mjs b/test/status.mjs
index 80d882b..2670476 100644
--- a/test/status.mjs
+++ b/test/status.mjs
@@ -74,6 +74,8 @@ describe('status check', () => {
test('separates rejected credentials from indeterminate failures', async (t) => {
for (const [httpStatus, authenticated] of [
+ [201, null],
+ [204, null],
[401, false],
[403, false],
[429, null],
From 6ca8202154793459fda5c9c1cd9f9ca36db83d93 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 11:54:54 +0300
Subject: [PATCH 6/9] test: reuse shared authenticated-home fixture
Route the remaining capture, prompt-recall, and SessionStart setup through makeAuthedHome so credentials have one test-fixture owner.
Signed-off-by: Tomas <180413002+Tomauskasz@users.noreply.github.com>
---
test/capture.mjs | 16 ++++------------
test/recall.mjs | 24 ++++++------------------
test/session-start.mjs | 8 ++------
3 files changed, 12 insertions(+), 36 deletions(-)
diff --git a/test/capture.mjs b/test/capture.mjs
index c9aebbe..69734d3 100644
--- a/test/capture.mjs
+++ b/test/capture.mjs
@@ -58,12 +58,8 @@ 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 { repo } = makeRepo(t);
+ const home = makeAuthedHome(t);
const transcript = join(makeTempDir(t, 'transcript'), 'session.jsonl');
writeFileSync(
transcript,
@@ -117,12 +113,8 @@ describe('capture hook', () => {
});
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 { repo } = makeRepo(t);
+ const home = makeAuthedHome(t);
const transcript = join(
makeTempDir(t, 'transcript-retry'),
'session.jsonl',
diff --git a/test/recall.mjs b/test/recall.mjs
index a79d541..d3d7b6f 100644
--- a/test/recall.mjs
+++ b/test/recall.mjs
@@ -348,12 +348,8 @@ describe('recall settings and merging', () => {
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 { repo } = makeRepo(t);
+ const home = makeAuthedHome(t);
const stub = await startStubServer(t, (_record, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(
@@ -716,12 +712,8 @@ describe('recall-directive hook', () => {
});
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 { 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(
@@ -735,12 +727,8 @@ describe('recall-directive hook', () => {
});
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' }),
- );
+ 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 },
diff --git a/test/session-start.mjs b/test/session-start.mjs
index c94e19d..e9a01cc 100644
--- a/test/session-start.mjs
+++ b/test/session-start.mjs
@@ -16,12 +16,8 @@ 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, 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 { repo } = makeRepo(t);
+ const home = makeAuthedHome(t);
const stub = await startStubServer(t, (_record, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(
From 10143afd00d908583b3dc9d3be45f1dc17346601 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:17:35 +0300
Subject: [PATCH 7/9] fix(status): preserve redirect responses
Signed-off-by: Tomas
---
plugin/hooks/lib/api.js | 1 +
test/status.mjs | 22 ++++++++++++++++++----
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js
index 3381a26..3925446 100644
--- a/plugin/hooks/lib/api.js
+++ b/plugin/hooks/lib/api.js
@@ -41,6 +41,7 @@ async function post(
'x-sm-source': 'claude-code',
},
body: JSON.stringify(body),
+ redirect: 'manual',
signal: AbortSignal.timeout(timeoutMs),
});
diff --git a/test/status.mjs b/test/status.mjs
index 2670476..e4a1e33 100644
--- a/test/status.mjs
+++ b/test/status.mjs
@@ -14,12 +14,19 @@ async function runStatus(t, httpStatus) {
const { repo } = makeRepo(t);
const apiKey = 'sm_status_secret_0123456789';
const home = makeAuthedHome(t, apiKey);
- const stub = await startStubServer(t, (_record, res) => {
- res.statusCode = httpStatus;
+ 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(
JSON.stringify(
- httpStatus === 200
+ responseStatus === 200
? { profile: { static: [], dynamic: [] } }
: { error: 'probe failed' },
),
@@ -76,17 +83,24 @@ describe('status check', () => {
for (const [httpStatus, authenticated] of [
[201, null],
[204, null],
+ [302, null],
[401, false],
[403, false],
[429, null],
[503, null],
]) {
- const { apiKey, output, result } = await runStatus(t, httpStatus);
+ 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'],
+ );
+ }
}
});
});
From 8f355ad32b1cdd09af611999054eccefdaffdfc9 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:36:23 +0300
Subject: [PATCH 8/9] fix(status): retain status on decode errors
Signed-off-by: Tomas
---
plugin/hooks/lib/api.js | 4 +++-
test/status.mjs | 27 +++++++++++++++++++++------
2 files changed, 24 insertions(+), 7 deletions(-)
diff --git a/plugin/hooks/lib/api.js b/plugin/hooks/lib/api.js
index 3925446..231a1ed 100644
--- a/plugin/hooks/lib/api.js
+++ b/plugin/hooks/lib/api.js
@@ -55,7 +55,9 @@ async function post(
{ 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 = {}) {
diff --git a/test/status.mjs b/test/status.mjs
index e4a1e33..42e8e0a 100644
--- a/test/status.mjs
+++ b/test/status.mjs
@@ -10,7 +10,7 @@ import {
startStubServer,
} from './helpers.mjs';
-async function runStatus(t, httpStatus) {
+async function runStatus(t, httpStatus, responseBody) {
const { repo } = makeRepo(t);
const apiKey = 'sm_status_secret_0123456789';
const home = makeAuthedHome(t, apiKey);
@@ -25,11 +25,12 @@ async function runStatus(t, httpStatus) {
res.statusCode = responseStatus;
res.setHeader('Content-Type', 'application/json');
res.end(
- JSON.stringify(
- responseStatus === 200
- ? { profile: { static: [], dynamic: [] } }
- : { error: 'probe failed' },
- ),
+ responseBody ??
+ JSON.stringify(
+ responseStatus === 200
+ ? { profile: { static: [], dynamic: [] } }
+ : { error: 'probe failed' },
+ ),
);
});
const sharedDir = join(home, '.codex', 'supermemory');
@@ -103,4 +104,18 @@ describe('status check', () => {
}
}
});
+
+ 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'],
+ );
+ });
});
From 2338ef6a734d5dea2136c5dc71add1ccff5fd434 Mon Sep 17 00:00:00 2001
From: Tomas <180413002+Tomauskasz@users.noreply.github.com>
Date: Wed, 2 Sep 2026 12:54:10 +0300
Subject: [PATCH 9/9] fix(settings): ignore null shared limits
Signed-off-by: Tomas
---
plugin/hooks/lib/settings.js | 2 +-
test/recall.mjs | 15 +++++++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/plugin/hooks/lib/settings.js b/plugin/hooks/lib/settings.js
index 87034ed..d99920e 100644
--- a/plugin/hooks/lib/settings.js
+++ b/plugin/hooks/lib/settings.js
@@ -78,7 +78,7 @@ function loadSettings() {
const shared = readSettings(SHARED_SETTINGS_FILE);
const settings = { ...DEFAULT_SETTINGS };
for (const key of SHARED_RECALL_KEYS) {
- if (Object.hasOwn(shared, key)) {
+ if (Object.hasOwn(shared, key) && shared[key] != null) {
settings[key] = shared[key];
}
}
diff --git a/test/recall.mjs b/test/recall.mjs
index d3d7b6f..5481d30 100644
--- a/test/recall.mjs
+++ b/test/recall.mjs
@@ -136,6 +136,21 @@ describe('recall settings and merging', () => {
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);