const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { getProfile } = require('./lib/api');
const { BRAND, gray, red } = require('./lib/colors');
const { getContainerTag } = require('./lib/container-tag');
const { getUserFriendlyError } = require('./lib/error-helpers');
const { loadProjectConfig } = require('./lib/project-config');
const {
loadSettings,
getApiKey,
getBaseUrl,
debugLog,
getRecallConfig,
} = require('./lib/settings');
const {
atomicWriteJson,
getSessionDir,
readState,
writeState,
} = require('./lib/statusline-state');
const { readStdin, writeOutput } = require('./lib/stdin');
// Recall is performed HERE, not delegated to the model: the hook searches
// supermemory with the prompt itself and injects the top matches, so recall
// happens on every substantive prompt instead of only when the model chooses
// 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;
function shouldSkip(prompt) {
if (prompt.length < MIN_PROMPT_LENGTH) return true;
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
// is pruned with it.
function hashText(text) {
return crypto
.createHash('sha256')
.update(text.replace(/\s+/g, ' ').trim())
.digest('hex')
.slice(0, 16);
}
function readSeenHashes(sessionDir) {
try {
const list = JSON.parse(
fs.readFileSync(path.join(sessionDir, 'recalled.json'), 'utf8'),
);
return Array.isArray(list)
? list.filter((h) => typeof h === 'string')
: [];
} catch {
return [];
}
}
// The hook can only inject what it finds. When a project has no stored memories
// the model gets silence — and silence reads as "there is nothing to search",
// so it never reaches for the tool and any history living in another container
// stays invisible. Say once per session that the tool exists, what to scope it
// to, and that the call is free. Once per session, because this fires on a miss
// and misses are the common case in an empty project: repeating it would tax
// every prompt and invite a search on turns where memory is irrelevant.
const DISCOVERY_MARKER = 'discovery.json';
function discoverySent(sessionDir) {
return fs.existsSync(path.join(sessionDir, DISCOVERY_MARKER));
}
function markDiscoverySent(sessionDir) {
try {
atomicWriteJson(path.join(sessionDir, DISCOVERY_MARKER), {
sentAt: new Date().toISOString(),
});
} catch {
// Best effort: a failed marker only risks repeating the notice.
}
}
// The tool is exposed under three names depending on install shape (see
// TOOL_NAME_RE in recall-approve.js), and under deferred tool loading the
// model holds a bare name with no schema — so point at the lookup, not at one
// hardcoded name.
function formatDiscovery(containerTag) {
return `
No stored memories matched this prompt for this project.
Deeper history may still exist. Search it with the supermemory search_memory
tool — exposed as mcp__…supermemory__search_memory; if its schema is not
loaded, resolve the exact name with ToolSearch("+supermemory search_memory").
- Scope to this project with containerTag: "${containerTag}"
- Omit containerTag to search the account's active/shared space instead.
- Read-only supermemory calls are auto-approved; they never prompt the user.
Worth a call when the user refers to past decisions, earlier sessions, or says
"remember" / "we decided" / "last time". Skip it for self-contained tasks.
`;
}
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 — it defaults to this project's container (${containerTag}). Pass containerTag only to search a different space. Or launch the context-gatherer agent.
`;
}
async function main() {
const settings = loadSettings();
try {
const input = await readStdin();
const cwd = input.cwd || process.cwd();
const prompt = (input.prompt || '').trim();
const { directive } = getRecallConfig(cwd);
if (directive) {
writeOutput({
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: directive,
},
});
return;
}
if (shouldSkip(prompt)) {
writeOutput({ continue: true, suppressOutput: true });
return;
}
const projectConfig = loadProjectConfig(cwd);
let apiKey;
try {
apiKey = getApiKey(cwd, projectConfig);
} catch {
writeOutput({ continue: true, suppressOutput: true });
return;
}
const containerTag = getContainerTag(cwd);
const response = await getProfile(
getBaseUrl(cwd, projectConfig),
apiKey,
containerTag,
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 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 repeats = results.length - fresh.length;
if (input.session_id) {
const prev = readState(input.session_id).search || {};
writeState(input.session_id, 'search', {
results: fresh.length,
count: (prev.count || 0) + 1,
memories: (prev.memories || 0) + fresh.length,
});
}
debugLog(settings, 'Prompt recall', {
query: prompt.slice(0, 80),
hits: results.length,
fresh: fresh.length,
});
if (fresh.length === 0) {
// Only when the container is genuinely empty: if results came back but
// were all repeats, formatRecall already delivered the same guidance
// earlier this session.
if (results.length === 0 && sessionDir && !discoverySent(sessionDir)) {
markDiscoverySent(sessionDir);
writeOutput({
systemMessage: `${BRAND} ${gray('·')} no memories yet for this project`,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: formatDiscovery(containerTag),
},
});
return;
}
writeOutput({ continue: true, suppressOutput: true });
return;
}
if (sessionDir) {
try {
atomicWriteJson(
path.join(sessionDir, 'recalled.json'),
[...seen, ...fresh.map((r) => hashText(resultText(r)))].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}`;
writeOutput({
systemMessage: `${BRAND} ${gray('·')} ${label}`,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: context,
},
});
} catch (err) {
debugLog(settings, 'Recall directive error', { error: err.message });
// A slow recall should not interrupt every prompt with an error banner.
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
writeOutput({ continue: true, suppressOutput: true });
return;
}
writeOutput({
systemMessage: `${BRAND} ${gray('·')} ${red(`recall failed: ${getUserFriendlyError(err).slice(0, 80)}`)}`,
continue: true,
suppressOutput: true,
});
}
}
main().catch(() => {
writeOutput({ continue: true, suppressOutput: true });
});