diff --git a/README.md b/README.md index 56ed9b5..6dbb90a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ and the lessons learned across every project — automatically. - 🧠 **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. + flushed after completed turns via the `Stop` hook. - 🏷️ **Shared Agents scoping** — Codex, Claude Code, and OpenCode use one collision-safe repository container. - 📦 **Custom container tags** — define custom memory containers (e.g., `work`, `personal`, @@ -51,17 +51,20 @@ and the lessons learned across every project — automatically. ## How it works Codex CLI supports a hooks system that lets external scripts run at specific -lifecycle events. `codex-supermemory` registers two hooks: +lifecycle events. `codex-supermemory` registers four hooks: | Hook | Event | What it does | | ----------------- | ---------------------- | ------------------------------------------------------------------- | -| `recall` | `UserPromptSubmit` | Captures new turns (every N prompts), then searches Supermemory for relevant memories and your profile, injecting them into the prompt as `additionalContext`. | -| `flush` | `Stop` | Captures any remaining turns at session end so the final conversation turns are never lost. | - -**Incremental capture**: Memories are saved every N turns (default: 3) during the session. -This means memories from earlier in your session are immediately available for recall +| `recall` | `UserPromptSubmit` | Searches Supermemory for relevant memories and your profile, injecting them into the prompt as `additionalContext`. | +| `capture-turn` | `UserPromptSubmit` | Captures new turns every N prompts in the background without delaying recall. | +| `flush` | `Stop` | Captures remaining turns in the background after a completed turn. | +| `session-start` | `SessionStart` | Loads persistent and recent profile context for the session. | + +**Incremental capture**: When configured, memories are saved every N turns during the session +(legacy installs retain their existing cadence; fresh installs rely on turn-stop capture). +This background hook makes memories from earlier in your session available for recall in the same session. The flush hook ensures any trailing turns are captured when the -session ends. +current turn stops. The installer: @@ -112,7 +115,7 @@ Drop this file in to override defaults: | `baseUrl` | `string` | `https://api.supermemory.ai` | Supermemory API base URL (`SUPERMEMORY_API_URL`/`SUPERMEMORY_BASE_URL` env vars take precedence). | | `similarityThreshold` | `number` | `0.6` | Minimum similarity score for retrieved memories. | | `maxMemories` | `number` | `5` | Max memories injected per prompt. | -| `maxProfileItems` | `number` | `5` | Max profile items considered. | +| `maxProfileItems` | `number` | `5` | Max profile items considered from each persistent/recent section. | | `injectProfile` | `boolean` | `true` | Whether to fetch and inject the user profile. | | `containerTagPrefix` | `string` | `"codex"` | Legacy prefix retained when reading containers created by older versions. | | `userContainerTag` | `string` | auto | Legacy personal container retained for backward-compatible reads. | diff --git a/build.mjs b/build.mjs index ce5c4e6..7743032 100644 --- a/build.mjs +++ b/build.mjs @@ -24,7 +24,7 @@ const sharedConfig = { const executableEntries = [ { in: "src/cli.ts", out: "dist/cli.js" }, - ...["recall", "flush", "session-start"].map((n) => ({ + ...["recall", "capture-turn", "flush", "session-start"].map((n) => ({ in: `src/hooks/${n}.ts`, out: `dist/hooks/${n}.js`, })), @@ -44,6 +44,9 @@ const libraryEntries = [ { 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" }, + { in: "src/services/client.ts", out: "dist/services/client.js" }, + { in: "src/services/context.ts", out: "dist/services/context.js" }, + { in: "src/services/tracker.ts", out: "dist/services/tracker.js" }, ]; await Promise.all( diff --git a/src/cli.ts b/src/cli.ts index 699ae84..eb215c0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,12 +33,14 @@ const CODEX_CONFIG_TOML = join(CODEX_DIR, "config.toml"); const CODEX_HOOKS_JSON = join(CODEX_DIR, "hooks.json"); const SUPERMEMORY_HOOKS_DIR = join(CODEX_DIR, "supermemory"); const RECALL_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "recall.js"); +const TURN_CAPTURE_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js"); const FLUSH_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "flush.js"); const SESSION_START_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "session-start.js"); const CODEX_SKILLS_DIR = join(homedir(), ".codex", "skills"); -const RECALL_TIMEOUT_SECONDS = 90; -const FLUSH_TIMEOUT_SECONDS = 60; -const SESSION_START_TIMEOUT_SECONDS = 60; +const RECALL_TIMEOUT_SECONDS = 5; +const CAPTURE_TIMEOUT_SECONDS = 30; +const FLUSH_TIMEOUT_SECONDS = 30; +const SESSION_START_TIMEOUT_SECONDS = 30; // Skill metadata — single source of truth for install/uninstall/status. const SKILLS = [ @@ -135,6 +137,7 @@ interface HookEntry { command: string; timeout?: number; statusMessage?: string; + async?: boolean; } // Codex hooks.json schema: each event key maps to an array of MatcherGroup objects. @@ -186,6 +189,7 @@ function ensureHookRegistered( command: string, timeout: number, statusMessage: string, + background = false, ): void { const exists = groups.some((g) => g.hooks.some((h) => h.command === command)); if (exists) { @@ -194,12 +198,20 @@ function ensureHookRegistered( if (hook.command === command) { hook.timeout = timeout; hook.statusMessage = statusMessage; + if (background) hook.async = true; + else delete hook.async; } } } } else { const globalGroup = groups.find((g) => !g.matcher); - const entry: HookEntry = { type: "command", command, timeout, statusMessage }; + const entry: HookEntry = { + type: "command", + command, + timeout, + statusMessage, + ...(background ? { async: true } : {}), + }; if (globalGroup) { globalGroup.hooks.push(entry); } else { @@ -231,6 +243,7 @@ function mergeHooksJson(add: boolean) { if (add) { const recallCmd = `node ${RECALL_SCRIPT}`; + const turnCaptureCmd = `node ${TURN_CAPTURE_SCRIPT}`; const flushCmd = `node ${FLUSH_SCRIPT}`; const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; const oldCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture.js")}`; @@ -243,9 +256,17 @@ function mergeHooksJson(add: boolean) { "Loading memory profile...", ); - // Register UserPromptSubmit hook for optional per-prompt recall / turn capture + // Recall must stay synchronous because its output is injected. Turn capture + // is a separate background hook so it can never delay prompt handling. if (!hooks.UserPromptSubmit) hooks.UserPromptSubmit = []; ensureHookRegistered(hooks.UserPromptSubmit, recallCmd, RECALL_TIMEOUT_SECONDS, "Searching memories..."); + ensureHookRegistered( + hooks.UserPromptSubmit, + turnCaptureCmd, + CAPTURE_TIMEOUT_SECONDS, + "Saving turn to memory...", + true, + ); // Remove old capture.js Stop hook from previous installs if (hooks.Stop) { @@ -255,10 +276,17 @@ function mergeHooksJson(add: boolean) { // Register Stop hook for flush if (!hooks.Stop) hooks.Stop = []; - ensureHookRegistered(hooks.Stop, flushCmd, FLUSH_TIMEOUT_SECONDS, "Saving to memory..."); + ensureHookRegistered( + hooks.Stop, + flushCmd, + FLUSH_TIMEOUT_SECONDS, + "Saving to memory...", + true, + ); } else { // Remove our hooks from every MatcherGroup, then drop empty groups. const recallCmd = `node ${RECALL_SCRIPT}`; + const turnCaptureCmd = `node ${TURN_CAPTURE_SCRIPT}`; const flushCmd = `node ${FLUSH_SCRIPT}`; const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; const oldCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture.js")}`; @@ -268,7 +296,10 @@ function mergeHooksJson(add: boolean) { if (hooks.SessionStart.length === 0) delete hooks.SessionStart; } if (hooks.UserPromptSubmit) { - hooks.UserPromptSubmit = removeHookCommands(hooks.UserPromptSubmit, [recallCmd]); + hooks.UserPromptSubmit = removeHookCommands( + hooks.UserPromptSubmit, + [recallCmd, turnCaptureCmd], + ); if (hooks.UserPromptSubmit.length === 0) delete hooks.UserPromptSubmit; } if (hooks.Stop) { @@ -291,15 +322,22 @@ function install() { // Copy hook scripts const recallSrc = join(DIST_HOOKS_DIR, "recall.js"); + const turnCaptureSrc = join(DIST_HOOKS_DIR, "capture-turn.js"); const flushSrc = join(DIST_HOOKS_DIR, "flush.js"); const sessionStartSrc = join(DIST_HOOKS_DIR, "session-start.js"); - if (!existsSync(recallSrc) || !existsSync(flushSrc) || !existsSync(sessionStartSrc)) { + if ( + !existsSync(recallSrc) || + !existsSync(turnCaptureSrc) || + !existsSync(flushSrc) || + !existsSync(sessionStartSrc) + ) { console.error("Error: Hook scripts not found. Please reinstall the package."); process.exit(1); } copyFileSync(recallSrc, RECALL_SCRIPT); + copyFileSync(turnCaptureSrc, TURN_CAPTURE_SCRIPT); copyFileSync(flushSrc, FLUSH_SCRIPT); copyFileSync(sessionStartSrc, SESSION_START_SCRIPT); @@ -342,7 +380,7 @@ You now have: ${hadExistingConfig ? "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"} + : "Fresh install: direct relevant-memory recall plus session-start profile and turn-stop 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 @@ -398,6 +436,7 @@ function status() { const hooksInstalled = existsSync(RECALL_SCRIPT) && + existsSync(TURN_CAPTURE_SCRIPT) && existsSync(FLUSH_SCRIPT) && existsSync(SESSION_START_SCRIPT); const hooksJsonExists = existsSync(CODEX_HOOKS_JSON); @@ -408,18 +447,27 @@ function status() { try { const hooks = normalizeHookEvents(JSON.parse(readFileSync(CODEX_HOOKS_JSON, "utf-8"))); const recallCmd = `node ${RECALL_SCRIPT}`; + const turnCaptureCmd = `node ${TURN_CAPTURE_SCRIPT}`; const flushCmd = `node ${FLUSH_SCRIPT}`; const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; const recallRegistered = hooks.UserPromptSubmit?.some((g: MatcherGroup) => g.hooks.some((h: HookEntry) => h.command === recallCmd) ); + const turnCaptureRegistered = hooks.UserPromptSubmit?.some((g: MatcherGroup) => + g.hooks.some((h: HookEntry) => h.command === turnCaptureCmd && h.async === true) + ); const flushRegistered = hooks.Stop?.some((g: MatcherGroup) => - g.hooks.some((h: HookEntry) => h.command === flushCmd) + g.hooks.some((h: HookEntry) => h.command === flushCmd && h.async === true) ); const sessionStartRegistered = hooks.SessionStart?.some((g: MatcherGroup) => g.hooks.some((h: HookEntry) => h.command === sessionStartCmd) ); - hooksEnabled = !!(recallRegistered && flushRegistered && sessionStartRegistered); + hooksEnabled = !!( + recallRegistered && + turnCaptureRegistered && + flushRegistered && + sessionStartRegistered + ); } catch { // ignore } diff --git a/src/config.ts b/src/config.ts index 1e88f8c..997a726 100644 --- a/src/config.ts +++ b/src/config.ts @@ -298,7 +298,7 @@ export function getRecallModeSummary(): string { 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`; + return `unified: session-start profile + capture every ${CONFIG.captureEveryNTurns} turns + turn-stop flush`; } - return "unified: session-start profile + session-end flush only"; + return "unified: session-start profile + turn-stop flush only"; } diff --git a/src/hooks/capture-turn.ts b/src/hooks/capture-turn.ts new file mode 100644 index 0000000..6fec553 --- /dev/null +++ b/src/hooks/capture-turn.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { CONFIG, isConfigured } from "../config.js"; +import { SupermemoryClient } from "../services/client.js"; +import { captureEntries, resolveTranscriptPath } from "../services/capture.js"; +import { getSessionId } from "../services/session.js"; +import { getTags } from "../services/tags.js"; +import { log } from "../services/logger.js"; + +interface CodexPromptPayload { + session_id?: string; + transcript_path?: string | null; + cwd?: string; + [key: string]: unknown; +} + +async function main(): Promise { + if (!isConfigured() || CONFIG.captureEveryNTurns <= 0) return; + + let payload: CodexPromptPayload; + try { + payload = JSON.parse(readFileSync(0, "utf-8")) as CodexPromptPayload; + } catch { + return; + } + + const cwd = payload.cwd || process.cwd(); + const tags = getTags(cwd); + const sessionId = getSessionId(payload.session_id, tags.project); + const transcriptPath = resolveTranscriptPath(payload.transcript_path, sessionId); + + log("capture-turn: start", { sessionId, transcriptPath }); + await captureEntries( + "recall", + new SupermemoryClient(), + sessionId, + transcriptPath, + tags, + { + requireMinEntries: 2, + requireMinTurns: CONFIG.captureEveryNTurns, + }, + ); +} + +main() + .catch(() => {}) + .finally(() => process.exit(0)); diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index 9fc53e9..e2ebefb 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -1,19 +1,15 @@ -import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; import { homedir } from "node:os"; -import { isConfigured, CONFIG, reloadApiKey, getContainerCatalog } from "../config.js"; -import { SupermemoryClient } from "../services/client.js"; +import { isConfigured, CONFIG, getContainerCatalog } from "../config.js"; import { getTags } from "../services/tags.js"; import { formatCombinedContext } from "../services/context.js"; import { log } from "../services/logger.js"; -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"); interface CodexHookPayload { @@ -25,14 +21,19 @@ interface CodexHookPayload { [key: string]: unknown; } -function exitWithContext(additionalContext: string): never { - if (additionalContext) { +function exitWithContext(additionalContext: string, systemMessage?: string): never { + if (additionalContext || systemMessage) { process.stdout.write( JSON.stringify({ - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext, - }, + ...(systemMessage ? { systemMessage } : {}), + ...(additionalContext + ? { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext, + }, + } + : {}), }) ); } @@ -53,38 +54,12 @@ async function main() { exitWithContext(""); } - const alreadyAttempted = existsSync(AUTH_ATTEMPTED_FILE); - - if (!alreadyAttempted) { - try { - mkdirSync(dirname(AUTH_ATTEMPTED_FILE), { recursive: true }); - writeFileSync(AUTH_ATTEMPTED_FILE, new Date().toISOString()); - } catch {} - - try { - log("recall: no API key, starting browser auth flow"); - await startAuthFlow(); - reloadApiKey(); - try { unlinkSync(AUTH_ATTEMPTED_FILE); } catch {} - log("recall: auth flow completed"); - } catch (authErr) { - const isTimeout = - authErr instanceof Error && authErr.message === "AUTH_TIMEOUT"; - exitWithContext( - "[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" + - (isTimeout - ? "Authentication timed out. Please complete login in the browser.\n" - : "Authentication failed.\n") + - `If the browser did not open, visit: ${AUTH_BASE_URL}\n` + - "Run /supermemory-login to try again, or set SUPERMEMORY_CODEX_API_KEY manually." - ); - } - } else { - exitWithContext( - "[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" + - "Run /supermemory-login to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." - ); - } + // UserPromptSubmit has a 5s backstop and must never launch the interactive + // browser flow. SessionStart and /supermemory-login own authentication. + exitWithContext( + "[SUPERMEMORY] Memory is installed but NOT active — missing API key.\n" + + "Run /supermemory-login to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." + ); } let payload: CodexHookPayload = {}; @@ -110,16 +85,6 @@ async function main() { recallMode: CONFIG.recallMode, }); - const transcriptPath = resolveTranscriptPath(payload.transcript_path, sessionId); - const client = new SupermemoryClient(); - - if (CONFIG.captureEveryNTurns > 0) { - await captureEntries("recall", client, sessionId, transcriptPath, tags, { - requireMinEntries: 2, - requireMinTurns: CONFIG.captureEveryNTurns, - }); - } - if (CONFIG.recallMode === "off") { exitWithContext(""); } @@ -136,6 +101,10 @@ async function main() { prepareRecallQuery(query), ); + if (!profileResult.success) { + exitWithContext("", "◪ supermemory · recall unavailable; continuing without recalled context"); + } + const seen = getSeenFacts(sessionId); const { text, newFacts } = formatCombinedContext( profileResult, @@ -175,7 +144,7 @@ async function main() { } } catch (error) { log("recall: error", { error: String(error) }); - exitWithContext(""); + exitWithContext("", "◪ supermemory · recall unavailable; continuing without recalled context"); } } diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 9cd5152..4a2c986 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -2,7 +2,7 @@ import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from " import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "../config.js"; -import { SupermemoryClient } from "../services/client.js"; +import { HOOK_API_TIMEOUT_MS, SupermemoryClient } from "../services/client.js"; import { getTags } from "../services/tags.js"; import { formatCombinedContext } from "../services/context.js"; import { log } from "../services/logger.js"; @@ -13,6 +13,17 @@ import { checkNpmUpdate, formatUpdateNotice } from "../services/version-check.js const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted"); const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out"); const UPDATE_COMMAND = "npx codex-supermemory@latest install"; +const SESSION_START_HOOK_TIMEOUT_MS = 30_000; +const SESSION_START_AUTH_TIMEOUT_MS = 25_000; + +function getSessionStartAuthTimeoutMs(): number { + const configured = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); + const requested = Number.isFinite(configured) && configured > 0 + ? configured + : SESSION_START_AUTH_TIMEOUT_MS; + // Leave room for the hard-capped profile/update requests and hook teardown. + return Math.min(requested, SESSION_START_HOOK_TIMEOUT_MS - HOOK_API_TIMEOUT_MS - 2_000); +} interface CodexHookPayload { session_id?: string; @@ -20,14 +31,19 @@ interface CodexHookPayload { [key: string]: unknown; } -function exitWithContext(additionalContext: string): never { - if (additionalContext) { +function exitWithContext(additionalContext: string, systemMessage?: string): never { + if (additionalContext || systemMessage) { process.stdout.write( JSON.stringify({ - hookSpecificOutput: { - hookEventName: "SessionStart", - additionalContext, - }, + ...(systemMessage ? { systemMessage } : {}), + ...(additionalContext + ? { + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext, + }, + } + : {}), }) ); } @@ -60,7 +76,7 @@ async function main() { } catch {} try { - await startAuthFlow(); + await startAuthFlow(getSessionStartAuthTimeoutMs()); reloadApiKey(); try { unlinkSync(AUTH_ATTEMPTED_FILE); } catch {} } catch { @@ -95,7 +111,11 @@ async function main() { log("session-start: begin", { sessionId, tags }); try { - const profileResult = await client.getProfileMany(tags.allReads); + const profileResult = await client.getProfileMany( + tags.allReads, + undefined, + { timeoutMs: HOOK_API_TIMEOUT_MS }, + ); const seen = getSeenFacts(sessionId); const { text, newFacts } = formatCombinedContext( { @@ -108,6 +128,13 @@ async function main() { seen, ); + if (!profileResult.success) { + exitWithContext( + await updateCheck ?? "", + "◪ supermemory · profile unavailable; continuing without recalled context", + ); + } + if (newFacts.length > 0) { addSeenFacts(sessionId, newFacts); const updateNotice = await updateCheck; @@ -120,10 +147,16 @@ async function main() { exitWithContext(await updateCheck ?? ""); } catch (error) { log("session-start: error", { error: String(error) }); - exitWithContext(await updateCheck ?? ""); + exitWithContext( + await updateCheck ?? "", + "◪ supermemory · profile unavailable; continuing without recalled context", + ); } } main().catch(() => { - exitWithContext(""); + exitWithContext( + "", + "◪ supermemory · profile unavailable; continuing without recalled context", + ); }); diff --git a/src/services/auth.ts b/src/services/auth.ts index 37e45cc..75a925a 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -17,7 +17,11 @@ export interface Credentials { const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/agent-connect"; -const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000; +const configuredAuthTimeout = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT); +const AUTH_TIMEOUT = + Number.isFinite(configuredAuthTimeout) && configuredAuthTimeout > 0 + ? configuredAuthTimeout + : 5 * 60_000; const AUTH_SUCCESS_HTML = ` Connected - Supermemory