Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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. |
Expand Down
5 changes: 4 additions & 1 deletion build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
})),
Expand All @@ -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(
Expand Down
70 changes: 59 additions & 11 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")}`;
Expand All @@ -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) {
Expand All @@ -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")}`;
Expand All @@ -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) {
Expand All @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
47 changes: 47 additions & 0 deletions src/hooks/capture-turn.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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));
Loading