From 81af8dd7c52be5d12ac05d59bc83948cda60f943 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 22 Aug 2026 19:59:30 +0530 Subject: [PATCH] feat(codex): add bounded direct prompt recall --- README.md | 7 +- build.mjs | 2 + src/cli.ts | 6 +- src/config.ts | 44 +++++++++--- src/hooks/recall.ts | 16 +++-- src/services/hookRecallClient.ts | 113 +++++++++++++++++++++++++++++++ src/services/recallPolicy.ts | 12 ++++ src/skills/status.ts | 3 +- test/unit.mjs | 57 +++++++++++++++- 9 files changed, 237 insertions(+), 23 deletions(-) create mode 100644 src/services/hookRecallClient.ts create mode 100644 src/services/recallPolicy.ts diff --git a/README.md b/README.md index 3748a23..56ed9b5 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ and the lessons learned across every project — automatically. ## Features -- 🧠 **Automatic recall** — relevant memories are injected into every prompt via the - `UserPromptSubmit` hook. +- 🧠 **Automatic recall** — relevant memories are injected for substantive prompts via + the `UserPromptSubmit` hook, with short commands skipped and retrieval capped at 3 seconds. - 💾 **Automatic capture** — conversations are stored incrementally (every N turns) and at session end via the `Stop` hook. - 🏷️ **Shared Agents scoping** — Codex, Claude Code, and OpenCode use one collision-safe @@ -119,6 +119,9 @@ Drop this file in to override defaults: | `projectContainerTag` | `string` | auto (per-repo) | Explicit unified project-container override, also honored by Claude Code. | | `filterPrompt` | `string` | (sensible) | Filter prompt used by Supermemory's stateful filter. | | `debug` | `boolean` | `false` | Enable debug logging. | +| `recallMode` | `"direct" \| "off" \| "advisory"` | `"direct"` | Directly retrieve relevant memory, disable prompt recall, or inject an advisory directive. | +| `recallDirective` | `string` | (sensible) | Context injected when `recallMode` is `"advisory"`. | +| `autoRecallEveryPrompt` | `boolean` | — | Deprecated compatibility key; `true` maps to direct and `false` maps to off. | | `autoSaveEveryTurns` | `number` | `3` | Save memories every N turns (incremental capture). | | `signalExtraction` | `boolean` | `false` | Enable signal-based filtering (only capture turns with keywords like "prefer", "decided"). | | `signalKeywords` | `string[]` | (defaults) | Keywords that trigger signal extraction. | diff --git a/build.mjs b/build.mjs index 0be93c2..ce5c4e6 100644 --- a/build.mjs +++ b/build.mjs @@ -42,6 +42,8 @@ const libraryEntries = [ { in: "src/services/resultMerge.ts", out: "dist/services/resultMerge.js" }, { in: "src/services/resultText.ts", out: "dist/services/resultText.js" }, { in: "src/services/factCache.ts", out: "dist/services/factCache.js" }, + { in: "src/services/recallPolicy.ts", out: "dist/services/recallPolicy.js" }, + { in: "src/services/hookRecallClient.ts", out: "dist/services/hookRecallClient.js" }, ]; await Promise.all( diff --git a/src/cli.ts b/src/cli.ts index c34bea5..699ae84 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,7 @@ import { rmSync, } from "node:fs"; import { loadCredentials } from "./services/auth.js"; -import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary, CONFIG } from "./config.js"; +import { writeInstallDefaults, CONFIG_FILE, getRecallModeSummary } from "./config.js"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -341,8 +341,8 @@ You now have: • Explicit memory — supermemory-search, supermemory-add, supermemory-save, supermemory-forget, supermemory-profile, supermemory-status, supermemory-login, and supermemory-logout skills ${hadExistingConfig - ? "Existing install: legacy per-prompt recall/capture preserved in ~/.codex/supermemory.json.\nTo opt into new defaults, set autoRecallEveryPrompt=false and captureEveryNTurns=0.\n" - : "Fresh install: session-start profile + session-end flush only.\nEnable autoRecallEveryPrompt or captureEveryNTurns in ~/.codex/supermemory.json if needed.\n"} + ? "Existing recall/capture preferences were preserved in ~/.codex/supermemory.json.\nSet recallMode to direct, off, or advisory to change recall behavior.\n" + : "Fresh install: direct relevant-memory recall plus session-start profile and session-end flush.\nSet recallMode to off or advisory in ~/.codex/supermemory.json if preferred.\n"} Next steps: 1. Start Codex — on your first prompt, a browser window will open to diff --git a/src/config.ts b/src/config.ts index 4a05b36..1e88f8c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,10 @@ export interface CustomContainer { description: string; } +export type RecallMode = "direct" | "off" | "advisory"; +export const DEFAULT_RECALL_DIRECTIVE = + "Relevant prior context may exist in Supermemory. Search memory before answering when the request depends on previous decisions, preferences, or project history."; + interface CodexSupermemoryConfig { apiKey?: string; baseUrl?: string; @@ -31,6 +35,8 @@ interface CodexSupermemoryConfig { /** @deprecated Use captureEveryNTurns */ autoSaveEveryTurns?: number; autoRecallEveryPrompt?: boolean; + recallMode?: RecallMode; + recallDirective?: string; captureEveryNTurns?: number; enableCustomContainers?: boolean; customContainers?: CustomContainer[]; @@ -61,6 +67,7 @@ const DEFAULTS = { signalTurnsBefore: 3, autoSaveEveryTurns: 3, autoRecallEveryPrompt: false, + recallMode: "direct" as RecallMode, captureEveryNTurns: 0, }; @@ -111,10 +118,14 @@ function resolveCaptureEveryNTurns(config: CodexSupermemoryConfig): number { return DEFAULTS.captureEveryNTurns; } -function resolveAutoRecallEveryPrompt(config: CodexSupermemoryConfig): boolean { - if (config.autoRecallEveryPrompt !== undefined) return config.autoRecallEveryPrompt; - if (configExisted) return true; - return DEFAULTS.autoRecallEveryPrompt; +function resolveRecallMode(config: CodexSupermemoryConfig): RecallMode { + if (["direct", "off", "advisory"].includes(config.recallMode ?? "")) { + return config.recallMode as RecallMode; + } + if (config.autoRecallEveryPrompt !== undefined) { + return config.autoRecallEveryPrompt ? "direct" : "off"; + } + return DEFAULTS.recallMode; } function getApiKey(): string | undefined { @@ -129,6 +140,8 @@ export function reloadApiKey(): void { SUPERMEMORY_API_KEY = getApiKey(); } +const recallMode = resolveRecallMode(fileConfig); + export const CONFIG = { similarityThreshold: fileConfig.similarityThreshold ?? DEFAULTS.similarityThreshold, maxMemories: fileConfig.maxMemories ?? DEFAULTS.maxMemories, @@ -143,7 +156,13 @@ export const CONFIG = { signalKeywords: fileConfig.signalKeywords ?? DEFAULTS.signalKeywords, signalTurnsBefore: fileConfig.signalTurnsBefore ?? DEFAULTS.signalTurnsBefore, autoSaveEveryTurns: fileConfig.autoSaveEveryTurns ?? DEFAULTS.autoSaveEveryTurns, - autoRecallEveryPrompt: resolveAutoRecallEveryPrompt(fileConfig), + recallMode, + recallDirective: + typeof fileConfig.recallDirective === "string" && fileConfig.recallDirective.trim() + ? fileConfig.recallDirective.trim() + : DEFAULT_RECALL_DIRECTIVE, + /** @deprecated Prefer recallMode. */ + autoRecallEveryPrompt: recallMode === "direct", captureEveryNTurns: resolveCaptureEveryNTurns(fileConfig), enableCustomContainers: fileConfig.enableCustomContainers ?? false, customContainers: (fileConfig.customContainers ?? []).filter( @@ -256,15 +275,15 @@ export function writeInstallDefaults(isExistingInstall: boolean): void { const current = loadRawConfigForWrite().config; const next: CodexSupermemoryConfig = { ...current }; + if (next.recallMode === undefined) { + next.recallMode = next.autoRecallEveryPrompt === false ? "off" : "direct"; + } + if (isExistingInstall) { - if (next.autoRecallEveryPrompt === undefined) { - next.autoRecallEveryPrompt = true; - } if (next.captureEveryNTurns === undefined) { next.captureEveryNTurns = next.autoSaveEveryTurns ?? 3; } } else { - next.autoRecallEveryPrompt = false; next.captureEveryNTurns = 0; } @@ -272,8 +291,11 @@ export function writeInstallDefaults(isExistingInstall: boolean): void { } export function getRecallModeSummary(): string { - if (CONFIG.autoRecallEveryPrompt) { - return "legacy: recall on every prompt"; + if (CONFIG.recallMode === "direct") { + return "direct: relevant recall on substantive prompts"; + } + if (CONFIG.recallMode === "advisory") { + return "advisory: prompt the agent to search memory when needed"; } if (CONFIG.captureEveryNTurns > 0) { return `unified: session-start profile + capture every ${CONFIG.captureEveryNTurns} turns + session-end flush`; diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index 72beff2..9fc53e9 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -10,6 +10,8 @@ import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js"; import { captureEntries, resolveTranscriptPath } from "../services/capture.js"; import { getSeenFacts, addSeenFacts } from "../services/factCache.js"; import { getSessionId } from "../services/session.js"; +import { getHookProfileWithSearchMany } from "../services/hookRecallClient.js"; +import { prepareRecallQuery, shouldRecallPrompt } from "../services/recallPolicy.js"; const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted"); const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out"); @@ -105,7 +107,7 @@ async function main() { query: query.slice(0, 100), tags, sessionId, - autoRecallEveryPrompt: CONFIG.autoRecallEveryPrompt, + recallMode: CONFIG.recallMode, }); const transcriptPath = resolveTranscriptPath(payload.transcript_path, sessionId); @@ -118,14 +120,20 @@ async function main() { }); } - if (!CONFIG.autoRecallEveryPrompt) { + if (CONFIG.recallMode === "off") { exitWithContext(""); } + if (CONFIG.recallMode === "advisory") { + exitWithContext(CONFIG.recallDirective); + } + + if (!shouldRecallPrompt(query)) exitWithContext(""); + try { - const profileResult = await client.getProfileWithSearchMany( + const profileResult = await getHookProfileWithSearchMany( tags.allReads, - query, + prepareRecallQuery(query), ); const seen = getSeenFacts(sessionId); diff --git a/src/services/hookRecallClient.ts b/src/services/hookRecallClient.ts new file mode 100644 index 0000000..a7ad494 --- /dev/null +++ b/src/services/hookRecallClient.ts @@ -0,0 +1,113 @@ +import { CONFIG, getApiKeyValue, getBaseUrl } from "../config.js"; +import type { ProfileWithSearchResult, SearchResultItem } from "./client.js"; +import { mergeProfileResults } from "./resultMerge.js"; +import { boundedMemoryText, recallProvenance } from "./resultText.js"; + +export const HOOK_RECALL_TIMEOUT_MS = 3000; + +interface HookRecallOptions { + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +function stringFacts(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter( + (fact): fact is string => typeof fact === "string" && fact.trim().length > 0, + ); +} + +function normalizeProfileResponse(raw: unknown): ProfileWithSearchResult { + const value = raw && typeof raw === "object" + ? raw as Record + : {}; + const profile = value.profile && typeof value.profile === "object" + ? value.profile as Record + : {}; + const searchResults = value.searchResults && typeof value.searchResults === "object" + ? value.searchResults as Record + : null; + const rawResults = Array.isArray(searchResults?.results) + ? searchResults.results as SearchResultItem[] + : []; + const results = rawResults + .map((result) => { + const provenance = recallProvenance(result); + return { + id: result.id, + memory: boundedMemoryText(result), + similarity: result.similarity, + title: provenance.title, + filepath: provenance.filepath, + updatedAt: result.updatedAt, + }; + }) + .filter((result) => result.memory.length > 0); + + return { + success: true, + profile: { + static: stringFacts(profile.static), + dynamic: stringFacts(profile.dynamic), + }, + searchResults: searchResults + ? { + results, + total: typeof searchResults.total === "number" ? searchResults.total : results.length, + timing: typeof searchResults.timing === "number" ? searchResults.timing : undefined, + } + : undefined, + }; +} + +async function fetchProfile( + containerTag: string, + query: string, + options: Required, +): Promise { + const apiKey = getApiKeyValue(); + if (!apiKey) return { success: false, error: "Missing API key", profile: null }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs); + try { + const response = await options.fetchImpl(`${getBaseUrl().replace(/\/+$/, "")}/v4/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "x-sm-source": "codex", + }, + body: JSON.stringify({ containerTag, q: query }), + signal: controller.signal, + }); + if (!response.ok) { + return { success: false, error: `Profile request failed (${response.status})`, profile: null }; + } + return normalizeProfileResponse(await response.json()); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + profile: null, + }; + } finally { + clearTimeout(timeout); + } +} + +export async function getHookProfileWithSearchMany( + containerTags: string[], + query: string, + options: HookRecallOptions = {}, +): Promise { + const resolved = { + timeoutMs: options.timeoutMs ?? HOOK_RECALL_TIMEOUT_MS, + fetchImpl: options.fetchImpl ?? fetch, + }; + const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const results = await Promise.all( + uniqueTags.map((containerTag) => fetchProfile(containerTag, query, resolved)), + ); + return mergeProfileResults(results, CONFIG.maxMemories); +} diff --git a/src/services/recallPolicy.ts b/src/services/recallPolicy.ts new file mode 100644 index 0000000..55af402 --- /dev/null +++ b/src/services/recallPolicy.ts @@ -0,0 +1,12 @@ +export const MIN_RECALL_QUERY_CHARS = 12; +export const MAX_RECALL_QUERY_CHARS = 500; + +export function shouldRecallPrompt(prompt: string): boolean { + const query = prompt.trim(); + if (query.length < MIN_RECALL_QUERY_CHARS) return false; + return !["/", "!", "#"].some((prefix) => query.startsWith(prefix)); +} + +export function prepareRecallQuery(prompt: string): string { + return prompt.trim().slice(0, MAX_RECALL_QUERY_CHARS); +} diff --git a/src/skills/status.ts b/src/skills/status.ts index d506921..7fdcb09 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -40,7 +40,8 @@ function getDevTlsHint(): string | null { } function getAutoRecallStatus(): string { - return CONFIG.autoRecallEveryPrompt ? "every prompt" : "off"; + if (CONFIG.recallMode === "direct") return "direct (substantive prompts)"; + return CONFIG.recallMode; } function getAutoCaptureStatus(): string { diff --git a/test/unit.mjs b/test/unit.mjs index c0dc9ab..87cf216 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -303,6 +303,51 @@ describe("session recall deduplication", () => { }); }); +describe("direct recall policy", () => { + const policyModule = new URL("../dist/services/recallPolicy.js", import.meta.url).href; + const hookClientModule = new URL("../dist/services/hookRecallClient.js", import.meta.url).href; + + test("skips control and tiny prompts while capping substantive queries", () => { + const script = ` + import { shouldRecallPrompt, prepareRecallQuery } from ${JSON.stringify(policyModule)}; + console.log(JSON.stringify({ + decisions: ["short", "/supermemory", "!shell command", "# heading text", "explain the cache design"].map(shouldRecallPrompt), + queryLength: prepareRecallQuery("x".repeat(600)).length, + })); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf-8" }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + decisions: [false, false, false, false, true], + queryLength: 500, + }); + }); + + test("aborts hook-only profile fetches and fails open", () => { + const script = ` + import { getHookProfileWithSearchMany } from ${JSON.stringify(hookClientModule)}; + const fetchImpl = (_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + const started = Date.now(); + const result = await getHookProfileWithSearchMany(["repo_test"], "substantive recall query", { + timeoutMs: 20, + fetchImpl, + }); + console.log(JSON.stringify({ success: result.success, elapsed: Date.now() - started })); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script], { + env: { ...process.env, SUPERMEMORY_CODEX_API_KEY: "sm_test" }, + encoding: "utf-8", + timeout: 1_000, + }); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.success, false); + assert.ok(output.elapsed < 500, `abort took ${output.elapsed}ms`); + }); +}); + // ─── session ids ──────────────────────────────────────────────────────────── describe("session ids", () => { @@ -553,6 +598,8 @@ describe("integration: install/uninstall", () => { `SKILL.md should contain name: ${skillName}` ); } + const config = JSON.parse(readFileSync(join(codexDir, "supermemory.json"), "utf-8")); + assert.equal(config.recallMode, "direct"); }); test("uninstall removes skill directories", (t) => { @@ -957,10 +1004,16 @@ describe("skill scripts: search/add/save/forget/status/logout", () => { assert.match(result.stdout, /Auto-recall: off/); }); - test("status reports every-prompt auto-recall when enabled", (t) => { + test("status maps legacy enabled auto-recall to direct mode", (t) => { const result = runStatusWithConfig(t, { autoRecallEveryPrompt: true, captureEveryNTurns: 0 }); assert.equal(result.status, 0); - assert.match(result.stdout, /Auto-recall: every prompt/); + assert.match(result.stdout, /Auto-recall: direct \(substantive prompts\)/); + }); + + test("status preserves advisory recall mode", (t) => { + const result = runStatusWithConfig(t, { recallMode: "advisory", captureEveryNTurns: 0 }); + assert.equal(result.status, 0); + assert.match(result.stdout, /Auto-recall: advisory/); }); test("status reports auto-capture off when captureEveryNTurns is zero", (t) => {