From 8568aaa40ebb24b6236ad9622b8615b4b150de9f Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Tue, 25 Aug 2026 19:41:35 +0530 Subject: [PATCH] Adopt hook and hosted MCP architecture --- README.md | 111 ++------ build.mjs | 7 +- src/cli.ts | 179 +++++++++---- src/config.ts | 76 +----- src/hooks/capture-turn.ts | 47 ---- src/hooks/flush.ts | 7 +- src/hooks/mcp-proxy.ts | 114 ++++++++ src/hooks/recall-approve.ts | 52 ++++ src/hooks/recall.ts | 89 ++++--- src/hooks/session-start.ts | 73 +++--- src/services/capture.ts | 31 ++- src/services/transcript.ts | 105 +++++--- src/skills/add-memory.ts | 52 ---- src/skills/forget-memory.ts | 92 ------- src/skills/login.ts | 53 ---- src/skills/logout.ts | 66 ----- src/skills/profile-memory.ts | 50 ---- src/skills/save-memory.ts | 106 -------- src/skills/search-memory.ts | 150 ----------- src/skills/status.ts | 62 +++-- src/skills/supermemory-add/SKILL.md | 15 -- src/skills/supermemory-forget/SKILL.md | 47 ---- src/skills/supermemory-login/SKILL.md | 27 -- src/skills/supermemory-logout/SKILL.md | 23 -- src/skills/supermemory-profile/SKILL.md | 14 - src/skills/supermemory-save/SKILL.md | 64 ----- src/skills/supermemory-search/SKILL.md | 51 ---- src/skills/supermemory-status/SKILL.md | 5 +- test/unit.mjs | 331 +++++++++--------------- 29 files changed, 669 insertions(+), 1430 deletions(-) delete mode 100644 src/hooks/capture-turn.ts create mode 100644 src/hooks/mcp-proxy.ts create mode 100644 src/hooks/recall-approve.ts delete mode 100644 src/skills/add-memory.ts delete mode 100644 src/skills/forget-memory.ts delete mode 100644 src/skills/login.ts delete mode 100644 src/skills/logout.ts delete mode 100644 src/skills/profile-memory.ts delete mode 100644 src/skills/save-memory.ts delete mode 100644 src/skills/search-memory.ts delete mode 100644 src/skills/supermemory-add/SKILL.md delete mode 100644 src/skills/supermemory-forget/SKILL.md delete mode 100644 src/skills/supermemory-login/SKILL.md delete mode 100644 src/skills/supermemory-logout/SKILL.md delete mode 100644 src/skills/supermemory-profile/SKILL.md delete mode 100644 src/skills/supermemory-save/SKILL.md delete mode 100644 src/skills/supermemory-search/SKILL.md diff --git a/README.md b/README.md index 6dbb90a..61172f6 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,12 @@ and the lessons learned across every project β€” automatically. ## Features - 🧠 **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 - flushed after completed turns via the `Stop` hook. + the `UserPromptSubmit` hook, with visible recall counts and a 3-second network cap. +- πŸ”Ž **Hosted MCP tools** β€” deeper search and explicit memory operations use + `mcp.supermemory.ai` through the same credentials as the hooks. +- πŸ’Ύ **Automatic capture** β€” completed turns are saved in the background 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`, - `code_style`). The AI automatically picks the right container based on your instructions - when saving, searching, or forgetting memories. - 🏷️ **Personal + project routing** β€” `sm_scope` metadata keeps automatic/personal memories distinguishable from explicit project knowledge in the shared container. - **Entity-aware extraction** - the shared container uses one coding-agent context @@ -27,9 +25,8 @@ and the lessons learned across every project β€” automatically. `~/.codex/hooks.json` for you. - πŸͺΆ **No runtime deps in hooks** β€” the hook scripts are pre-bundled with esbuild for fast cold starts. -- πŸ”§ **Fallback skills** β€” explicit `/supermemory-search`, `/supermemory-add`, `/supermemory-save`, - `/supermemory-forget`, `/supermemory-status`, and `/supermemory-logout` commands available when hooks - don't cover your use case. +- πŸ”§ **Focused status skill** β€” `$supermemory-status` checks authentication and connectivity; + memory operations come from MCP instead of separate command skills. ## Quick start @@ -42,36 +39,30 @@ and the lessons learned across every project β€” automatically. 2. **Start Codex CLI.** On your first prompt, a browser window will open to authenticate with Supermemory automatically. - Alternatively, authenticate manually: - - Use `$supermemory-login` inside Codex - - Or set `export SUPERMEMORY_CODEX_API_KEY="sm_..."` in your shell profile + Alternatively, set `export SUPERMEMORY_CODEX_API_KEY="sm_..."` in your shell profile. 3. **That's it β€” memory is active.** ## How it works -Codex CLI supports a hooks system that lets external scripts run at specific -lifecycle events. `codex-supermemory` registers four hooks: +Codex CLI supports hooks and MCP servers. `codex-supermemory` registers four hooks: | Hook | Event | What it does | | ----------------- | ---------------------- | ------------------------------------------------------------------- | -| `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. | +| `recall` | `UserPromptSubmit` | Searches Supermemory directly, injects fresh relevant memories, and prints `β—ͺ supermemory Β· recalled …`. | +| `recall-approve` | `PreToolUse` | Prints the MCP search query and auto-allows read-only Supermemory tools. | +| `flush` | `Stop` | Captures completed turns in the background. | | `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 -current turn stops. +Prompt recall and automatic capture call the Supermemory API directly. Deeper model-initiated +search, add, list, and forget operations go through the hosted MCP server. The installer: -- Enables the `codex_hooks` feature flag in `~/.codex/config.toml` +- Registers the `supermemory` MCP server in `~/.codex/config.toml` - Registers the hooks in `~/.codex/hooks.json` - Copies pre-bundled hook scripts to `~/.codex/supermemory/` -- Installs skills to `~/.codex/skills/` +- Installs only the `supermemory-status` skill to `~/.codex/skills/` The hooks are tolerant: if Supermemory is unreachable, the API key is missing, or anything else fails, they exit cleanly without breaking your Codex session. @@ -125,13 +116,10 @@ Drop this file in to override defaults: | `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). | +| `autoSaveEveryTurns` | `number` | `3` | Deprecated compatibility setting; completed turns are captured by `Stop`. | | `signalExtraction` | `boolean` | `false` | Enable signal-based filtering (only capture turns with keywords like "prefer", "decided"). | | `signalKeywords` | `string[]` | (defaults) | Keywords that trigger signal extraction. | | `signalTurnsBefore` | `number` | `3` | Include N turns before a signal for context. | -| `enableCustomContainers` | `boolean` | `false` | Enable AI-driven routing to custom containers. | -| `customContainers` | `array` | `[]` | Custom containers with `tag` and `description` (see below). | -| `customContainerInstructions` | `string` | `""` | Free-text instructions for the AI on how to route memories to containers. Project tags combine the sanitized repository name with a normalized Git-remote hash. Linked worktrees and clones of the same remote therefore share one container; @@ -154,73 +142,16 @@ but may miss some context. Disabled by default β€” all turns are captured. ## Commands ```bash -npx codex-supermemory install # set up hooks + config + skills +npx codex-supermemory install # set up hooks + MCP + status skill npx codex-supermemory uninstall # remove hooks + config (keeps your memories) npx codex-supermemory status # show current install status ``` -## Skills (fallback commands) +## Status -These Codex skills are available as explicit commands when you need more control. -The search, save, and forget skills support `--container ` to target a specific custom container. - -| Skill | Usage | Description | -| ---------------------- | ----------------------------------------------------------- | ---------------------------------------- | -| `/supermemory-search` | `/supermemory-search [--container ] ` | Search memories manually. | -| `/supermemory-add` | `/supermemory-add ` | Add a personal memory for this project. | -| `/supermemory-save` | `/supermemory-save [--container ] ` | Save a specific memory explicitly. | -| `/supermemory-forget` | `/supermemory-forget [--container ] ` | Remove a memory. | -| `/supermemory-profile` | `/supermemory-profile` | Show remembered profile facts. | -| `/supermemory-status` | `/supermemory-status` | Show connection and account status. | -| `/supermemory-login` | `/supermemory-login` | Re-authenticate with Supermemory. | -| `/supermemory-logout` | `/supermemory-logout` | Remove saved local credentials. | - -Skills are fallback commands β€” the hooks handle most use cases automatically. - -## Custom Container Tags - -Custom container tags let you organize memories into separate buckets (e.g., `work`, -`personal`, `code_style`). The AI reads the container descriptions from your config -and automatically picks the right container when saving memories. - -### Setup - -Add these fields to `~/.codex/supermemory.json`: - -```json -{ - "enableCustomContainers": true, - "customContainers": [ - { "tag": "personal", "description": "Personal life β€” family, health, hobbies, routines" }, - { "tag": "work", "description": "Work-related β€” projects, deadlines, meetings, colleagues" }, - { "tag": "code_style", "description": "Coding preferences β€” languages, tools, patterns, conventions" } - ], - "customContainerInstructions": "Route coding preferences to code_style. Personal topics to personal. Default to project container for ambiguous content." -} -``` - -### How it works - -1. You define containers with a `tag` (identifier) and a `description` (plain English - explaining what belongs there). -2. On every prompt, the container catalog is injected into the AI's context so it knows - what containers are available. -3. When the AI saves a memory (via `/supermemory-save`), it picks the best matching - container based on the descriptions and uses `--container `. -4. When searching or forgetting, the AI can also target specific containers. -5. Automatic capture (background saving) always goes to the default project/user - containers β€” only explicit saves get routed to custom containers. - -Each container tag automatically becomes a **Space** on the -[Supermemory dashboard](https://app.supermemory.ai), so you can view and manage -memories organized by category. - -### Container config reference - -| Field | Type | Description | -| ------------------ | -------- | -------------------------------------------------- | -| `tag` | `string` | Unique identifier for the container (e.g. `work`). | -| `description` | `string` | Plain English description for AI routing. | +Run `$supermemory-status` inside Codex to check the saved credential, API reachability, +active project container, and account details. Browser authentication is automatic on +`SessionStart`; there is no separate login skill. ## Privacy diff --git a/build.mjs b/build.mjs index 7743032..54ea048 100644 --- a/build.mjs +++ b/build.mjs @@ -24,11 +24,11 @@ const sharedConfig = { const executableEntries = [ { in: "src/cli.ts", out: "dist/cli.js" }, - ...["recall", "capture-turn", "flush", "session-start"].map((n) => ({ + ...["recall", "recall-approve", "mcp-proxy", "flush", "session-start"].map((n) => ({ in: `src/hooks/${n}.ts`, out: `dist/hooks/${n}.js`, })), - ...["search-memory", "add-memory", "save-memory", "forget-memory", "profile-memory", "status", "login", "logout"].map((n) => ({ + ...["status"].map((n) => ({ in: `src/skills/${n}.ts`, out: `dist/skills/${n}.js`, })), @@ -45,6 +45,7 @@ const libraryEntries = [ { 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/capture.ts", out: "dist/services/capture.js" }, { in: "src/services/context.ts", out: "dist/services/context.js" }, { in: "src/services/tracker.ts", out: "dist/services/tracker.js" }, ]; @@ -70,7 +71,7 @@ await Promise.all( ); // Copy SKILL.md files to dist -for (const skillName of ["supermemory-search", "supermemory-add", "supermemory-save", "supermemory-forget", "supermemory-profile", "supermemory-status", "supermemory-login", "supermemory-logout"]) { +for (const skillName of ["supermemory-status"]) { mkdirSync(`dist/skills/${skillName}`, { recursive: true }); copyFileSync( `src/skills/${skillName}/SKILL.md`, diff --git a/src/cli.ts b/src/cli.ts index eb215c0..1f77969 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,31 +32,45 @@ const CODEX_DIR = join(homedir(), ".codex"); 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 LOGGED_OUT_FILE = join(SUPERMEMORY_HOOKS_DIR, ".logged-out"); const RECALL_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "recall.js"); -const TURN_CAPTURE_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js"); +const RECALL_APPROVE_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "recall-approve.js"); +const MCP_PROXY_SCRIPT = join(SUPERMEMORY_HOOKS_DIR, "mcp-proxy.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 = 5; -const CAPTURE_TIMEOUT_SECONDS = 30; +const RECALL_APPROVE_TIMEOUT_SECONDS = 5; const FLUSH_TIMEOUT_SECONDS = 30; const SESSION_START_TIMEOUT_SECONDS = 30; +const SUPERMEMORY_MCP_MATCHER = "^mcp__supermemory__"; // Skill metadata β€” single source of truth for install/uninstall/status. const SKILLS = [ - { name: "supermemory-search", script: "search-memory.js" }, - { name: "supermemory-add", script: "add-memory.js" }, - { name: "supermemory-save", script: "save-memory.js" }, - { name: "supermemory-forget", script: "forget-memory.js" }, { name: "supermemory-status", script: "status.js" }, - { name: "supermemory-profile", script: "profile-memory.js" }, - { name: "supermemory-login", script: "login.js" }, - { name: "supermemory-logout", script: "logout.js" }, ] as const; const LEGACY_SUPERMEMORY_SCRIPTS = [ "capture.js", + "capture-turn.js", "tags.js", + "search-memory.js", + "add-memory.js", + "save-memory.js", + "forget-memory.js", + "profile-memory.js", + "login.js", + "logout.js", +] as const; + +const LEGACY_SKILLS = [ + "supermemory-search", + "supermemory-add", + "supermemory-save", + "supermemory-forget", + "supermemory-profile", + "supermemory-login", + "supermemory-logout", ] as const; const SCRIPT_DIR = getScriptDir(); @@ -118,17 +132,36 @@ function mergeConfigToml(enable: boolean) { const config = readConfigToml(); - // Toggle the codex_hooks feature flag. - if (!config.features) config.features = {}; - const features = config.features as Record; - if (enable) { - features.codex_hooks = true; - } else { + // Hooks are enabled by default in current Codex. Remove only the deprecated + // alias written by older codex-supermemory releases; preserve any explicit + // user choice for the canonical `features.hooks` key. + const features = config.features as Record | undefined; + if (features) { delete features.codex_hooks; - // Drop the empty [features] section to keep config.toml clean. if (Object.keys(features).length === 0) delete config.features; } + if (enable) { + if (!config.mcp_servers) config.mcp_servers = {}; + const mcpServers = config.mcp_servers as Record; + mcpServers.supermemory = { + command: "node", + args: [MCP_PROXY_SCRIPT], + }; + } else { + const mcpServers = config.mcp_servers as Record | undefined; + const server = mcpServers?.supermemory as Record | undefined; + if ( + server?.command === "node" && + Array.isArray(server.args) && + server.args.length === 1 && + server.args[0] === MCP_PROXY_SCRIPT + ) { + if (mcpServers) delete mcpServers.supermemory; + if (mcpServers && Object.keys(mcpServers).length === 0) delete config.mcp_servers; + } + } + writeFileSync(CODEX_CONFIG_TOML, TOML.stringify(config as TOML.JsonMap)); } @@ -169,7 +202,7 @@ function normalizeHookEvents(raw: unknown): HookEvents { ? maybeWrapped.hooks : (maybeWrapped as HookEvents); - for (const key of ["UserPromptSubmit", "Stop"] as const) { + for (const key of ["SessionStart", "UserPromptSubmit", "PreToolUse", "Stop"] as const) { const val = events[key]; if (val !== undefined && !Array.isArray(val)) { events[key] = [val as unknown as MatcherGroup]; @@ -190,6 +223,7 @@ function ensureHookRegistered( timeout: number, statusMessage: string, background = false, + matcher?: string, ): void { const exists = groups.some((g) => g.hooks.some((h) => h.command === command)); if (exists) { @@ -204,7 +238,9 @@ function ensureHookRegistered( } } } else { - const globalGroup = groups.find((g) => !g.matcher); + const matchingGroup = groups.find((g) => + matcher ? g.matcher === matcher : !g.matcher + ); const entry: HookEntry = { type: "command", command, @@ -212,10 +248,10 @@ function ensureHookRegistered( statusMessage, ...(background ? { async: true } : {}), }; - if (globalGroup) { - globalGroup.hooks.push(entry); + if (matchingGroup) { + matchingGroup.hooks.push(entry); } else { - groups.push({ hooks: [entry] }); + groups.push({ ...(matcher ? { matcher } : {}), hooks: [entry] }); } } } @@ -243,10 +279,11 @@ function mergeHooksJson(add: boolean) { if (add) { const recallCmd = `node ${RECALL_SCRIPT}`; - const turnCaptureCmd = `node ${TURN_CAPTURE_SCRIPT}`; + const recallApproveCmd = `node ${RECALL_APPROVE_SCRIPT}`; const flushCmd = `node ${FLUSH_SCRIPT}`; const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; const oldCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture.js")}`; + const oldTurnCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js")}`; if (!hooks.SessionStart) hooks.SessionStart = []; ensureHookRegistered( @@ -256,16 +293,24 @@ function mergeHooksJson(add: boolean) { "Loading memory profile...", ); - // Recall must stay synchronous because its output is injected. Turn capture - // is a separate background hook so it can never delay prompt handling. + // Recall must stay synchronous because its output is injected. if (!hooks.UserPromptSubmit) hooks.UserPromptSubmit = []; ensureHookRegistered(hooks.UserPromptSubmit, recallCmd, RECALL_TIMEOUT_SECONDS, "Searching memories..."); - ensureHookRegistered( + + // Remove the old per-prompt capture hook. Stop now owns automatic capture. + hooks.UserPromptSubmit = removeHookCommands( hooks.UserPromptSubmit, - turnCaptureCmd, - CAPTURE_TIMEOUT_SECONDS, - "Saving turn to memory...", - true, + [oldTurnCaptureCmd], + ); + + if (!hooks.PreToolUse) hooks.PreToolUse = []; + ensureHookRegistered( + hooks.PreToolUse, + recallApproveCmd, + RECALL_APPROVE_TIMEOUT_SECONDS, + "Checking Supermemory recall...", + false, + SUPERMEMORY_MCP_MATCHER, ); // Remove old capture.js Stop hook from previous installs @@ -286,10 +331,11 @@ function mergeHooksJson(add: boolean) { } else { // Remove our hooks from every MatcherGroup, then drop empty groups. const recallCmd = `node ${RECALL_SCRIPT}`; - const turnCaptureCmd = `node ${TURN_CAPTURE_SCRIPT}`; + const recallApproveCmd = `node ${RECALL_APPROVE_SCRIPT}`; const flushCmd = `node ${FLUSH_SCRIPT}`; const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; const oldCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture.js")}`; + const oldTurnCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js")}`; if (hooks.SessionStart) { hooks.SessionStart = removeHookCommands(hooks.SessionStart, [sessionStartCmd]); @@ -298,10 +344,14 @@ function mergeHooksJson(add: boolean) { if (hooks.UserPromptSubmit) { hooks.UserPromptSubmit = removeHookCommands( hooks.UserPromptSubmit, - [recallCmd, turnCaptureCmd], + [recallCmd, oldTurnCaptureCmd], ); if (hooks.UserPromptSubmit.length === 0) delete hooks.UserPromptSubmit; } + if (hooks.PreToolUse) { + hooks.PreToolUse = removeHookCommands(hooks.PreToolUse, [recallApproveCmd]); + if (hooks.PreToolUse.length === 0) delete hooks.PreToolUse; + } if (hooks.Stop) { hooks.Stop = removeHookCommands(hooks.Stop, [flushCmd, oldCaptureCmd]); if (hooks.Stop.length === 0) delete hooks.Stop; @@ -322,13 +372,15 @@ function install() { // Copy hook scripts const recallSrc = join(DIST_HOOKS_DIR, "recall.js"); - const turnCaptureSrc = join(DIST_HOOKS_DIR, "capture-turn.js"); + const recallApproveSrc = join(DIST_HOOKS_DIR, "recall-approve.js"); + const mcpProxySrc = join(DIST_HOOKS_DIR, "mcp-proxy.js"); const flushSrc = join(DIST_HOOKS_DIR, "flush.js"); const sessionStartSrc = join(DIST_HOOKS_DIR, "session-start.js"); if ( !existsSync(recallSrc) || - !existsSync(turnCaptureSrc) || + !existsSync(recallApproveSrc) || + !existsSync(mcpProxySrc) || !existsSync(flushSrc) || !existsSync(sessionStartSrc) ) { @@ -337,7 +389,8 @@ function install() { } copyFileSync(recallSrc, RECALL_SCRIPT); - copyFileSync(turnCaptureSrc, TURN_CAPTURE_SCRIPT); + copyFileSync(recallApproveSrc, RECALL_APPROVE_SCRIPT); + copyFileSync(mcpProxySrc, MCP_PROXY_SCRIPT); copyFileSync(flushSrc, FLUSH_SCRIPT); copyFileSync(sessionStartSrc, SESSION_START_SCRIPT); @@ -346,6 +399,13 @@ function install() { const oldScript = join(SUPERMEMORY_HOOKS_DIR, script); if (existsSync(oldScript)) rmSync(oldScript); } + if (existsSync(LOGGED_OUT_FILE)) rmSync(LOGGED_OUT_FILE); + + // Remove command skills retired by the hosted MCP architecture. + for (const name of LEGACY_SKILLS) { + const skillDir = join(CODEX_SKILLS_DIR, name); + if (existsSync(skillDir)) rmSync(skillDir, { recursive: true, force: true }); + } // Copy skill scripts and SKILL.md files for (const { name, script } of SKILLS) { @@ -360,12 +420,12 @@ function install() { join(skillDir, "SKILL.md") ); } - console.log(`βœ“ Installed hook and skill scripts to ${SUPERMEMORY_HOOKS_DIR}`); - console.log(`βœ“ Installed skills to ${CODEX_SKILLS_DIR}`); + console.log(`βœ“ Installed hooks and MCP proxy to ${SUPERMEMORY_HOOKS_DIR}`); + console.log(`βœ“ Installed the supermemory-status skill to ${CODEX_SKILLS_DIR}`); - // Merge config.toml (hooks feature flag) + // Merge config.toml (hosted MCP server) mergeConfigToml(true); - console.log(`βœ“ Enabled codex_hooks in ${CODEX_CONFIG_TOML}`); + console.log(`βœ“ Registered the Supermemory MCP server in ${CODEX_CONFIG_TOML}`); // Merge hooks.json mergeHooksJson(true); @@ -375,8 +435,9 @@ function install() { Installation complete! You now have: - β€’ Session-start profile recall (${getRecallModeSummary()}) - β€’ Explicit memory β€” supermemory-search, supermemory-add, supermemory-save, supermemory-forget, supermemory-profile, supermemory-status, supermemory-login, and supermemory-logout skills + β€’ Automatic session and prompt recall (${getRecallModeSummary()}) + β€’ Hosted Supermemory MCP tools for deeper search and explicit memory operations + β€’ The supermemory-status skill for connection diagnostics ${hadExistingConfig ? "Existing recall/capture preferences were preserved in ~/.codex/supermemory.json.\nSet recallMode to direct, off, or advisory to change recall behavior.\n" @@ -386,9 +447,7 @@ Next steps: 1. Start Codex β€” on your first prompt, a browser window will open to authenticate with Supermemory automatically. - Or authenticate manually: - /supermemory-login (inside Codex) - export SUPERMEMORY_CODEX_API_KEY="sm_..." (in your shell profile) + Or set SUPERMEMORY_CODEX_API_KEY="sm_..." in your shell profile. 2. Get an API key at: https://app.supermemory.ai/?view=integrations (if needed) @@ -405,7 +464,7 @@ function uninstall() { console.log(`βœ“ Removed hooks from ${CODEX_HOOKS_JSON}`); mergeConfigToml(false); - console.log(`βœ“ Disabled codex_hooks in ${CODEX_CONFIG_TOML}`); + console.log(`βœ“ Removed the Supermemory MCP server from ${CODEX_CONFIG_TOML}`); if (existsSync(SUPERMEMORY_HOOKS_DIR)) { rmSync(SUPERMEMORY_HOOKS_DIR, { recursive: true, force: true }); @@ -436,7 +495,8 @@ function status() { const hooksInstalled = existsSync(RECALL_SCRIPT) && - existsSync(TURN_CAPTURE_SCRIPT) && + existsSync(RECALL_APPROVE_SCRIPT) && + existsSync(MCP_PROXY_SCRIPT) && existsSync(FLUSH_SCRIPT) && existsSync(SESSION_START_SCRIPT); const hooksJsonExists = existsSync(CODEX_HOOKS_JSON); @@ -447,14 +507,15 @@ 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 recallApproveCmd = `node ${RECALL_APPROVE_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 recallApproveRegistered = hooks.PreToolUse?.some((g: MatcherGroup) => + g.matcher === SUPERMEMORY_MCP_MATCHER && + g.hooks.some((h: HookEntry) => h.command === recallApproveCmd) ); const flushRegistered = hooks.Stop?.some((g: MatcherGroup) => g.hooks.some((h: HookEntry) => h.command === flushCmd && h.async === true) @@ -464,7 +525,7 @@ function status() { ); hooksEnabled = !!( recallRegistered && - turnCaptureRegistered && + recallApproveRegistered && flushRegistered && sessionStartRegistered ); @@ -473,19 +534,33 @@ function status() { } } - const skillsInstalled = SKILLS.every(({ name }) => + const statusSkillInstalled = SKILLS.every(({ name }) => existsSync(join(CODEX_SKILLS_DIR, name, "SKILL.md")) ); + let mcpInstalled = false; + if (configTomlExists) { + try { + const config = readConfigToml(); + const server = (config.mcp_servers as Record | undefined) + ?.supermemory as Record | undefined; + mcpInstalled = server?.command === "node" && + Array.isArray(server.args) && + server.args.length === 1 && + server.args[0] === MCP_PROXY_SCRIPT; + } catch {} + } + console.log("codex-supermemory status:\n"); console.log(` API key: ${apiKey ? `βœ“ set (${apiKeySource})` : "βœ— not set"}`); console.log(` Recall mode: ${getRecallModeSummary()}`); console.log(` Hook scripts: ${hooksInstalled ? `βœ“ installed at ${SUPERMEMORY_HOOKS_DIR}` : "βœ— not installed"}`); console.log(` hooks.json: ${hooksEnabled ? "βœ“ registered (implicit memory)" : "βœ— not registered"}`); - console.log(` Skills: ${skillsInstalled ? `βœ“ installed (${SKILLS.map(s => s.name).join(", ")})` : "βœ— not installed"}`); + console.log(` MCP server: ${mcpInstalled ? "βœ“ registered (hosted tools via local proxy)" : "βœ— not registered"}`); + console.log(` Status skill: ${statusSkillInstalled ? "βœ“ installed" : "βœ— not installed"}`); console.log(` config.toml: ${configTomlExists ? "βœ“ exists" : "βœ— not found"}`); - if (!apiKey || !hooksInstalled || !hooksEnabled || !skillsInstalled) { + if (!apiKey || !hooksInstalled || !hooksEnabled || !mcpInstalled || !statusSkillInstalled) { console.log("\nRun `npx codex-supermemory install` to set up."); } else { console.log("\nAll good! Memory is active."); diff --git a/src/config.ts b/src/config.ts index 997a726..7c93dd3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,11 +8,6 @@ export { PLUGIN_VERSION } from "./version.js"; export const CONFIG_FILE = join(homedir(), ".codex", "supermemory.json"); export const DEFAULT_BASE_URL = "https://api.supermemory.ai"; -export interface CustomContainer { - tag: string; - 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."; @@ -38,9 +33,6 @@ interface CodexSupermemoryConfig { recallMode?: RecallMode; recallDirective?: string; captureEveryNTurns?: number; - enableCustomContainers?: boolean; - customContainers?: CustomContainer[]; - customContainerInstructions?: string; } const DEFAULT_SIGNAL_KEYWORDS = [ @@ -164,12 +156,6 @@ export const CONFIG = { /** @deprecated Prefer recallMode. */ autoRecallEveryPrompt: recallMode === "direct", captureEveryNTurns: resolveCaptureEveryNTurns(fileConfig), - enableCustomContainers: fileConfig.enableCustomContainers ?? false, - customContainers: (fileConfig.customContainers ?? []).filter( - (c): c is CustomContainer => - !!c && typeof c.tag === "string" && typeof c.description === "string", - ), - customContainerInstructions: fileConfig.customContainerInstructions ?? "", }; export function isConfigured(): boolean { @@ -224,54 +210,8 @@ export function getSignalConfig(): { }; } -export function getContainerCatalog(): string | null { - if (!CONFIG.enableCustomContainers || CONFIG.customContainers.length === 0) { - return null; - } - - const lines: string[] = []; - lines.push("Custom memory containers are available for organizing memories:"); - lines.push(""); - for (const c of CONFIG.customContainers) { - lines.push(`- \`${c.tag}\`: ${c.description}`); - } - - if (CONFIG.customContainerInstructions) { - lines.push(""); - lines.push(CONFIG.customContainerInstructions); - } - - lines.push(""); - lines.push( - "When saving memories with /supermemory-save, use --container to route to a specific container.", - ); - lines.push( - "When searching with /supermemory-search, use --container to search a specific container.", - ); - lines.push( - "When forgetting with /supermemory-forget, use --container to target a specific container.", - ); - lines.push("If no container is specified, memories go to the default project/user containers."); - - return lines.join("\n"); -} - -export function validateContainerTag(tag: string): string | null { - if (!CONFIG.enableCustomContainers || CONFIG.customContainers.length === 0) { - return "Custom containers are not enabled. Remove --container or set enableCustomContainers in config."; - } - - const validTags = CONFIG.customContainers.map((c) => c.tag); - if (validTags.includes(tag)) { - return null; - } - - const validList = validTags.map((t) => `'${t}'`).join(", "); - return `Unknown container tag '${tag}'. Valid containers: ${validList}`; -} - /** Persist explicit recall/capture defaults for fresh installs or legacy upgrades. */ -export function writeInstallDefaults(isExistingInstall: boolean): void { +export function writeInstallDefaults(_isExistingInstall: boolean): void { const current = loadRawConfigForWrite().config; const next: CodexSupermemoryConfig = { ...current }; @@ -279,13 +219,8 @@ export function writeInstallDefaults(isExistingInstall: boolean): void { next.recallMode = next.autoRecallEveryPrompt === false ? "off" : "direct"; } - if (isExistingInstall) { - if (next.captureEveryNTurns === undefined) { - next.captureEveryNTurns = next.autoSaveEveryTurns ?? 3; - } - } else { - next.captureEveryNTurns = 0; - } + // Per-prompt capture was retired. Stop captures every completed turn. + next.captureEveryNTurns = 0; writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2)); } @@ -297,8 +232,5 @@ export function getRecallModeSummary(): string { 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 + turn-stop flush`; - } - return "unified: session-start profile + turn-stop flush only"; + return "session-start profile + turn-stop capture"; } diff --git a/src/hooks/capture-turn.ts b/src/hooks/capture-turn.ts deleted file mode 100644 index 6fec553..0000000 --- a/src/hooks/capture-turn.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/flush.ts b/src/hooks/flush.ts index 5d1c029..f5fdbb1 100644 --- a/src/hooks/flush.ts +++ b/src/hooks/flush.ts @@ -43,7 +43,12 @@ async function main() { const client = new SupermemoryClient(); // Flush captures all remaining entries with no gating thresholds - await captureEntries("flush", client, sessionId, transcriptPath, tags); + const result = await captureEntries("flush", client, sessionId, transcriptPath, tags); + if (result.status === "captured") { + process.stdout.write(JSON.stringify({ + systemMessage: "β—ͺ supermemory Β· saved this turn", + })); + } } main() diff --git a/src/hooks/mcp-proxy.ts b/src/hooks/mcp-proxy.ts new file mode 100644 index 0000000..57e2fa1 --- /dev/null +++ b/src/hooks/mcp-proxy.ts @@ -0,0 +1,114 @@ +import { createInterface } from "node:readline"; +import { getApiKeyValue } from "../config.js"; + +const MCP_URL = + process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp"; +const REQUEST_TIMEOUT_MS = 30_000; + +let sessionId: string | null = null; + +interface JsonRpcMessage { + id?: string | number | null; + [key: string]: unknown; +} + +function send(message: unknown): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function sendError( + id: JsonRpcMessage["id"], + code: number, + message: string, +): void { + if (id === undefined || id === null) return; + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function emitSseData(body: string): void { + for (const event of body.split("\n\n")) { + for (const line of event.split("\n")) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data) process.stdout.write(`${data}\n`); + } + } +} + +async function forward(message: JsonRpcMessage, apiKey: string): Promise { + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (sessionId) headers["Mcp-Session-Id"] = sessionId; + + const response = await fetch(MCP_URL, { + method: "POST", + headers, + body: JSON.stringify(message), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + const nextSessionId = response.headers.get("mcp-session-id"); + if (nextSessionId) sessionId = nextSessionId; + + if (response.status === 202) return; + if (!response.ok) { + const body = await response.text().catch(() => ""); + sendError( + message.id, + -32000, + `Supermemory MCP ${response.status}: ${body.slice(0, 200) || "request failed"}`, + ); + return; + } + + const contentType = response.headers.get("content-type") || ""; + const body = await response.text(); + if (!body.trim()) return; + + if (contentType.includes("text/event-stream")) emitSseData(body); + else process.stdout.write(`${body.trim()}\n`); +} + +function main(): void { + const apiKey = getApiKeyValue(); + let queue = Promise.resolve(); + const lines = createInterface({ input: process.stdin }); + + lines.on("line", (line) => { + if (!line.trim()) return; + + let message: JsonRpcMessage; + try { + message = JSON.parse(line) as JsonRpcMessage; + } catch { + return; + } + + queue = queue.then(async () => { + if (!apiKey) { + sendError( + message.id, + -32001, + "Supermemory is not authenticated. Start a new Codex task to log in, or set SUPERMEMORY_CODEX_API_KEY.", + ); + return; + } + + try { + await forward(message, apiKey); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + sendError(message.id, -32000, `Supermemory MCP proxy error: ${detail}`); + } + }); + }); + + lines.on("close", () => { + queue.finally(() => process.exit(0)); + }); +} + +main(); diff --git a/src/hooks/recall-approve.ts b/src/hooks/recall-approve.ts new file mode 100644 index 0000000..ee1a3db --- /dev/null +++ b/src/hooks/recall-approve.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; + +const TOOL_NAME_RE = /^mcp__supermemory__(.+)$/; +const READ_ONLY_TOOLS = new Set([ + "search_memory", + "listSpaces", + "listMemories", + "listDocuments", + "getDocument", + "whoAmI", + "memory-graph", + "fetch-graph-data", +]); + +interface CodexPreToolPayload { + tool_name?: string; + tool_input?: { query?: unknown }; +} + +function main(): void { + let input: CodexPreToolPayload; + try { + input = JSON.parse(readFileSync(0, "utf-8")) as CodexPreToolPayload; + } catch { + return; + } + + const tool = TOOL_NAME_RE.exec(input.tool_name ?? "")?.[1]; + if (!tool || !READ_ONLY_TOOLS.has(tool)) return; + + const query = typeof input.tool_input?.query === "string" + ? input.tool_input.query + : null; + + process.stdout.write(JSON.stringify({ + systemMessage: query + ? `β—ͺ supermemory Β· recalling: ${query}` + : "β—ͺ supermemory Β· recalling memories", + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: + "Supermemory recall is read-only memory access.", + // Codex requires allow decisions to carry an updatedInput object. Pass + // the original arguments through unchanged so this remains an approval, + // not a rewrite. + updatedInput: input.tool_input ?? {}, + }, + })); +} + +main(); diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index e2ebefb..b53e1f6 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -1,16 +1,13 @@ -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { isConfigured, CONFIG, getContainerCatalog } from "../config.js"; +import { readFileSync } from "node:fs"; +import { isConfigured, CONFIG } from "../config.js"; import { getTags } from "../services/tags.js"; -import { formatCombinedContext } from "../services/context.js"; import { log } from "../services/logger.js"; -import { getSeenFacts, addSeenFacts } from "../services/factCache.js"; +import { getSeenFacts, addSeenFacts, factKey } from "../services/factCache.js"; import { getSessionId } from "../services/session.js"; import { getHookProfileWithSearchMany } from "../services/hookRecallClient.js"; import { prepareRecallQuery, shouldRecallPrompt } from "../services/recallPolicy.js"; -const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out"); +const MAX_RESULT_CHARS = 300; interface CodexHookPayload { session_id?: string; @@ -40,6 +37,29 @@ function exitWithContext(additionalContext: string, systemMessage?: string): nev process.exit(0); } +interface RecallItem { + memory: string; + title?: string; + filepath?: string; +} + +function formatRecall(items: RecallItem[], containerTag: string): string { + const lines = items.map((item) => { + const text = item.memory.replace(/\s+/g, " ").slice(0, MAX_RESULT_CHARS); + const title = item.title?.trim(); + const prefix = title && !text.startsWith(title) ? `${title} β€” ` : ""; + const filepath = item.filepath ? ` (${item.filepath})` : ""; + return `- β—ͺ ${prefix}${text}${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}"). +`; +} + async function main() { let rawInput = ""; try { @@ -49,16 +69,11 @@ async function main() { } if (!isConfigured()) { - if (existsSync(LOGGED_OUT_FILE)) { - log("recall: logged out marker present, skipping browser auth"); - exitWithContext(""); - } - - // UserPromptSubmit has a 5s backstop and must never launch the interactive - // browser flow. SessionStart and /supermemory-login own authentication. + // UserPromptSubmit has a 5s backstop and must never launch browser auth. + // SessionStart owns 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." + "Start a new Codex task to authenticate, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." ); } @@ -106,42 +121,32 @@ async function main() { } const seen = getSeenFacts(sessionId); - const { text, newFacts } = formatCombinedContext( - profileResult, - CONFIG.maxMemories, - CONFIG.maxProfileItems, - seen, - ); + const matches = profileResult.searchResults?.results ?? []; + const fresh = matches + .filter((item) => !seen.has(factKey(item.memory))) + .slice(0, Math.min(CONFIG.maxMemories, 5)); + const repeats = matches.length - fresh.length; log("recall: done", { - contextLength: text.length, - newFactCount: newFacts.length, + matchCount: matches.length, + freshCount: fresh.length, seenCount: seen.size, }); - const containerCatalog = getContainerCatalog(); - - if (newFacts.length > 0) { - addSeenFacts(sessionId, newFacts); - let additionalContext = `[SUPERMEMORY CONTEXT]\n${text}\n[END SUPERMEMORY CONTEXT]`; - - if (containerCatalog) { - additionalContext += `\n\n[SUPERMEMORY CONTAINERS]\n${containerCatalog}\n[END SUPERMEMORY CONTAINERS]`; - } - + if (fresh.length > 0) { + addSeenFacts(sessionId, fresh.map((item) => item.memory)); + const additionalContext = formatRecall(fresh, tags.canonical); + const tokens = Math.round(additionalContext.length / 4); + const label = repeats > 0 + ? `recalled ${fresh.length} new (${tokens} tok) Β· ${repeats} already in context` + : `recalled ${fresh.length} ${fresh.length === 1 ? "memory" : "memories"} (${tokens} tok)`; log("recall: emit context", { additionalContextLength: additionalContext.length, }); - exitWithContext(additionalContext); - } else if (containerCatalog) { - const additionalContext = `[SUPERMEMORY CONTAINERS]\n${containerCatalog}\n[END SUPERMEMORY CONTAINERS]`; - log("recall: emit container catalog only", { - additionalContextLength: additionalContext.length, - }); - exitWithContext(additionalContext); - } else { - exitWithContext(""); + exitWithContext(additionalContext, `β—ͺ supermemory Β· ${label}`); } + + exitWithContext(""); } catch (error) { log("recall: error", { error: String(error) }); exitWithContext("", "β—ͺ supermemory Β· recall unavailable; continuing without recalled context"); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 4a2c986..de45f1e 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; +import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "../config.js"; @@ -10,8 +10,7 @@ import { startAuthFlow, AUTH_BASE_URL } from "../services/auth.js"; import { getSeenFacts, addSeenFacts } from "../services/factCache.js"; 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 MARK_TIP_FILE = join(homedir(), ".codex", "supermemory", ".mark-tip-shown"); const UPDATE_COMMAND = "npx codex-supermemory@latest install"; const SESSION_START_HOOK_TIMEOUT_MS = 30_000; const SESSION_START_AUTH_TIMEOUT_MS = 25_000; @@ -54,6 +53,17 @@ function combineContextParts(parts: Array): string { return parts.map((part) => part?.trim()).filter(Boolean).join("\n\n"); } +function markTip(): string | null { + try { + if (existsSync(MARK_TIP_FILE)) return null; + mkdirSync(dirname(MARK_TIP_FILE), { recursive: true }); + writeFileSync(MARK_TIP_FILE, new Date().toISOString()); + return "β—ͺ is the supermemory mark β€” whenever you see it (notices or Codex's answers), that information came from supermemory."; + } catch { + return null; + } +} + async function main() { let rawInput = ""; try { @@ -63,33 +73,14 @@ async function main() { } if (!isConfigured()) { - if (existsSync(LOGGED_OUT_FILE)) { - log("session-start: logged out marker present, skipping browser auth"); - 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 { - await startAuthFlow(getSessionStartAuthTimeoutMs()); - reloadApiKey(); - try { unlinkSync(AUTH_ATTEMPTED_FILE); } catch {} - } catch { - exitWithContext( - "[SUPERMEMORY] Memory is installed but NOT active β€” missing API key.\n" + - `Visit: ${AUTH_BASE_URL}\n` + - "Run /supermemory-login to authenticate." - ); - } - } else { + try { + await startAuthFlow(getSessionStartAuthTimeoutMs()); + reloadApiKey(); + } catch { exitWithContext( "[SUPERMEMORY] Memory is installed but NOT active β€” missing API key.\n" + - "Run /supermemory-login to authenticate." + `Visit: ${AUTH_BASE_URL}\n` + + "A new Codex task will try browser authentication again, or set SUPERMEMORY_CODEX_API_KEY." ); } } @@ -131,20 +122,40 @@ async function main() { if (!profileResult.success) { exitWithContext( await updateCheck ?? "", - "β—ͺ supermemory Β· profile unavailable; continuing without recalled context", + combineContextParts([ + "β—ͺ supermemory Β· profile unavailable; continuing without recalled context", + markTip(), + ]), ); } if (newFacts.length > 0) { addSeenFacts(sessionId, newFacts); const updateNotice = await updateCheck; + const context = ` +Recalled memory for this project (${tags.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: ${tags.canonical} + +${text} +`; exitWithContext(combineContextParts([ - `[SUPERMEMORY CONTEXT]\n${text}\n[END SUPERMEMORY CONTEXT]`, + context, updateNotice, + ]), combineContextParts([ + `β—ͺ supermemory Β· active Β· ${newFacts.length} ${newFacts.length === 1 ? "memory" : "memories"} loaded for ${tags.projectName}`, + markTip(), ])); } - exitWithContext(await updateCheck ?? ""); + const storedProfileCount = (profileResult.profile?.static.length ?? 0) + + (profileResult.profile?.dynamic.length ?? 0); + const activeMessage = storedProfileCount > 0 + ? `β—ͺ supermemory Β· active Β· memory context current for ${tags.projectName}` + : `β—ͺ supermemory Β· active Β· no memories saved for ${tags.projectName} yet`; + exitWithContext(await updateCheck ?? "", combineContextParts([ + activeMessage, + markTip(), + ])); } catch (error) { log("session-start: error", { error: String(error) }); exitWithContext( diff --git a/src/services/capture.ts b/src/services/capture.ts index 76ccf25..e0b667d 100644 --- a/src/services/capture.ts +++ b/src/services/capture.ts @@ -31,6 +31,11 @@ export interface CaptureOptions { requireMinTurns?: number; } +export type CaptureResult = + | { status: "captured"; entryCount: number } + | { status: "skipped" } + | { status: "failed" }; + /** * Resolve a transcript path β€” either from the provided value or by * searching for a file matching the session ID. @@ -61,14 +66,15 @@ export async function captureEntries( transcriptPath: string | null, tags: Pick, options: CaptureOptions = {}, -): Promise { +): Promise { if (!transcriptPath || !existsSync(transcriptPath)) { log(`${caller}: no transcript to capture from`, { sessionId, transcriptPath }); - return; + return { status: "skipped" }; } + let result: CaptureResult = { status: "skipped" }; const acquired = await withSessionCaptureLock(sessionId, async () => { - await captureEntriesLocked( + result = await captureEntriesLocked( caller, client, sessionId, @@ -79,7 +85,9 @@ export async function captureEntries( }); if (!acquired) { log(`${caller}: capture lock timed out`, { sessionId }); + return { status: "failed" }; } + return result; } async function captureEntriesLocked( @@ -89,13 +97,13 @@ async function captureEntriesLocked( transcriptPath: string, tags: Pick, options: CaptureOptions, -): Promise { +): Promise { const { requireMinEntries = 0, requireMinTurns = 0 } = options; const entries = parseTranscript(transcriptPath); if (entries.length === 0) { log(`${caller}: transcript empty`, { sessionId }); - return; + return { status: "skipped" }; } const lastIndex = getLastCapturedIndex(sessionId); @@ -108,12 +116,12 @@ async function captureEntriesLocked( required: requireMinEntries, lastIndex, }); - return; + return { status: "skipped" }; } if (newEntries.length === 0) { log(`${caller}: no new entries to capture`, { sessionId }); - return; + return { status: "skipped" }; } // Turn-based gating (used by recall to batch captures) @@ -127,7 +135,7 @@ async function captureEntriesLocked( requiredTurns: requireMinTurns, lastIndex, }); - return; + return { status: "skipped" }; } } @@ -143,7 +151,7 @@ async function captureEntriesLocked( // Still update tracker so we don't re-check these entries const lastEntry = newEntries[newEntries.length - 1]; setLastCapturedIndex(sessionId, lastEntry.index); - return; + return { status: "skipped" }; } log(`${caller}: capturing signal entries`, { @@ -187,7 +195,7 @@ async function captureEntriesLocked( sessionId, error: result.error, }); - return; + return { status: "failed" }; } const lastEntry = newEntries[newEntries.length - 1]; @@ -198,8 +206,9 @@ async function captureEntriesLocked( count: newEntries.length, lastIndex: lastEntry.index, }); + return { status: "captured", entryCount: newEntries.length }; } catch (error) { log(`${caller}: capture error`, { error: String(error) }); - // Don't rethrow β€” let the caller decide how to handle + return { status: "failed" }; } } diff --git a/src/services/transcript.ts b/src/services/transcript.ts index 6a922cd..a686a88 100644 --- a/src/services/transcript.ts +++ b/src/services/transcript.ts @@ -65,13 +65,43 @@ function searchDirForSession(dir: string, sessionId: string): string | null { return null; } +const DUPLICATE_LINE_WINDOW = 5; + +interface ContentBlock { + type?: string; + text?: string; +} + +function extractTextBlocks( + content: unknown, + blockTypes: string[], + separator = "\n", +): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return (content as ContentBlock[]) + .filter( + (block): block is ContentBlock & { text: string } => + !!block && + blockTypes.includes(block.type ?? "") && + typeof block.text === "string", + ) + .map((block) => block.text) + .join(separator); +} + /** * Parse a Codex JSONL transcript file into TranscriptEntry[]. * * Codex transcript format: - * - User messages: { type: "event_msg", payload: { type: "user_message", message: "..." } } - * - Assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } - * - Also check response_item for assistant messages + * - Legacy user messages: { type: "event_msg", payload: { type: "user_message", message: "..." } } + * - Legacy assistant text: { type: "event_msg", payload: { type: "assistant_output_text", text: "..." } } + * - Current messages: { type: "response_item", payload: { type: "message", role: "user" | "assistant", content: [...] } } + * - user content blocks use `input_text` + * - assistant content blocks use `output_text` + * + * Some rollouts contain both formats for the same turn. Identical nearby + * entries are deduplicated so the stored conversation contains one copy. */ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { const entries: TranscriptEntry[] = []; @@ -80,6 +110,18 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { return entries; } + function pushEntry(index: number, role: string, rawContent: string): void { + const cleaned = cleanContent(stripPrivateContent(rawContent)); + if (!cleaned) return; + + for (let i = entries.length - 1; i >= 0; i--) { + if (index - entries[i].index > DUPLICATE_LINE_WINDOW) break; + if (entries[i].role === role && entries[i].content === cleaned) return; + } + + entries.push({ index, role, content: cleaned }); + } + try { const raw = readFileSync(transcriptPath, "utf-8"); const lines = raw.split("\n"); @@ -106,55 +148,32 @@ export function parseTranscript(transcriptPath: string): TranscriptEntry[] { // User message if (payload.type === "user_message" && payload.message) { - const cleaned = cleanContent(stripPrivateContent(payload.message)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "user", - content: cleaned, - }); - } + pushEntry(i, "user", payload.message); } // Assistant output text if (payload.type === "assistant_output_text" && payload.text) { - const cleaned = cleanContent(stripPrivateContent(payload.text)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "assistant", - content: cleaned, - }); - } + pushEntry(i, "assistant", payload.text); } } - // Handle response_item entries (assistant responses) + // Handle current response_item messages. if (parsed.type === "response_item" && parsed.payload) { const payload = parsed.payload; - if (payload.role === "assistant" && payload.content) { - const content = payload.content; - let text = ""; - - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - // Extract text from content blocks - for (const block of content as Array<{ type?: string; text?: string }>) { - if (block.type === "output_text" && block.text) { - text += block.text + "\n"; - } - } - } - - const cleaned = cleanContent(stripPrivateContent(text)); - if (cleaned && cleaned.length > 0) { - entries.push({ - index: i, - role: "assistant", - content: cleaned, - }); - } + if (payload.role === "user" && payload.content) { + // Codex builds event_msg.user_message by concatenating input_text + // blocks without separators, so mirror that shape for deduplication. + pushEntry( + i, + "user", + extractTextBlocks(payload.content, ["input_text"], ""), + ); + } else if (payload.role === "assistant" && payload.content) { + pushEntry( + i, + "assistant", + extractTextBlocks(payload.content, ["output_text"]), + ); } } } catch { diff --git a/src/skills/add-memory.ts b/src/skills/add-memory.ts deleted file mode 100644 index 246ebc0..0000000 --- a/src/skills/add-memory.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { isConfigured } from "../config.js"; -import { SupermemoryClient, USER_ENTITY_CONTEXT } from "../services/client.js"; -import { - getProjectIdentity, - getProjectName, - getProjectTag, -} from "../services/tags.js"; - -async function main(): Promise { - if (!isConfigured()) { - console.error( - "Supermemory is not authenticated.\n" + - "Run /supermemory-login to connect, or set SUPERMEMORY_CODEX_API_KEY in your shell profile.", - ); - process.exit(1); - } - - const content = process.argv.slice(2).join(" ").trim(); - if (!content) { - console.log('No content provided. Usage: node add-memory.js "content to remember"'); - process.exit(0); - } - - const cwd = process.cwd(); - const containerTag = getProjectTag(cwd); - const projectName = getProjectName(cwd); - const client = new SupermemoryClient(); - const result = await client.addMemory( - content, - containerTag, - { - type: "manual", - project: projectName, - sm_project_id: getProjectIdentity(cwd), - sm_scope: "personal", - sm_capture_mode: "explicit", - timestamp: new Date().toISOString(), - }, - { entityContext: USER_ENTITY_CONTEXT }, - ); - - if (!result.success) { - console.log(`Failed to add personal memory: ${result.error}`); - return; - } - console.log(`Personal memory added for ${projectName} (id: ${result.id})`); -} - -main().catch((error) => { - console.error(`Failed to add personal memory: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); -}); diff --git a/src/skills/forget-memory.ts b/src/skills/forget-memory.ts deleted file mode 100644 index d214674..0000000 --- a/src/skills/forget-memory.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { isConfigured, validateContainerTag } from "../config.js"; -import { SupermemoryClient } from "../services/client.js"; -import { getTags } from "../services/tags.js"; - -function parseArgs(args: string[]): { content: string; containerTag?: string } { - let containerTag: string | undefined; - const contentParts: string[] = []; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--container" && i + 1 < args.length) { - containerTag = args[++i]; - } else { - contentParts.push(args[i]); - } - } - - return { content: contentParts.join(" "), containerTag }; -} - -async function main(): Promise { - if (!isConfigured()) { - console.error( - "Supermemory is not authenticated.\n" + - "Run /supermemory-login to connect, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." - ); - process.exit(1); - } - - const { content, containerTag } = parseArgs(process.argv.slice(2)); - - if (!content.trim()) { - console.log( - 'No content provided. Usage: node forget-memory.js [--container ] "content to forget"' - ); - process.exit(0); - } - - const client = new SupermemoryClient(); - - if (containerTag) { - const validationError = validateContainerTag(containerTag); - if (validationError) { - console.log(validationError); - process.exit(1); - } - } - - try { - if (containerTag) { - const result = await client.forgetMemory(content, containerTag); - if (result.success) { - console.log(`Memory forgotten from container '${containerTag}'${result.id ? ` (id: ${result.id})` : ""}`); - } else { - console.log(`Failed to forget memory from container '${containerTag}': ${result.error}`); - } - } else { - const tags = getTags(process.cwd()); - const targetTags = tags.allReads; - const results = await Promise.all( - targetTags.map(async (tag) => ({ - tag, - result: await client.forgetMemory(content, tag), - })), - ); - - const forgotten: string[] = []; - const errors: string[] = []; - - for (const { tag, result } of results) { - if (result.success) { - forgotten.push(result.id ? `${tag} (id: ${result.id})` : tag); - } else { - errors.push(`${tag}: ${result.error}`); - } - } - - if (forgotten.length > 0) { - console.log(`Memory forgotten from: ${forgotten.join(", ")}`); - } else { - console.log(`Failed to forget memory: ${errors.join("; ")}`); - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Failed to forget memory: ${message}`); - } -} - -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.log(`Failed to forget memory: ${message}`); -}); diff --git a/src/skills/login.ts b/src/skills/login.ts deleted file mode 100644 index 13a36e2..0000000 --- a/src/skills/login.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { unlinkSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { isConfigured } from "../config.js"; -import { startAuthFlow, AUTH_BASE_URL, CREDENTIALS_FILE } from "../services/auth.js"; - -const AUTH_ATTEMPTED_FILE = join(homedir(), ".codex", "supermemory", ".auth-attempted"); -const LOGGED_OUT_FILE = join(homedir(), ".codex", "supermemory", ".logged-out"); - -async function main(): Promise { - try { - if (existsSync(LOGGED_OUT_FILE)) unlinkSync(LOGGED_OUT_FILE); - } catch {} - - if (isConfigured()) { - console.log("Already authenticated with Supermemory. Memory is active."); - console.log(`To re-authenticate, remove ${CREDENTIALS_FILE} and run this again.`); - process.exit(0); - } - - // Clear the marker so a later SessionStart can offer browser auth again. - try { - if (existsSync(AUTH_ATTEMPTED_FILE)) unlinkSync(AUTH_ATTEMPTED_FILE); - } catch {} - - console.log("Opening browser to authenticate with Supermemory..."); - console.log(`If the browser does not open, visit: ${AUTH_BASE_URL}`); - - try { - await startAuthFlow(); - try { - if (existsSync(AUTH_ATTEMPTED_FILE)) unlinkSync(AUTH_ATTEMPTED_FILE); - } catch {} - console.log("\nAuthenticated successfully! Supermemory is now active."); - process.exit(0); - } catch (err) { - const isTimeout = err instanceof Error && err.message === "AUTH_TIMEOUT"; - if (isTimeout) { - console.error("\nAuthentication timed out. Please try again."); - } else { - console.error("\nAuthentication failed:", err instanceof Error ? err.message : err); - } - console.error(`\nAlternatively, set the API key manually:`); - console.error(` export SUPERMEMORY_CODEX_API_KEY="sm_..."`); - console.error(` Get your key at: https://app.supermemory.ai/?view=integrations`); - process.exit(1); - } -} - -main().catch((err) => { - console.error("Fatal:", err instanceof Error ? err.message : err); - process.exit(1); -}); diff --git a/src/skills/logout.ts b/src/skills/logout.ts deleted file mode 100644 index 8a79445..0000000 --- a/src/skills/logout.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { CREDENTIALS_FILE } from "../services/auth.js"; - -const SUPERMEMORY_DIR = join(homedir(), ".codex", "supermemory"); -const AUTH_ATTEMPTED_FILE = join(SUPERMEMORY_DIR, ".auth-attempted"); -const LOGGED_OUT_FILE = join(SUPERMEMORY_DIR, ".logged-out"); -const CONFIG_FILE = join(homedir(), ".codex", "supermemory.json"); - -function removeFile(path: string): boolean { - try { - if (!existsSync(path)) return false; - unlinkSync(path); - return true; - } catch (error) { - console.error(`Failed to remove ${path}: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - } -} - -function removeConfigApiKey(): boolean { - try { - if (!existsSync(CONFIG_FILE)) return false; - const parsed = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as Record; - if (!Object.prototype.hasOwnProperty.call(parsed, "apiKey")) return false; - delete parsed.apiKey; - writeFileSync(CONFIG_FILE, `${JSON.stringify(parsed, null, 2)}\n`); - return true; - } catch (error) { - console.error(`Failed to update ${CONFIG_FILE}: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - } -} - -function main(): void { - const removedCredentials = removeFile(CREDENTIALS_FILE); - const removedAuthMarker = removeFile(AUTH_ATTEMPTED_FILE); - const removedConfigApiKey = removeConfigApiKey(); - const envApiKeySet = !!process.env.SUPERMEMORY_CODEX_API_KEY; - mkdirSync(SUPERMEMORY_DIR, { recursive: true }); - writeFileSync(LOGGED_OUT_FILE, new Date().toISOString()); - - if (removedCredentials || removedConfigApiKey || removedAuthMarker) { - console.log("Logged out of Supermemory for Codex."); - } else { - console.log("No saved Supermemory login was found."); - } - - if (removedCredentials) { - console.log(`Removed credentials file: ${CREDENTIALS_FILE}`); - } - if (removedConfigApiKey) { - console.log(`Removed apiKey from ${CONFIG_FILE}`); - } - - if (envApiKeySet) { - console.log(""); - console.log("SUPERMEMORY_CODEX_API_KEY is still set in this shell, so memory may remain active until you unset it or restart Codex."); - } else { - console.log("Supermemory memory is inactive until you run /supermemory-login again."); - console.log("This only logs out this local Codex install. To revoke the account-level Codex integration key, disconnect it from the Supermemory integrations page."); - } -} - -main(); diff --git a/src/skills/profile-memory.ts b/src/skills/profile-memory.ts deleted file mode 100644 index 459312c..0000000 --- a/src/skills/profile-memory.ts +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -import { isConfigured } from "../config.js"; -import { SupermemoryClient } from "../services/client.js"; -import { getTags } from "../services/tags.js"; - -async function main() { - if (!isConfigured()) { - console.error("Supermemory is not authenticated. Run /supermemory-login first."); - process.exit(1); - } - - const cwd = process.cwd(); - const tags = getTags(cwd); - const client = new SupermemoryClient(); - const result = await client.getProfileScopedMany( - tags.canonical, - tags.personalReads, - "personal", - ); - - if (!result.success || !result.profile) { - console.log("No profile available yet."); - process.exit(0); - } - - const staticFacts = result.profile.static ?? []; - const dynamicFacts = result.profile.dynamic ?? []; - const lines: string[] = []; - - if (staticFacts.length > 0) { - lines.push("[User Profile β€” Static]"); - staticFacts.forEach((fact, i) => lines.push(`${i + 1}. ${fact}`)); - } - if (dynamicFacts.length > 0) { - lines.push("[User Profile β€” Recent]"); - dynamicFacts.forEach((fact, i) => lines.push(`${i + 1}. ${fact}`)); - } - - if (lines.length === 0) { - console.log("Profile is empty."); - process.exit(0); - } - - console.log(lines.join("\n")); -} - -main().catch((error) => { - console.error(String(error)); - process.exit(1); -}); diff --git a/src/skills/save-memory.ts b/src/skills/save-memory.ts deleted file mode 100644 index f2a96d8..0000000 --- a/src/skills/save-memory.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { CONFIG, isConfigured, validateContainerTag } from "../config.js"; -import { PROJECT_ENTITY_CONTEXT, SupermemoryClient } from "../services/client.js"; -import { - getProjectIdentity, - getProjectName, - getProjectTag, -} from "../services/tags.js"; - -function parseArgs(args: string[]): { content: string; containerTag?: string } { - let containerTag: string | undefined; - const contentParts: string[] = []; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--container" && i + 1 < args.length) { - containerTag = args[++i]; - } else { - contentParts.push(args[i]); - } - } - - return { content: contentParts.join(" "), containerTag }; -} - -function getEntityContext(containerTag: string | undefined): string { - if (!containerTag) return PROJECT_ENTITY_CONTEXT; - - const customContainer = CONFIG.customContainers.find((c) => c.tag === containerTag); - if (!customContainer) return PROJECT_ENTITY_CONTEXT; - - return `Custom Codex memory container. - -Purpose: ${customContainer.description} - -EXTRACT: -- Memories that match this container's purpose -- Stable facts, preferences, decisions, workflows, and implementation lessons relevant to this container - -SKIP: -- Unrelated project or user context that belongs in another container -- One-off assistant suggestions the user did not accept`; -} - -async function main(): Promise { - if (!isConfigured()) { - console.error( - "Supermemory is not authenticated.\n" + - "Run /supermemory-login to connect, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." - ); - process.exit(1); - } - - const { content, containerTag } = parseArgs(process.argv.slice(2)); - - if (!content.trim()) { - console.log('No content provided. Usage: node save-memory.js [--container ] "content to save"'); - process.exit(0); - } - - if (containerTag) { - const validationError = validateContainerTag(containerTag); - if (validationError) { - console.log(validationError); - process.exit(1); - } - } - - const client = new SupermemoryClient(); - const projectTag = getProjectTag(process.cwd()); - const projectName = getProjectName(process.cwd()); - const projectId = getProjectIdentity(process.cwd()); - const effectiveTag = containerTag || projectTag; - - try { - const metadata = { - type: "project-knowledge" as const, - source: "skill", - project: projectName, - sm_project_id: projectId, - sm_scope: "project", - sm_capture_mode: "explicit", - timestamp: new Date().toISOString(), - }; - - const result = await client.addMemory(content, effectiveTag, metadata, { - entityContext: getEntityContext(containerTag), - }); - - if (result.success) { - if (!containerTag) { - await client.updateContainerTagName(projectTag, `Agents Β· ${projectName}`); - } - const tagLabel = containerTag ? `container '${containerTag}'` : `project '${effectiveTag}'`; - console.log(`Memory saved (id: ${result.id}) to ${tagLabel}`); - } else { - console.log(`Failed to save memory: ${result.error}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Failed to save memory: ${message}`); - } -} - -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.log(`Failed to save memory: ${message}`); -}); diff --git a/src/skills/search-memory.ts b/src/skills/search-memory.ts deleted file mode 100644 index 101e9cf..0000000 --- a/src/skills/search-memory.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { CONFIG, isConfigured, validateContainerTag } from "../config.js"; -import { - SupermemoryClient, - type ProfileWithSearchResult, - type SearchResponse, -} from "../services/client.js"; -import { formatContextForPrompt } from "../services/context.js"; -import { getTags } from "../services/tags.js"; - -type Scope = "user" | "project" | "both" | "custom"; - -interface ParsedArgs { - scope: Scope; - includeProfile: boolean; - query: string; - containerTag?: string; -} - -function parseArgs(args: string[]): ParsedArgs { - let scope: Scope = "both"; - let includeProfile = true; - let containerTag: string | undefined; - const queryParts: string[] = []; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--user") { - scope = "user"; - } else if (args[i] === "--project") { - scope = "project"; - } else if (args[i] === "--both") { - scope = "both"; - } else if (args[i] === "--no-profile") { - includeProfile = false; - } else if (args[i] === "--container" && i + 1 < args.length) { - containerTag = args[++i]; - scope = "custom"; - } else { - queryParts.push(args[i]); - } - } - - return { scope, includeProfile, query: queryParts.join(" "), containerTag }; -} - -async function main(): Promise { - if (!isConfigured()) { - console.error( - "Supermemory is not authenticated.\n" + - "Run /supermemory-login to connect, or set SUPERMEMORY_CODEX_API_KEY in your shell profile." - ); - process.exit(1); - } - - const { scope, includeProfile, query, containerTag } = parseArgs(process.argv.slice(2)); - - if (!query.trim()) { - console.log( - 'No search query provided. Usage: node search-memory.js [--user|--project|--both|--container ] "query"' - ); - process.exit(0); - } - - const client = new SupermemoryClient(); - const tags = getTags(process.cwd()); - - if (containerTag) { - const validationError = validateContainerTag(containerTag); - if (validationError) { - console.log(validationError); - process.exit(1); - } - } - - try { - let searchResult: SearchResponse; - - if (scope === "custom" && containerTag) { - searchResult = await client.searchMemories(query, containerTag); - - if (!searchResult.success) { - console.log(`Failed to search container '${containerTag}': ${searchResult.error}`); - return; - } - } else if (scope === "both") { - searchResult = await client.searchMemoriesMany(query, tags.allReads); - if (!searchResult.success) { - console.log(`Failed to search memories: ${searchResult.error}`); - return; - } - } else { - const readTags = scope === "user" ? tags.personalReads : tags.projectReads; - const metadataScope = scope === "user" ? "personal" : "project"; - searchResult = await client.searchMemoriesScoped( - query, - tags.canonical, - readTags, - metadataScope, - ); - - // Surface error for single-scope search failure - if (!searchResult.success) { - console.log(`Failed to search memories: ${searchResult.error}`); - return; - } - } - - let profileResult: ProfileWithSearchResult = { - success: false, - profile: null, - }; - if (includeProfile && scope === "both") { - profileResult = await client.getProfileMany(tags.allReads, query); - } else if (includeProfile && scope === "user") { - profileResult = await client.getProfileScopedMany( - tags.canonical, - tags.personalReads, - "personal", - query, - ); - } else if (includeProfile && scope === "project") { - profileResult = await client.getProfileScopedMany( - tags.canonical, - tags.projectReads, - "project", - query, - ); - } - - const output = formatContextForPrompt( - searchResult, - profileResult, - CONFIG.maxMemories, - CONFIG.maxProfileItems - ); - - if (output.trim()) { - console.log(output); - } else { - console.log(`No memories found for "${query}"`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`Failed to search memories: ${message}`); - } -} - -main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.log(`Failed to search memories: ${message}`); -}); diff --git a/src/skills/status.ts b/src/skills/status.ts index 7fdcb09..71200be 100644 --- a/src/skills/status.ts +++ b/src/skills/status.ts @@ -2,8 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { CONFIG, getApiBaseUrl, getApiKeyValue, isConfigured } from "../config.js"; -import { CREDENTIALS_FILE, loadCredentials } from "../services/auth.js"; -import { SupermemoryClient } from "../services/client.js"; +import { loadCredentials } from "../services/auth.js"; import { getTags } from "../services/tags.js"; const API_URL = @@ -44,21 +43,17 @@ function getAutoRecallStatus(): string { return CONFIG.recallMode; } -function getAutoCaptureStatus(): string { - if (CONFIG.captureEveryNTurns <= 0) return "off"; - return `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"}`; -} - async function fetchJson(path: string): Promise { const apiKey = getApiKeyValue(); if (!apiKey) return null; try { - const response = await fetch(`${API_URL}${path}`, { + const response = await fetch(`${API_URL.replace(/\/+$/, "")}${path}`, { headers: { Authorization: `Bearer ${apiKey}`, "x-sm-source": "codex", }, + signal: AbortSignal.timeout(8_000), }); if (!response.ok) return null; return await response.json(); @@ -83,6 +78,40 @@ async function getAccountInfo(): Promise<{ email?: string; name?: string; userId }; } +async function probeApi(containerTag: string): Promise<{ + ok: boolean; + status?: number; + detail: string; +}> { + const apiKey = getApiKeyValue(); + if (!apiKey) return { ok: false, detail: "not checked (missing API key)" }; + + try { + const response = await fetch(`${API_URL.replace(/\/+$/, "")}/v4/profile`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "x-sm-source": "codex", + }, + body: JSON.stringify({ containerTag, q: "connectivity probe" }), + signal: AbortSignal.timeout(8_000), + }); + if (response.status === 200) { + return { ok: true, status: 200, detail: "reachable, key valid" }; + } + if (response.status === 401 || response.status === 403) { + return { ok: false, status: response.status, detail: "reachable, key invalid or revoked" }; + } + return { ok: false, status: response.status, detail: "API returned an error" }; + } catch (error) { + return { + ok: false, + detail: error instanceof Error ? error.message : String(error), + }; + } +} + async function main(): Promise { const cwd = process.cwd(); const tags = getTags(cwd); @@ -91,30 +120,31 @@ async function main(): Promise { lines.push("supermemory status"); lines.push(""); + lines.push(`Authenticated: ${isConfigured() ? "yes" : "no"}`); lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); lines.push(`API key: ${maskKey(apiKey)} (${getKeySource()})`); lines.push(`API URL: ${API_URL}`); lines.push(`Memory scope: one project container with metadata scopes`); lines.push(`Auto-recall: ${getAutoRecallStatus()}`); - lines.push(`Auto-capture: ${getAutoCaptureStatus()}`); + lines.push("Auto-capture: after completed turns"); lines.push(`Project container: ${tags.canonical}`); lines.push(`Reads (including legacy): ${tags.allReads.join(", ")}`); if (!isConfigured()) { lines[2] = "Connected: no"; lines.push(""); - lines.push("Run /supermemory-login to connect, or set SUPERMEMORY_CODEX_API_KEY."); + lines.push("Start a new Codex task to connect automatically, or set SUPERMEMORY_CODEX_API_KEY."); console.log(lines.join("\n")); process.exit(0); } - const client = new SupermemoryClient(); - const [profileResult, accountInfo] = await Promise.all([ - client.getProfileMany(tags.allReads), + const [probe, accountInfo] = await Promise.all([ + probeApi(tags.canonical), getAccountInfo(), ]); - lines[2] = profileResult.success ? "Connected: yes" : "Connected: no"; + lines[3] = probe.ok ? "Connected: yes" : "Connected: no"; + lines.push(`API reachability: ${probe.status ? `${probe.status} β€” ` : ""}${probe.detail}`); if (accountInfo.email || accountInfo.name || accountInfo.userId || accountInfo.orgName) { lines.push(""); @@ -128,9 +158,9 @@ async function main(): Promise { lines.push("Account: authenticated API key (account details unavailable from API key)"); } - if (!profileResult.success) { + if (!probe.ok) { lines.push(""); - lines.push(`Connection check failed: ${profileResult.error}`); + lines.push(`Connection check failed: ${probe.detail}`); const devTlsHint = getDevTlsHint(); if (devTlsHint) lines.push(devTlsHint); } diff --git a/src/skills/supermemory-add/SKILL.md b/src/skills/supermemory-add/SKILL.md deleted file mode 100644 index 3b70812..0000000 --- a/src/skills/supermemory-add/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: supermemory-add -description: Add a personal memory for the current project. Use when the user explicitly asks Codex to remember a preference, intention, learning, or personal context rather than shared project knowledge. -allowed-tools: Bash(node:*) ---- - -# Supermemory Add - -Save a personal memory associated with the current project: - -```bash -node ~/.codex/supermemory/add-memory.js "MEMORY_CONTENT" -``` - -Use this for personal preferences, goals, learnings, and explicit β€œremember this” requests. For architecture, conventions, setup, bug fixes, or decisions that should describe the repository itself, use `/supermemory-save` instead. diff --git a/src/skills/supermemory-forget/SKILL.md b/src/skills/supermemory-forget/SKILL.md deleted file mode 100644 index 21efa7a..0000000 --- a/src/skills/supermemory-forget/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: supermemory-forget -description: Remove outdated or incorrect information from memory. Use when user says something is no longer true, wants to delete a memory, or information has changed. -allowed-tools: Bash(node:*) ---- - -# Forget - -Remove outdated or incorrect information from Supermemory. - -## When to Use - -- User says something is no longer true or has changed -- User explicitly asks to forget or delete a memory -- Information has become outdated or incorrect - -## How to Forget - -Describe the content to forget β€” the system will find and remove matching memories: - -```bash -node ~/.codex/supermemory/forget-memory.js "DESCRIPTION_OF_WHAT_TO_FORGET" -``` - -To forget from a specific custom container: - -```bash -node ~/.codex/supermemory/forget-memory.js --container "DESCRIPTION_OF_WHAT_TO_FORGET" -``` - -## Examples - -- User says "I no longer use React, I switched to Vue": - - ```bash - node ~/.codex/supermemory/forget-memory.js "user prefers React for frontend development" - ``` - -- User says "forget that API endpoint, it changed": - - ```bash - node ~/.codex/supermemory/forget-memory.js "API endpoint for user authentication" - ``` - -## After Forgetting - -Confirm to the user that the memory has been removed. If they mentioned new information to replace it, use the supermemory-save skill to save the updated information. diff --git a/src/skills/supermemory-login/SKILL.md b/src/skills/supermemory-login/SKILL.md deleted file mode 100644 index a8b9452..0000000 --- a/src/skills/supermemory-login/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: supermemory-login -description: Log in to Supermemory. Use when the user needs to authenticate, set up their API key, or when memory features report a missing key. -allowed-tools: Bash(node:*) ---- - -# Supermemory Login - -Authenticate with Supermemory to enable persistent memory across Codex sessions. - -## Usage - -```bash -node ~/.codex/supermemory/login.js -``` - -## Windows Sandbox - -On Windows, if running the command from Codex, request escalated shell execution immediately instead of trying the sandbox first. The script lives under `~/.codex`, reads/writes auth state there, and may open the browser. Use a narrow approval reason such as: - -> Run Supermemory login from the Codex home directory so it can read credentials and open the browser auth flow. - -This opens a browser window for authentication. Once complete, the API key is saved automatically and memory features activate immediately. - -If the browser does not open, the script prints a URL to visit manually. - -Never print the full API key. If the script reports that Supermemory is already authenticated, tell the user memory is active and do not ask them to log in again. diff --git a/src/skills/supermemory-logout/SKILL.md b/src/skills/supermemory-logout/SKILL.md deleted file mode 100644 index 0a80de8..0000000 --- a/src/skills/supermemory-logout/SKILL.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: supermemory-logout -description: Log out of Supermemory in Codex. Use when the user wants to disconnect Supermemory, remove saved credentials, switch accounts, clear auth state, or stop memory from using the current saved API key. -allowed-tools: Bash(node:*) ---- - -# Supermemory Logout - -Remove saved Supermemory credentials for Codex: - -```bash -node ~/.codex/supermemory/logout.js -``` - -Logout creates an intentional disconnected marker so the automatic recall hook will not reopen browser auth on the next prompt. `/supermemory-status` should report disconnected after logout. `/supermemory-login` clears the marker and reconnects. - -On Windows, if running the command from Codex, request escalated shell execution immediately instead of trying the sandbox first. The script removes auth files under `~/.codex`. Use a narrow approval reason such as: - -> Run Supermemory logout from the Codex home directory so it can remove saved credentials. - -If `SUPERMEMORY_CODEX_API_KEY` is set in the parent shell, the script cannot unset it. Tell the user to unset that environment variable or restart Codex if the script reports it is still active. - -Never print the full API key. diff --git a/src/skills/supermemory-profile/SKILL.md b/src/skills/supermemory-profile/SKILL.md deleted file mode 100644 index 8fad240..0000000 --- a/src/skills/supermemory-profile/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: supermemory-profile -description: View the user's Supermemory profile (persistent facts and recent context). ---- - -# Supermemory Profile - -Fetch the user profile from Supermemory: - -```bash -node profile-memory.js -``` - -Use when the user asks what you remember about them or wants to inspect stored profile facts. diff --git a/src/skills/supermemory-save/SKILL.md b/src/skills/supermemory-save/SKILL.md deleted file mode 100644 index 057ff0d..0000000 --- a/src/skills/supermemory-save/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: supermemory-save -description: Save important project knowledge to memory. Use when user wants to preserve architectural decisions, significant bug fixes, design patterns, or important implementation details for future reference. -allowed-tools: Bash(node:*) ---- - -# Super Save - -Save important project knowledge based on what the user wants to preserve. - -## Step 1: Understand User Request - -Analyze what the user is asking to save from the conversation. - -## Step 2: Format Content - -Format the content to capture the key context: - -``` -[SAVE:] - - wanted to . - -The approach taken was . - -Decision: . - - - -[/SAVE] -``` - -Example: -``` -[SAVE:2025-06-15] - -User wanted to create a skill for saving project knowledge. - -The approach taken was using a separate container tag for shared team knowledge. - -Decision: Keep it simple - no transcript fetching, just save what user asks for. - -Files: src/save-memory.ts, src/skills/super-save/SKILL.md - -[/SAVE] -``` - -Keep it natural. Capture the conversation flow. - -## Step 3: Save - -```bash -node ~/.codex/supermemory/save-memory.js "FORMATTED_CONTENT" -``` - -### Container Routing - -If custom containers are configured (see `[SUPERMEMORY CONTAINERS]` in your context), you can route the memory to a specific container using `--container`: - -```bash -node ~/.codex/supermemory/save-memory.js --container "FORMATTED_CONTENT" -``` - -Choose the container whose description best matches the content being saved. If unsure, omit `--container` to save to the default project container. diff --git a/src/skills/supermemory-search/SKILL.md b/src/skills/supermemory-search/SKILL.md deleted file mode 100644 index 6580b8c..0000000 --- a/src/skills/supermemory-search/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: supermemory-search -description: Search your coding memory. Use when user asks about past work, previous sessions, how something was implemented, what they worked on before, or wants to recall information from earlier sessions. -allowed-tools: Bash(node:*) ---- - -# Super Search - -Search Supermemory for past coding sessions, decisions, and saved information. - -## How to Search - -Run the search script with the user's query and optional scope flag: - -```bash -node ~/.codex/supermemory/search-memory.js [--user|--project|--both|--container ] "USER_QUERY_HERE" -``` - -### Scope Flags - -- `--both` (default): Search both personal and project memories in parallel -- `--user`: Search personal/user memories across sessions -- `--project`: Search project-specific memories -- `--container `: Search a specific custom container (see `[SUPERMEMORY CONTAINERS]` in your context for available containers) - -### Options - -- `--no-profile`: Skip fetching the user profile summary (included by default) - -## Examples - -- User asks "what did I work on yesterday": - - ```bash - node ~/.codex/supermemory/search-memory.js "work yesterday recent activity" - ``` - -- User asks "how did we implement auth" (project-specific): - - ```bash - node ~/.codex/supermemory/search-memory.js --project "authentication implementation" - ``` - -- User asks "what are my coding preferences": - ```bash - node ~/.codex/supermemory/search-memory.js --user "coding preferences style" - ``` - -## Present Results - -The script outputs formatted memory results with relevance information. Present them clearly to the user and offer to search again with different terms if needed. diff --git a/src/skills/supermemory-status/SKILL.md b/src/skills/supermemory-status/SKILL.md index 3459073..51eb422 100644 --- a/src/skills/supermemory-status/SKILL.md +++ b/src/skills/supermemory-status/SKILL.md @@ -1,7 +1,6 @@ --- name: supermemory-status description: Show Supermemory connection status for Codex. Use when the user asks whether Supermemory is connected, which account or API key is active, memory hook health, or plugin status. -allowed-tools: Bash(node:*) --- # Supermemory Status @@ -12,6 +11,10 @@ Show whether Supermemory is connected and which credential source is active: node ~/.codex/supermemory/status.js ``` +Then call the `mcp__supermemory__whoAmI` tool when it is available. Report API +reachability and MCP reachability separately; a working direct API probe does not +prove that the MCP tool path is connected. + ## Windows Sandbox On Windows, if running the command from Codex and sandbox execution is likely to fail for `~/.codex` paths, request escalated shell execution immediately instead of trying the sandbox first. Use a narrow approval reason such as: diff --git a/test/unit.mjs b/test/unit.mjs index a052e73..11cb618 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -318,6 +318,63 @@ describe("cross-container result merging", () => { describe("capture tracker", () => { const trackerModule = new URL("../dist/services/tracker.js", import.meta.url).href; + const captureModule = new URL("../dist/services/capture.js", import.meta.url).href; + + test("reports a successful capture for the Stop hook notice", (t) => { + const homeDir = makeTmpDir(); + t.after(() => rmSync(homeDir, { recursive: true, force: true })); + const transcriptFile = join(homeDir, "transcript.jsonl"); + writeFileSync( + transcriptFile, + [ + JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [ + { type: "input_text", text: "Remember that " }, + { type: "input_text", text: "we use PostgreSQL." }, + ] } }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "Remember that we use PostgreSQL." } }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [ + { type: "output_text", text: "Understood." }, + ] } }), + ].join("\n"), + ); + const script = ` + import { captureEntries } from ${JSON.stringify(captureModule)}; + let capturedContent = ""; + const client = { addMemory: async (content) => { + capturedContent = content; + return { success: true }; + } }; + const result = await captureEntries( + "flush", + client, + "notice-session", + ${JSON.stringify(transcriptFile)}, + { + canonical: "repo_test__1234567890abcdef", + project: "repo_test__1234567890abcdef", + user: "repo_test__1234567890abcdef", + projectName: "test", + projectId: "1234567890abcdef", + }, + ); + console.log(JSON.stringify({ result, capturedContent })); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script], { + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + SUPERMEMORY_CODEX_API_KEY: "sm_test", + }, + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + const captured = JSON.parse(result.stdout); + assert.deepEqual(captured.result, { status: "captured", entryCount: 2 }); + assert.match(captured.capturedContent, /1\. \[user\] Remember that we use PostgreSQL\./); + assert.match(captured.capturedContent, /2\. \[assistant\] Understood\./); + assert.equal(captured.capturedContent.match(/Remember that we use PostgreSQL\./g)?.length, 1); + }); test("serializes overlapping capture transactions and keeps the cursor monotonic", (t) => { const homeDir = makeTmpDir(); @@ -587,20 +644,12 @@ describe("stripPrivateContent", () => { }); describe("browser auth opener", () => { - test("login keeps a long auth window while SessionStart uses a bounded one", () => { - const content = readFileSync(new URL("../dist/skills/login.js", import.meta.url), "utf-8"); - assert.ok(content.includes("Refusing to open non-http URL")); - assert.ok(content.includes("rundll32.exe")); - assert.ok(content.includes("url.dll,FileProtocolHandler")); - assert.ok(!content.includes("explorer.exe")); - + test("SessionStart owns browser authentication with a bounded window", () => { const authSource = readFileSync(new URL("../src/services/auth.ts", import.meta.url), "utf-8"); const sessionStartSource = readFileSync(new URL("../src/hooks/session-start.ts", import.meta.url), "utf-8"); - const loginSource = readFileSync(new URL("../src/skills/login.ts", import.meta.url), "utf-8"); - assert.ok(authSource.includes("5 * 60_000"), "explicit login keeps its long default"); assert.ok(authSource.includes("startAuthFlow(timeoutMs = AUTH_TIMEOUT)")); assert.ok(sessionStartSource.includes("startAuthFlow(getSessionStartAuthTimeoutMs())")); - assert.ok(loginSource.includes("await startAuthFlow();")); + assert.ok(!existsSync(new URL("../src/skills/login.ts", import.meta.url))); }); }); @@ -624,19 +673,6 @@ describe("entity context wiring", () => { assert.ok(content.includes("project: tags.projectName")); }); - test("manual save writes project entity context", () => { - const content = readFileSync(new URL("../src/skills/save-memory.ts", import.meta.url), "utf-8"); - assert.ok(content.includes("PROJECT_ENTITY_CONTEXT")); - assert.ok(content.includes("entityContext: getEntityContext(containerTag)")); - assert.ok(content.includes('sm_scope: "project"')); - }); - - test("personal add writes the unified personal scope", () => { - const content = readFileSync(new URL("../src/skills/add-memory.ts", import.meta.url), "utf-8"); - assert.ok(content.includes("getProjectTag")); - assert.ok(content.includes('sm_scope: "personal"')); - assert.ok(content.includes("entityContext: USER_ENTITY_CONTEXT")); - }); }); describe("hooks.json format", () => { @@ -744,27 +780,34 @@ describe("hooks.json format", () => { describe("integration: install/uninstall", () => { const cliBin = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); - test("install copies skill SKILL.md files to ~/.codex/skills/", (t) => { + test("install keeps only status skill and registers hosted MCP", (t) => { const { tmpDir, codexDir } = setupCodexHome(t); + for (const legacy of ["supermemory-search", "supermemory-login", "supermemory-logout"]) { + mkdirSync(join(codexDir, "skills", legacy), { recursive: true }); + writeFileSync(join(codexDir, "skills", legacy, "SKILL.md"), "legacy"); + } + const result = runCli(cliBin, "install", tmpDir); assert.equal(result.status, 0, `install should exit 0: ${result.stderr}`); const skillsDir = join(codexDir, "skills"); - for (const skillName of ["supermemory-search", "supermemory-add", "supermemory-save", "supermemory-forget", "supermemory-status", "supermemory-login", "supermemory-logout"]) { - const skillMd = join(skillsDir, skillName, "SKILL.md"); - assert.ok(existsSync(skillMd), `${skillName}/SKILL.md should exist`); - const content = readFileSync(skillMd, "utf-8"); - assert.ok( - content.includes(`name: ${skillName}`), - `SKILL.md should contain name: ${skillName}` - ); + assert.ok(existsSync(join(skillsDir, "supermemory-status", "SKILL.md"))); + for (const legacy of ["supermemory-search", "supermemory-login", "supermemory-logout"]) { + assert.ok(!existsSync(join(skillsDir, legacy)), `${legacy} should be removed`); } + + const toml = readToml(join(codexDir, "config.toml")); + assert.equal(toml.mcp_servers.supermemory.command, "node"); + assert.deepEqual(toml.mcp_servers.supermemory.args, [ + join(codexDir, "supermemory", "mcp-proxy.js"), + ]); const config = JSON.parse(readFileSync(join(codexDir, "supermemory.json"), "utf-8")); assert.equal(config.recallMode, "direct"); + assert.equal(config.captureEveryNTurns, 0); }); - test("install upgrades capture hooks to background handlers", (t) => { + test("install registers synchronous recall hooks and background capture", (t) => { const { tmpDir, codexDir } = setupCodexHome(t); const hooksPath = join(codexDir, "hooks.json"); const flushCmd = `node ${join(codexDir, "supermemory", "flush.js")}`; @@ -780,11 +823,13 @@ describe("integration: install/uninstall", () => { const hooks = JSON.parse(readFileSync(hooksPath, "utf-8")).hooks; const stop = hooks.Stop.flatMap((group) => group.hooks) .find((hook) => hook.command === flushCmd); - const turnCaptureCmd = `node ${join(codexDir, "supermemory", "capture-turn.js")}`; - const turnCapture = hooks.UserPromptSubmit.flatMap((group) => group.hooks) - .find((hook) => hook.command === turnCaptureCmd); const recall = hooks.UserPromptSubmit.flatMap((group) => group.hooks) .find((hook) => hook.command.endsWith("/recall.js")); + const recallApprove = hooks.PreToolUse.flatMap((group) => group.hooks) + .find((hook) => hook.command.endsWith("/recall-approve.js")); + const recallApproveGroup = hooks.PreToolUse.find((group) => + group.hooks.includes(recallApprove) + ); const sessionStart = hooks.SessionStart.flatMap((group) => group.hooks) .find((hook) => hook.command.endsWith("/session-start.js")); @@ -792,12 +837,12 @@ describe("integration: install/uninstall", () => { { async: stop.async, timeout: stop.timeout }, { async: true, timeout: 30 }, ); - assert.deepEqual( - { async: turnCapture.async, timeout: turnCapture.timeout }, - { async: true, timeout: 30 }, - ); assert.equal(recall.async, undefined); assert.equal(recall.timeout, 5); + assert.equal(recallApprove.async, undefined); + assert.equal(recallApprove.timeout, 5); + assert.equal(recallApproveGroup.matcher, "^mcp__supermemory__"); + assert.ok(!existsSync(join(codexDir, "supermemory", "capture-turn.js"))); assert.equal(sessionStart.async, undefined); assert.equal(sessionStart.timeout, 30); }); @@ -811,12 +856,7 @@ describe("integration: install/uninstall", () => { assert.equal(uninstallResult.status, 0, `uninstall should exit 0: ${uninstallResult.stderr}`); const skillsDir = join(codexDir, "skills"); - for (const skillName of ["supermemory-search", "supermemory-add", "supermemory-save", "supermemory-forget", "supermemory-status", "supermemory-login", "supermemory-logout"]) { - assert.ok( - !existsSync(join(skillsDir, skillName)), - `${skillName} skill dir should be removed` - ); - } + assert.ok(!existsSync(join(skillsDir, "supermemory-status"))); }); test("uninstall drops empty [features] section", (t) => { @@ -916,7 +956,8 @@ describe("integration: install/uninstall", () => { const config = readToml(configPath); assert.equal(config.model, "gpt-5"); assert.equal(config.features.web_search, true); - assert.equal(config.features.codex_hooks, true); + assert.equal(config.features.codex_hooks, undefined); + assert.equal(config.mcp_servers.supermemory.command, "node"); }); }); @@ -954,24 +995,6 @@ describe("recall hook output envelope", () => { ); }); - test("exits silently after explicit logout marker", (t) => { - const tmpDir = makeTmpDir(); - const supermemoryDir = join(tmpDir, ".codex", "supermemory"); - mkdirSync(supermemoryDir, { recursive: true }); - writeFileSync(join(supermemoryDir, ".logged-out"), new Date().toISOString()); - t.after(() => rmSync(tmpDir, { recursive: true, force: true })); - - const result = spawnSync("node", [recallBin], { - input: JSON.stringify({ session_id: "s1", prompt: "$supermemory-status" }), - env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir, SUPERMEMORY_CODEX_API_KEY: "" }, - encoding: "utf-8", - timeout: 5_000, - }); - - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - }); - test("emits no envelope on empty prompt (so Codex doesn't render an empty hook context line)", () => { const result = spawnSync("node", [recallBin], { input: JSON.stringify({ session_id: "s1", prompt: "" }), @@ -1018,27 +1041,39 @@ describe("recall hook output envelope", () => { }); }); -// ─── session-start hook logout behavior ────────────────────────────────────── +describe("hosted MCP hooks", () => { + const approveBin = fileURLToPath(new URL("../dist/hooks/recall-approve.js", import.meta.url)); + const proxyBin = fileURLToPath(new URL("../dist/hooks/mcp-proxy.js", import.meta.url)); -describe("session-start hook logout behavior", () => { - const sessionStartBin = fileURLToPath(new URL("../dist/hooks/session-start.js", import.meta.url)); + test("read-only searches show the query and are allowed", () => { + const result = spawnSync("node", [approveBin], { + input: JSON.stringify({ + tool_name: "mcp__supermemory__search_memory", + tool_input: { query: "company employment full time" }, + }), + encoding: "utf-8", + }); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.systemMessage, "β—ͺ supermemory Β· recalling: company employment full time"); + assert.equal(output.hookSpecificOutput.permissionDecision, "allow"); + assert.deepEqual(output.hookSpecificOutput.updatedInput, { + query: "company employment full time", + }); + }); - test("exits silently after explicit logout marker", (t) => { + test("MCP proxy fails clearly when SessionStart has not authenticated", (t) => { const tmpDir = makeTmpDir(); - const supermemoryDir = join(tmpDir, ".codex", "supermemory"); - mkdirSync(supermemoryDir, { recursive: true }); - writeFileSync(join(supermemoryDir, ".logged-out"), new Date().toISOString()); t.after(() => rmSync(tmpDir, { recursive: true, force: true })); - - const result = spawnSync("node", [sessionStartBin], { - input: JSON.stringify({ session_id: "s1" }), + const result = spawnSync("node", [proxyBin], { + input: `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" })}\n`, env: { ...process.env, HOME: tmpDir, USERPROFILE: tmpDir, SUPERMEMORY_CODEX_API_KEY: "" }, encoding: "utf-8", - timeout: 5_000, }); - - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.equal(output.error.code, -32001); + assert.match(output.error.message, /Start a new Codex task/); }); }); @@ -1111,19 +1146,10 @@ describe("flush hook Stop payload", () => { }); }); -// ─── skill scripts (search/save/forget/status/logout) ─────────────────────── -// -// These scripts (dist/skills/*.js) are entry-points invoked by Codex skills. -// They reuse SupermemoryClient + tags, so we only smoke-test the CLI shape: -// argument parsing, the unconfigured-fallback message, and clean exit codes. - -describe("skill scripts: search/add/save/forget/status/logout", () => { - const searchBin = fileURLToPath(new URL("../dist/skills/search-memory.js", import.meta.url)); - const addBin = fileURLToPath(new URL("../dist/skills/add-memory.js", import.meta.url)); - const saveBin = fileURLToPath(new URL("../dist/skills/save-memory.js", import.meta.url)); - const forgetBin = fileURLToPath(new URL("../dist/skills/forget-memory.js", import.meta.url)); +// ─── status skill ─────────────────────────────────────────────────────────── + +describe("status skill", () => { const statusBin = fileURLToPath(new URL("../dist/skills/status.js", import.meta.url)); - const logoutBin = fileURLToPath(new URL("../dist/skills/logout.js", import.meta.url)); // Run a script with a fresh empty $HOME (no config file) and an empty // SUPERMEMORY_CODEX_API_KEY so isConfigured() is false. Returns the spawn result. @@ -1149,48 +1175,11 @@ describe("skill scripts: search/add/save/forget/status/logout", () => { }); } - // Run a script with a (fake) API key but no network. We expect arg-parsing - // branches (missing query/content) to short-circuit before any network call. - function runSkillNoArgs(t, bin) { - const tmpDir = makeTmpDir(); - mkdirSync(join(tmpDir, ".codex"), { recursive: true }); - t.after(() => rmSync(tmpDir, { recursive: true, force: true })); - return spawnSync("node", [bin], { - env: { PATH: process.env.PATH, HOME: tmpDir, USERPROFILE: tmpDir, SUPERMEMORY_CODEX_API_KEY: "sm_test" }, - encoding: "utf-8", - }); - } - - test("search-memory prints not-configured message and exits 1 when no API key", (t) => { - const result = runSkillUnconfigured(t, searchBin, ["hello"]); - assert.equal(result.status, 1); - assert.match(result.stderr, /Supermemory is not authenticated/); - assert.match(result.stderr, /supermemory-login/); - }); - - test("save-memory prints not-configured message and exits 1 when no API key", (t) => { - const result = runSkillUnconfigured(t, saveBin, ["some content"]); - assert.equal(result.status, 1); - assert.match(result.stderr, /Supermemory is not authenticated/); - }); - - test("add-memory prints not-configured message and exits 1 when no API key", (t) => { - const result = runSkillUnconfigured(t, addBin, ["some content"]); - assert.equal(result.status, 1); - assert.match(result.stderr, /Supermemory is not authenticated/); - }); - - test("forget-memory prints not-configured message and exits 1 when no API key", (t) => { - const result = runSkillUnconfigured(t, forgetBin, ["some content"]); - assert.equal(result.status, 1); - assert.match(result.stderr, /Supermemory is not authenticated/); - }); - test("status prints disconnected state and exits 0 when no API key", (t) => { const result = runSkillUnconfigured(t, statusBin, []); assert.equal(result.status, 0); assert.match(result.stdout, /Connected: no/); - assert.match(result.stdout, /supermemory-login/); + assert.match(result.stdout, /Start a new Codex task/); }); test("status reports auto-recall off when auto recall is disabled", (t) => { @@ -1211,90 +1200,10 @@ describe("skill scripts: search/add/save/forget/status/logout", () => { assert.match(result.stdout, /Auto-recall: advisory/); }); - test("status reports auto-capture off when captureEveryNTurns is zero", (t) => { - const result = runStatusWithConfig(t, { autoRecallEveryPrompt: false, captureEveryNTurns: 0 }); - assert.equal(result.status, 0); - assert.match(result.stdout, /Auto-capture: off/); - }); - - test("status reports configured auto-capture cadence from captureEveryNTurns", (t) => { + test("status reports turn-stop capture regardless of legacy cadence", (t) => { const result = runStatusWithConfig(t, { autoRecallEveryPrompt: false, captureEveryNTurns: 5, autoSaveEveryTurns: 3 }); assert.equal(result.status, 0); - assert.match(result.stdout, /Auto-capture: every 5 turns/); - assert.doesNotMatch(result.stdout, /every 3 turns/); - }); - - test("logout removes saved credentials and config apiKey", (t) => { - const tmpDir = makeTmpDir(); - const codexDir = join(tmpDir, ".codex"); - const supermemoryDir = join(codexDir, "supermemory"); - mkdirSync(supermemoryDir, { recursive: true }); - writeFileSync(join(supermemoryDir, "credentials.json"), JSON.stringify({ apiKey: "sm_test" })); - writeFileSync(join(supermemoryDir, ".auth-attempted"), new Date().toISOString()); - writeFileSync(join(codexDir, "supermemory.json"), JSON.stringify({ apiKey: "sm_config", maxMemories: 3 })); - t.after(() => rmSync(tmpDir, { recursive: true, force: true })); - - const result = spawnSync("node", [logoutBin], { - env: { PATH: process.env.PATH, HOME: tmpDir, USERPROFILE: tmpDir, SUPERMEMORY_CODEX_API_KEY: "" }, - encoding: "utf-8", - }); - - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /Logged out/); - assert.ok(!existsSync(join(supermemoryDir, "credentials.json")), "credentials should be removed"); - assert.ok(!existsSync(join(supermemoryDir, ".auth-attempted")), "auth marker should be removed"); - assert.ok(existsSync(join(supermemoryDir, ".logged-out")), "logged-out marker should be created"); - assert.deepEqual(JSON.parse(readFileSync(join(codexDir, "supermemory.json"), "utf-8")), { maxMemories: 3 }); - }); - - test("search-memory prints usage and exits 0 when no query is given", (t) => { - const result = runSkillNoArgs(t, searchBin); - assert.equal(result.status, 0); - assert.match(result.stdout, /No search query provided/); - assert.match(result.stdout, /node search-memory\.js/); - }); - - test("save-memory prints usage and exits 0 when no content is given", (t) => { - const result = runSkillNoArgs(t, saveBin); - assert.equal(result.status, 0); - assert.match(result.stdout, /No content provided/); - assert.match(result.stdout, /node save-memory\.js/); - }); - - test("add-memory prints usage and exits 0 when no content is given", (t) => { - const result = runSkillNoArgs(t, addBin); - assert.equal(result.status, 0); - assert.match(result.stdout, /No content provided/); - assert.match(result.stdout, /node add-memory\.js/); - }); - - test("forget-memory prints usage and exits 0 when no content is given", (t) => { - const result = runSkillNoArgs(t, forgetBin); - assert.equal(result.status, 0); - assert.match(result.stdout, /No content provided/); - assert.match(result.stdout, /node forget-memory\.js/); - }); - - test("search-memory only treats --user/--project/--both/--no-profile as flags; other args become the query", (t) => { - // With a fresh HOME and no API key, every invocation hits the unconfigured - // branch β€” which is fine. The point of this test is to assert that the - // script *runs at all* (i.e. arg-parsing doesn't crash) for every flag - // permutation we expect users to send. - for (const args of [ - ["--user", "find", "thing"], - ["--project", "find", "thing"], - ["--both", "find", "thing"], - ["--no-profile", "find", "thing"], - ["--user", "--no-profile", "find", "thing"], - ]) { - const result = runSkillUnconfigured(t, searchBin, args); - assert.equal(result.status, 1, `flags ${args.join(" ")} should exit 1 when unconfigured`); - assert.match( - result.stderr, - /Supermemory is not authenticated/, - `flags ${args.join(" ")} should hit the unconfigured branch` - ); - } + assert.match(result.stdout, /Auto-capture: after completed turns/); }); });