memoryrouter-codex 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,17 +6,27 @@ Native Codex integration for durable MemoryRouter memory. It uses Codex's curren
6
6
 
7
7
  ## What this integration does
8
8
 
9
+ Every turn, automatically: **recall before the prompt, save after the turn.** What is saved is exactly two things per turn, the user's message and the assistant's final response. Tool calls, tool output, reasoning, plans, intermediate assistant messages and subagent output are never saved.
10
+
9
11
  | Layer | Behavior |
10
12
  | --- | --- |
11
- | `UserPromptSubmit` hook | Searches your whole MemoryRouter vault before every prompt and injects bounded context as developer context. The vault is shared with Claude Code and every other MemoryRouter surface. |
12
- | `Stop` hook | Captures only the user request and final assistant response after each completed turn. Tool names, inputs, and results are excluded. |
13
- | `SubagentStop` hook | Captures the stable final outcome of completed subagents without parsing their unstable transcript files. |
14
- | `PreCompact` / compact `SessionStart` | Checkpoints only the current user request and final assistant response when present, then recalls context before Codex continues after compaction. |
15
- | `SessionEnd` | Best-effort message-only final checkpoint within Codex's three-second SessionEnd budget. |
16
- | Remote MCP | OAuth-based explicit search/store through `https://mcp.memoryrouter.ai/mcp`. |
17
- | Local MCP fallback | API-key-authenticated stdio tools over the same whole vault, with handles, local suppression, status, and full-vault clear confirmation. |
13
+ | `UserPromptSubmit` hook | Searches your whole MemoryRouter vault with the prompt and injects what it finds as developer context for that turn. The vault is shared with Claude Code and every other MemoryRouter surface. Records the prompt for this turn. |
14
+ | `Stop` hook | Saves the user's message and the final assistant response for the turn. Nothing else. |
15
+ | `SessionStart` hook | Restores context after Codex compacts a long chat (key mode also on new/resumed chats). |
16
+ | `SessionEnd` (key mode) | Only if the last turn never finished (interrupted), saves that turn's user message. A completed turn is never saved twice. |
17
+ | Remote MCP | OAuth-based explicit search/store through `https://mcp.memoryrouter.ai/mcp` (`search_memories`, `store_memory`, ...). |
18
+ | Local MCP fallback | API-key-authenticated stdio tools over the same whole vault. |
18
19
  | `AGENTS.md` | Tells Codex when to recall/store and how MemoryRouter coexists with native Memories. |
19
20
 
21
+ **Two ways the hooks reach MemoryRouter (1.8.0):**
22
+
23
+ - **Over your MemoryRouter sign-in (no API key).** Default when you signed in with OAuth (Codex app Settings > Plugins > Authenticate, or `codex mcp login memoryrouter`) and no key is configured. The hooks are Codex `mcp_tool` hooks that call hidden lifecycle tools on the already-connected `memoryrouter` server (`codex_turn_start`, `codex_turn_end`, `codex_session_start`) with your OAuth token and the vault you picked when you signed in. Nothing secret is written to disk.
24
+ - **Local runtime with a Memory Key.** Used when a key is configured (`MEMORYROUTER_API_KEY`, `--api-key-stdin`, or `memoryrouter-claude`'s `~/.memoryrouter/config.json`). Same per-turn behavior.
25
+
26
+ Force either with `--hooks-via mcp` or `--hooks-via command`.
27
+
28
+ 1.8.0 removed the `SubagentStop` and `PreCompact` capture hooks: subagent output is intermediate work, and the PreCompact checkpoint re-saved a prompt the `Stop` hook already captured.
29
+
20
30
  MemoryRouter is the **cross-machine, cross-tool, team-shareable, and auditable** layer: one memory across every session, repo and tool, shared by Codex, Claude Code, ChatGPT, and Claude. Codex's native Memories remain useful as an automated local personal recall layer.
21
31
 
22
32
  ## Requirements and supported surfaces
@@ -24,7 +34,7 @@ MemoryRouter is the **cross-machine, cross-tool, team-shareable, and auditable**
24
34
  - Node.js 18 or newer.
25
35
  - **Codex 0.145.0 or newer** for the complete lifecycle-hook integration. Version 0.146.0 was tested directly.
26
36
  - Local Codex CLI, ChatGPT desktop app's local Codex host, and Codex IDE extension share `config.toml` MCP configuration.
27
- - User/project hooks require the Codex host to support and trust command hooks. Use `/hooks` to review them.
37
+ - Hooks must be trusted once: Codex app **Settings > Hooks**, or `/hooks` in the CLI. OAuth (`mcp_tool`) hooks need a Codex build with MCP tool hooks (the current app and CLI 0.166 have them).
28
38
  - Project `.codex` config is loaded only for trusted projects.
29
39
  - ChatGPT web does **not** read a laptop's `~/.codex/config.toml` or local hooks. Use MemoryRouter's hosted plugin/connector surface there.
30
40
  - Codex cloud environments do not automatically inherit host-local user config or secrets. A project-scope install can travel with a repository, but the environment still needs Node, project trust, package/network availability for the stdio fallback, and `MEMORYROUTER_API_KEY` for hooks.
@@ -33,9 +43,30 @@ There is no dependency on the obsolete `experimental_use_rmcp_client` setting in
33
43
 
34
44
  ## Install
35
45
 
36
- ### Recommended: remote OAuth MCP plus automatic hooks
46
+ ### Codex app (Mac): two steps, no key
47
+
48
+ 1. Add the server in the app: **Settings > Plugins > Add > Add MCP server**, Type **Streamable HTTP**, Name `memoryrouter`, URL `https://mcp.memoryrouter.ai/mcp`, **Save**, then **Authenticate** and sign in (or create an account) in the browser tab that opens.
49
+ 2. In a Codex chat, paste:
50
+
51
+ > Run `npx -y memoryrouter-codex@latest init` with network access, then tell me to trust the MemoryRouter hooks in Settings > Hooks.
52
+
53
+ Then trust the three MemoryRouter hooks in **Settings > Hooks** and open a **New chat**.
54
+
55
+ `init` keeps the server the app added (and its sign-in), adds the three `mcp_tool` hooks to `~/.codex/hooks.json` and the MemoryRouter guidance to `~/.codex/AGENTS.md`. The Codex CLI and IDE extension on the same Mac share all of it.
56
+
57
+ ### Codex CLI, OAuth (no key)
58
+
59
+ ```bash
60
+ npx -y memoryrouter-codex init --scope user
61
+ codex mcp login memoryrouter
62
+ npx -y memoryrouter-codex doctor --scope user
63
+ ```
64
+
65
+ Then start Codex, trust the MemoryRouter hooks in `/hooks`, and check `/mcp verbose`.
66
+
67
+ ### With a Memory Key (local hook runtime)
37
68
 
38
- Obtain a MemoryRouter memory key from your account. Pass it on stdin so it does not appear in shell history:
69
+ Pass the key on stdin so it does not appear in shell history:
39
70
 
40
71
  ```bash
41
72
  printf '%s' "$MEMORYROUTER_API_KEY" | \
@@ -56,7 +87,7 @@ The API key is written to `~/.memoryrouter/codex/config.json` with mode `0600`;
56
87
 
57
88
  Already using MemoryRouter in Claude Code? When no Codex key is configured, the hooks reuse the `memoryrouter-claude` key in `~/.memoryrouter/config.json`, so both agents use the same vault (one memory for every session, repo and tool) with no second paste. Key precedence: `MEMORYROUTER_API_KEY`, then the Codex private config, then the Claude Code config.
58
89
 
59
- If you prefer not to persist the key, omit `--api-key-stdin` and ensure `MEMORYROUTER_API_KEY` is in the environment of the Codex host. Without either source, remote OAuth MCP tools can work after login, but automatic hooks intentionally become no-op/fail-open.
90
+ If you prefer not to persist the key, omit `--api-key-stdin` and ensure `MEMORYROUTER_API_KEY` is in the environment of the Codex host. Without any key, `init` installs the OAuth (`mcp_tool`) hooks instead, which work as soon as you are signed in.
60
91
 
61
92
  ### Project scope
62
93
 
package/bin/cli.cjs CHANGED
@@ -23,7 +23,7 @@ function parseArgs(argv) {
23
23
  }
24
24
 
25
25
  function usage() {
26
- return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex import [--instructions [--run]] [--file PATH] [--dismiss] [--yes]\n memoryrouter-codex mcp-server\n\nImport:\n Import historical Codex conversations into your vault. --instructions\n fetches the server's extraction prompt for the host agent to execute\n (--run spawns it via codex exec); --file uploads a JSONL extract with a\n preview, price quote, explicit approval (--yes for non-interactive),\n idempotent batches, and resume; --dismiss silences the import nudge\n permanently. Re-running --file is always safe: committed batches replay\n for free and duplicates are never re-billed.\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n Automatic mode: when the server reports consolidation debt above its\n threshold, the hooks spawn this same loop in the background (max 3 batches\n per trigger, 30 minute cooldown, machine-wide lockfile at\n ~/.memoryrouter/consolidate.lock). Opt out with "autoConsolidate": false in\n ~/.memoryrouter/codex/config.json. Logs: ~/.memoryrouter/consolidate.log\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY, a key saved with --api-key-stdin, or\n an existing memoryrouter-claude key in ~/.memoryrouter/config.json.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
26
+ return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--hooks-via mcp|command] [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex import [--instructions [--run]] [--file PATH] [--dismiss] [--yes]\n memoryrouter-codex mcp-server\n\nImport:\n Import historical Codex conversations into your vault. --instructions\n fetches the server's extraction prompt for the host agent to execute\n (--run spawns it via codex exec); --file uploads a JSONL extract with a\n preview, price quote, explicit approval (--yes for non-interactive),\n idempotent batches, and resume; --dismiss silences the import nudge\n permanently. Re-running --file is always safe: committed batches replay\n for free and duplicates are never re-billed.\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n Automatic mode: when the server reports consolidation debt above its\n threshold, the hooks spawn this same loop in the background (max 3 batches\n per trigger, 30 minute cooldown, machine-wide lockfile at\n ~/.memoryrouter/consolidate.lock). Opt out with "autoConsolidate": false in\n ~/.memoryrouter/codex/config.json. Logs: ~/.memoryrouter/consolidate.log\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Sign in once (Codex app: Settings > Plugins > Authenticate; CLI:\n \`codex mcp login memoryrouter\`). With no API key, the automatic\n hooks call MemoryRouter over that same sign-in (Codex mcp_tool\n hooks): recall before every prompt, save the prompt and the final\n response after every turn. No key on disk. If a key is configured\n (MEMORYROUTER_API_KEY, --api-key-stdin, or memoryrouter-claude's\n ~/.memoryrouter/config.json) the local hook runtime is used\n instead; force either with --hooks-via mcp|command.\n A server you already added in the Codex app is kept as is.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
27
27
  }
28
28
 
29
29
  function optionsFromArgs(args) {
@@ -35,6 +35,7 @@ function optionsFromArgs(args) {
35
35
  agents: args.agents,
36
36
  force: Boolean(args.force),
37
37
  offline: Boolean(args.offline),
38
+ ...(args.hooksVia ? { hookMode: String(args.hooksVia) } : {}),
38
39
  };
39
40
  if (args.apiKeyStdin) {
40
41
  options.apiKey = fs.readFileSync(0, 'utf8').trim();
@@ -122,20 +123,26 @@ async function main() {
122
123
  if (binding.status === 'mismatch') throw new VaultMismatchError(binding);
123
124
  }
124
125
  const result = init(options);
125
- process.stdout.write(`MemoryRouter Codex ${INTEGRATION_VERSION} installed (${result.layout.scope}, ${result.auth}).\n`);
126
- process.stdout.write(` config: ${result.layout.configFile}\n`);
126
+ process.stdout.write(`MemoryRouter Codex ${INTEGRATION_VERSION} installed (${result.layout.scope}, ${result.auth}${result.hookMode === 'mcp' ? ', hooks over your MemoryRouter sign-in' : ''}).\n`);
127
+ process.stdout.write(` config: ${result.layout.configFile}${result.adoptedServer ? ` (kept your existing "${result.adoptedServer}" server)` : ''}\n`);
127
128
  if (options.hooks !== false) process.stdout.write(` hooks: ${result.layout.hooksFile}\n`);
128
129
  if (options.agents !== false) process.stdout.write(` guide: ${result.layout.agentsFile}\n`);
129
130
  if (binding && binding.status === 'match') process.stdout.write(`\nVault check: the hooks use the same vault you selected in OAuth, ${vaultLabel(binding)}.\n`);
130
131
  else if (binding && binding.status === 'mixed') process.stdout.write(`\nVault check: ${binding.message}\n`);
131
132
  else if (binding && binding.status === 'unknown') process.stdout.write(`\nVault check skipped (${binding.message}). Run doctor later to confirm one vault.\n`);
132
- if (result.auth === 'oauth') {
133
+ if (result.hookMode === 'mcp') {
134
+ process.stdout.write('\nAutomatic memory: before every prompt Codex searches your vault and adds what it finds as context; after every turn it saves your message and its final response (never tool calls, tool output or intermediate steps). No API key needed.\n');
135
+ process.stdout.write('\nNext:\n');
136
+ if (!result.adoptedServer) process.stdout.write(' 1. Sign in: Codex app Settings > Plugins > memoryrouter > Authenticate (or run: codex mcp login memoryrouter)\n');
137
+ process.stdout.write(` ${result.adoptedServer ? '1' : '2'}. Trust the MemoryRouter hooks once: Codex app Settings > Hooks (or /hooks in the CLI)\n`);
138
+ process.stdout.write(` ${result.adoptedServer ? '2' : '3'}. Start a New chat\n`);
139
+ } else if (result.auth === 'oauth') {
133
140
  const pick = binding && binding.vault ? ` and select the vault ${vaultLabel(binding)} (the one your hooks use)` : '';
134
141
  process.stdout.write(`\nNext: codex mcp login memoryrouter${pick}\nThen open Codex, run /hooks and trust the MemoryRouter hooks, and check /mcp verbose.\n`);
135
142
  }
136
143
  else process.stdout.write('\nNext: open Codex, run /hooks, and trust the MemoryRouter hooks.\n');
137
144
  if (result.keySource === 'claude-code-config') process.stdout.write('\nAutomatic hooks reuse your Claude Code Memory Key (~/.memoryrouter/config.json), so both agents share the same vault.\n');
138
- if (!result.automaticMemory) process.stdout.write('\nRemote MCP tools are configured, but automatic hooks are fail-open/no-op until MEMORYROUTER_API_KEY is available or init is re-run with --api-key-stdin.\n');
145
+ if (!result.automaticMemory && options.hooks !== false) process.stdout.write('\nRemote MCP tools are configured, but automatic hooks are fail-open/no-op until MEMORYROUTER_API_KEY is available or init is re-run with --api-key-stdin.\n');
139
146
  const scopeArgs = result.layout.scope === 'project' ? `--scope project --project-dir ${JSON.stringify(result.layout.root)}` : '--scope user';
140
147
  process.stdout.write(`\nVerify: npx -y memoryrouter-codex@${INTEGRATION_VERSION} doctor ${scopeArgs}\n`);
141
148
  process.stdout.write('One memory across every session, repo and tool: Codex, Claude Code (memoryrouter-claude) and the MemoryRouter MCP all use the same vault.\n');
package/lib/hooks.cjs CHANGED
@@ -5,7 +5,14 @@ const path = require('path');
5
5
  const { atomicWrite, readText } = require('./managed.cjs');
6
6
  const { INTEGRATION_VERSION } = require('../runtime/constants.cjs');
7
7
 
8
+ // The hidden Codex lifecycle tools on the MemoryRouter MCP server
9
+ // (mcp-server/src/tools/codex-hooks.ts).
10
+ const MCP_HOOK_TOOLS = new Set(['codex_turn_start', 'codex_turn_end', 'codex_session_start']);
11
+
8
12
  function isOwnedHook(handler) {
13
+ if (handler?.type === 'mcp_tool') {
14
+ return handler.server === 'memoryrouter' && MCP_HOOK_TOOLS.has(handler.tool);
15
+ }
9
16
  return handler?.type === 'command' && typeof handler.command === 'string' &&
10
17
  (/memoryrouter[\\/]runtime[\\/]hook\.cjs/.test(handler.command) || /memoryrouter-codex(?:@[^ ]+)?\s+hook\b/.test(handler.command));
11
18
  }
@@ -49,16 +56,47 @@ function managedGroups(runtimeDir, scope) {
49
56
  ...(statusMessage ? { statusMessage } : {}),
50
57
  ...(additionalContextLimit ? { additionalContextLimit } : {}),
51
58
  });
59
+ // Per turn: recall before the prompt, save the user's prompt and the final
60
+ // assistant response after the turn. Nothing in between (no tool calls, tool
61
+ // output, reasoning, subagent output). SessionEnd only saves the prompt of a
62
+ // last turn that never finished (interrupted), see runtime/hook.cjs flush.
52
63
  return {
53
64
  SessionStart: [{ matcher: 'startup|resume|clear|compact', hooks: [handler('session-start', 8, 'Recalling MemoryRouter context', 3500)] }],
54
- UserPromptSubmit: [{ hooks: [handler('inject', 8, 'Searching project memory', 3500)] }],
55
- Stop: [{ hooks: [handler('capture', 18, 'Saving durable project memory')] }],
56
- SubagentStop: [{ hooks: [handler('capture-subagent', 15, 'Saving durable subagent outcome')] }],
57
- PreCompact: [{ matcher: 'manual|auto', hooks: [handler('flush', 18, 'Saving memory before compaction')] }],
65
+ UserPromptSubmit: [{ hooks: [handler('inject', 8, 'Searching MemoryRouter', 3500)] }],
66
+ Stop: [{ hooks: [handler('capture', 18, 'Saving to MemoryRouter')] }],
58
67
  SessionEnd: [{ hooks: [handler('flush', 3)] }],
59
68
  };
60
69
  }
61
70
 
71
+ /**
72
+ * OAuth hooks: no API key and no local runtime. Codex calls hidden tools on the
73
+ * already-authenticated `memoryrouter` MCP server (`type: "mcp_tool"`), with
74
+ * the user's OAuth token and the vault picked during sign-in.
75
+ * UserPromptSubmit -> codex_turn_start: recall for this prompt (returned as
76
+ * developer context), then save the prompt.
77
+ * Stop -> codex_turn_end: save the final assistant message.
78
+ * SessionStart (compact) -> codex_session_start: restore context right after
79
+ * Codex compacts a long chat.
80
+ * Codex does not support mcp_tool hooks on SessionEnd; none is needed.
81
+ */
82
+ function mcpManagedGroups(serverName = 'memoryrouter') {
83
+ const turn = { session_id: '${session_id}', turn_id: '${turn_id}', cwd: '${cwd}' };
84
+ return {
85
+ // Only after compaction: on startup/resume/clear the first prompt's
86
+ // codex_turn_start recall already injects context (no double injection).
87
+ SessionStart: [{
88
+ matcher: 'compact',
89
+ hooks: [{ type: 'mcp_tool', server: serverName, tool: 'codex_session_start', input: { session_id: '${session_id}', cwd: '${cwd}', source: '${source}' }, timeout: 12, statusMessage: 'Recalling MemoryRouter context' }],
90
+ }],
91
+ UserPromptSubmit: [{
92
+ hooks: [{ type: 'mcp_tool', server: serverName, tool: 'codex_turn_start', input: { prompt: '${prompt}', ...turn }, timeout: 12, statusMessage: 'Searching MemoryRouter' }],
93
+ }],
94
+ Stop: [{
95
+ hooks: [{ type: 'mcp_tool', server: serverName, tool: 'codex_turn_end', input: { last_assistant_message: '${last_assistant_message}', ...turn }, timeout: 15, statusMessage: 'Saving to MemoryRouter' }],
96
+ }],
97
+ };
98
+ }
99
+
62
100
  function parseHooks(file) {
63
101
  const raw = readText(file);
64
102
  if (!raw.trim()) return { description: 'Lifecycle hooks for this Codex configuration.', hooks: {} };
@@ -71,18 +109,19 @@ function parseHooks(file) {
71
109
  }
72
110
  }
73
111
 
74
- function installHooks(file, runtimeDir, scope) {
112
+ function installHooks(file, runtimeDir, scope, options = {}) {
75
113
  const current = parseHooks(file);
76
114
  const clean = cleanOwnedHooks(current);
77
115
  clean.description = clean.description || 'Lifecycle hooks for this Codex configuration.';
78
116
  clean.hooks = clean.hooks || {};
79
- for (const [event, groups] of Object.entries(managedGroups(runtimeDir, scope))) {
117
+ const groupsToInstall = options.mode === 'mcp' ? mcpManagedGroups(options.serverName) : managedGroups(runtimeDir, scope);
118
+ for (const [event, groups] of Object.entries(groupsToInstall)) {
80
119
  clean.hooks[event] = [...(clean.hooks[event] || []), ...groups];
81
120
  }
82
121
  const rendered = `${JSON.stringify(clean, null, 2)}\n`;
83
122
  const previous = readText(file);
84
123
  atomicWrite(file, rendered);
85
- return { changed: rendered !== previous, file, events: Object.keys(managedGroups(runtimeDir, scope)) };
124
+ return { changed: rendered !== previous, file, mode: options.mode === 'mcp' ? 'mcp' : 'command', events: Object.keys(groupsToInstall) };
86
125
  }
87
126
 
88
127
  function uninstallHooks(file) {
@@ -99,4 +138,4 @@ function uninstallHooks(file) {
99
138
  return { changed: true, file };
100
139
  }
101
140
 
102
- module.exports = { isOwnedHook, cleanOwnedHooks, commandFor, managedGroups, parseHooks, installHooks, uninstallHooks };
141
+ module.exports = { isOwnedHook, cleanOwnedHooks, commandFor, managedGroups, mcpManagedGroups, MCP_HOOK_TOOLS, parseHooks, installHooks, uninstallHooks };
package/lib/installer.cjs CHANGED
@@ -9,9 +9,9 @@ const { resolveProject } = require('../runtime/project.cjs');
9
9
  const { MCP_ENDPOINT, INTEGRATION_VERSION } = require('../runtime/constants.cjs');
10
10
  const {
11
11
  installToml, uninstallToml, installAgents, uninstallAgents, readText, replaceBlock,
12
- hasUnmanagedMemoryRouterTable, TOML_START, TOML_END, AGENTS_START, AGENTS_END,
12
+ hasUnmanagedMemoryRouterTable, externalRemoteServerName, TOML_START, TOML_END, AGENTS_START, AGENTS_END,
13
13
  } = require('./managed.cjs');
14
- const { installHooks, uninstallHooks, isOwnedHook, managedGroups, parseHooks } = require('./hooks.cjs');
14
+ const { installHooks, uninstallHooks, isOwnedHook, managedGroups, mcpManagedGroups, parseHooks } = require('./hooks.cjs');
15
15
 
16
16
  function resolveLayout(options = {}) {
17
17
  const env = options.env || process.env;
@@ -54,6 +54,9 @@ function preflight(layout, options = {}) {
54
54
  const configText = readText(layout.configFile);
55
55
  // Validate marker pairs before any file is changed.
56
56
  replaceBlock(configText, TOML_START, TOML_END, '');
57
+ // A remote MemoryRouter server the user added themselves (Codex app:
58
+ // Settings > Plugins > Add MCP server) is adopted as is, never an error.
59
+ if (options.auth !== 'api-key' && externalRemoteServerName(configText, MCP_ENDPOINT)) return;
57
60
  if (hasUnmanagedMemoryRouterTable(configText) && !options.force) {
58
61
  throw new Error(`An unmanaged [mcp_servers.memoryrouter] table already exists in ${layout.configFile}. Re-run with --force after reviewing the migration backup.`);
59
62
  }
@@ -61,16 +64,41 @@ function preflight(layout, options = {}) {
61
64
  if (options.agents !== false) replaceBlock(readText(layout.agentsFile), AGENTS_START, AGENTS_END, '');
62
65
  }
63
66
 
67
+ /**
68
+ * How the automatic hooks reach MemoryRouter.
69
+ * - 'mcp': Codex `mcp_tool` hooks call MemoryRouter's hidden lifecycle tools
70
+ * over the signed-in OAuth MCP connection. No API key on disk.
71
+ * - 'command': the local Node runtime with a Memory Key (api-key mode, or
72
+ * OAuth users who also configured a key, e.g. shared with Claude Code).
73
+ */
74
+ function resolveHookMode(options, auth, env) {
75
+ if (options.hookMode === 'mcp' || options.hookMode === 'command') return options.hookMode;
76
+ if (auth === 'api-key') return 'command';
77
+ const key = options.apiKey || getApiKey(env, loadConfig(options.env));
78
+ return key ? 'command' : 'mcp';
79
+ }
80
+
64
81
  function init(options = {}) {
65
82
  const layout = resolveLayout(options);
66
83
  const auth = options.auth || 'oauth';
67
84
  if (!['oauth', 'api-key'].includes(auth)) throw new Error('--auth must be oauth or api-key');
68
- preflight(layout, options);
85
+ if (options.hookMode && !['mcp', 'command'].includes(options.hookMode)) throw new Error('--hooks-via must be mcp or command');
86
+ const env = options.env || process.env;
87
+ preflight(layout, { ...options, auth });
88
+ const hookMode = resolveHookMode(options, auth, env);
89
+ if (hookMode === 'mcp' && auth === 'api-key') throw new Error('--hooks-via mcp needs the OAuth MCP server (--auth oauth).');
69
90
  copyRuntime(layout.runtimeDir);
70
91
  const results = [];
71
- results.push(installToml(layout.configFile, { auth, scope: layout.scope, runtimeDir: layout.runtimeDir, force: options.force }));
72
- if (options.hooks !== false) results.push(installHooks(layout.hooksFile, layout.runtimeDir, layout.scope));
73
- if (options.agents !== false) results.push(installAgents(layout.agentsFile));
92
+ const adoptedServer = auth === 'oauth' ? externalRemoteServerName(readText(layout.configFile), MCP_ENDPOINT) : null;
93
+ if (adoptedServer) {
94
+ // Leave the user's own server table (and its sign-in) untouched.
95
+ results.push({ changed: false, file: layout.configFile, adopted: adoptedServer });
96
+ } else {
97
+ results.push(installToml(layout.configFile, { auth, scope: layout.scope, runtimeDir: layout.runtimeDir, force: options.force }));
98
+ }
99
+ const serverName = adoptedServer || 'memoryrouter';
100
+ if (options.hooks !== false) results.push(installHooks(layout.hooksFile, layout.runtimeDir, layout.scope, { mode: hookMode, serverName }));
101
+ if (options.agents !== false) results.push(installAgents(layout.agentsFile, { hookMode }));
74
102
 
75
103
  if (options.apiKey) {
76
104
  const current = loadConfig(options.env);
@@ -81,6 +109,9 @@ function init(options = {}) {
81
109
  installedAt: new Date().toISOString(),
82
110
  scope: layout.scope,
83
111
  auth,
112
+ hookMode,
113
+ serverName,
114
+ adoptedServer: Boolean(adoptedServer),
84
115
  configFile: layout.configFile,
85
116
  hooksFile: options.hooks === false ? null : layout.hooksFile,
86
117
  agentsFile: options.agents === false ? null : layout.agentsFile,
@@ -90,12 +121,14 @@ function init(options = {}) {
90
121
  fs.mkdirSync(manifestDir, { recursive: true, mode: 0o700 });
91
122
  const manifestName = Buffer.from(`${layout.scope}:${layout.root}`).toString('base64url');
92
123
  fs.writeFileSync(path.join(manifestDir, `${manifestName}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
93
- const env = options.env || process.env;
94
124
  return {
95
125
  layout,
96
126
  auth,
127
+ hookMode,
128
+ serverName,
129
+ adoptedServer,
97
130
  results,
98
- automaticMemory: Boolean(options.apiKey || getApiKey(env, loadConfig(options.env))),
131
+ automaticMemory: options.hooks !== false && (hookMode === 'mcp' || Boolean(options.apiKey || getApiKey(env, loadConfig(options.env)))),
99
132
  keySource: options.apiKey ? 'codex-config' : apiKeySource(env, loadConfig(options.env)),
100
133
  };
101
134
  }
@@ -175,7 +208,9 @@ async function doctor(options = {}) {
175
208
  else if (!versionAtLeast(codex.version, [0, 145, 0])) addCheck(checks, 'codex-version', 'error', `${codex.output} is too old for the lifecycle hooks used here. Upgrade to Codex 0.145.0 or newer.`);
176
209
  else addCheck(checks, 'codex-version', 'pass', `${codex.output} supports current hooks and remote MCP configuration.`);
177
210
 
178
- addCheck(checks, 'mcp-config', configText.includes(TOML_START) && configText.includes('[mcp_servers.memoryrouter]') ? 'pass' : 'error',
211
+ const adopted = externalRemoteServerName(configText, MCP_ENDPOINT);
212
+ if (adopted) addCheck(checks, 'mcp-config', 'pass', `MemoryRouter MCP server "${adopted}" (added in the Codex app or by hand) is configured.`);
213
+ else addCheck(checks, 'mcp-config', configText.includes(TOML_START) && configText.includes('[mcp_servers.memoryrouter]') ? 'pass' : 'error',
179
214
  configText.includes(TOML_START) ? 'Managed MemoryRouter MCP configuration is present.' : 'Managed MemoryRouter MCP configuration is missing.');
180
215
  addCheck(checks, 'runtime', runtimeVersion === INTEGRATION_VERSION ? 'pass' : 'error',
181
216
  runtimeVersion ? `Installed runtime version is ${runtimeVersion}; package version is ${INTEGRATION_VERSION}.` : 'Managed runtime is missing.');
@@ -183,15 +218,21 @@ async function doctor(options = {}) {
183
218
  let hooks = null;
184
219
  try { hooks = hooksText ? JSON.parse(hooksText) : null; } catch { /* handled */ }
185
220
  const owned = hooks ? Object.values(hooks.hooks || {}).flat().flatMap(group => group.hooks || []).filter(isOwnedHook) : [];
186
- const expectedOwned = Object.values(managedGroups(layout.runtimeDir, layout.scope)).flat().flatMap(group => group.hooks || []).length;
187
- addCheck(checks, 'hooks', owned.length === expectedOwned ? 'pass' : 'error', owned.length ? `${owned.length} of ${expectedOwned} managed lifecycle hooks found.` : 'Managed lifecycle hooks are missing or hooks.json is invalid.');
221
+ const mcpHooks = owned.filter(handler => handler.type === 'mcp_tool');
222
+ const expectedOwned = mcpHooks.length
223
+ ? Object.values(mcpManagedGroups()).flat().flatMap(group => group.hooks || []).length
224
+ : Object.values(managedGroups(layout.runtimeDir, layout.scope)).flat().flatMap(group => group.hooks || []).length;
225
+ addCheck(checks, 'hooks', owned.length === expectedOwned ? 'pass' : 'error', owned.length
226
+ ? `${owned.length} of ${expectedOwned} managed lifecycle hooks found${mcpHooks.length ? ' (over the signed-in MCP connection, no API key needed)' : ''}. Trust them once in Codex: app Settings > Hooks, or /hooks in the CLI.`
227
+ : 'Managed lifecycle hooks are missing or hooks.json is invalid.');
188
228
  addCheck(checks, 'agents', agentsText.includes(AGENTS_START) ? 'pass' : 'warn', agentsText.includes(AGENTS_START) ? 'Managed AGENTS.md guidance is present.' : 'Managed AGENTS.md guidance is missing.');
189
229
 
190
230
  const cfg = loadConfig(options.env);
191
231
  const keyEnv = options.env || process.env;
192
232
  const key = getApiKey(keyEnv, cfg);
193
233
  const keyFrom = apiKeySource(keyEnv, cfg);
194
- if (!key) addCheck(checks, 'automatic-auth', 'warn', 'No MemoryRouter API key is available. Remote OAuth tools can work after `codex mcp login memoryrouter`, but automatic hook retrieval/capture is disabled.');
234
+ if (!key && mcpHooks.length) addCheck(checks, 'automatic-auth', 'pass', 'Automatic recall and saving use your MemoryRouter sign-in (OAuth). No API key needed.');
235
+ else if (!key) addCheck(checks, 'automatic-auth', 'warn', 'No MemoryRouter API key is available. Remote OAuth tools can work after `codex mcp login memoryrouter`, but automatic hook retrieval/capture is disabled.');
195
236
  else {
196
237
  try {
197
238
  const { stats } = require('../runtime/api.cjs');
@@ -210,7 +251,7 @@ async function doctor(options = {}) {
210
251
  }
211
252
  }
212
253
 
213
- if (configText.includes('auth = "oauth"')) {
254
+ if (configText.includes('auth = "oauth"') || adopted) {
214
255
  if (options.offline) {
215
256
  addCheck(checks, 'oauth-discovery', 'warn', 'OAuth discovery check skipped in offline mode.');
216
257
  addCheck(checks, 'oauth-session', 'info', 'Browser consent/token issuance is user-specific. Run `codex mcp login memoryrouter`; then `/mcp verbose`. Doctor does not claim login completion without interactive consent.');
package/lib/managed.cjs CHANGED
@@ -53,6 +53,26 @@ function removeUnmanagedMemoryRouterTables(text) {
53
53
  return kept.join('\n').replace(/\n{3,}/g, '\n\n');
54
54
  }
55
55
 
56
+ /**
57
+ * The MCP server name the user already connected to MemoryRouter's remote
58
+ * endpoint outside our managed block (for example the Codex app's
59
+ * Settings > Plugins > Add MCP server), or null. Returns the first
60
+ * `[mcp_servers.<name>]` whose `url` is the MemoryRouter MCP endpoint.
61
+ */
62
+ function externalRemoteServerName(text, endpoint) {
63
+ const stripped = replaceBlock(text, TOML_START, TOML_END, '');
64
+ const lines = stripped.split('\n');
65
+ let current = null;
66
+ for (const line of lines) {
67
+ const table = line.match(/^\s*\[mcp_servers\.("?)([^\]".]+)\1\]\s*(?:#.*)?$/);
68
+ if (table) { current = table[2]; continue; }
69
+ if (/^\s*\[/.test(line)) { current = null; continue; }
70
+ const url = line.match(/^\s*url\s*=\s*"([^"]+)"/);
71
+ if (current && url && url[1].replace(/\/+$/, '') === endpoint.replace(/\/+$/, '')) return current;
72
+ }
73
+ return null;
74
+ }
75
+
56
76
  function tomlString(value) { return JSON.stringify(String(value)); }
57
77
 
58
78
  function mcpBlock({ auth = 'oauth', scope = 'user', runtimeDir }) {
@@ -129,13 +149,16 @@ function uninstallToml(file) {
129
149
  return { changed: true, file };
130
150
  }
131
151
 
132
- function agentsBlock() {
133
- return `## MemoryRouter memory\n\n- MemoryRouter is the durable cross-machine, cross-tool, and team/auditable memory layer. Codex native Memories may remain enabled as the local personal recall layer.\n- Automatic hooks search project-scoped memory before each prompt and capture completed turns when a MemoryRouter API key is configured.\n- For explicit recall, search MemoryRouter before answering questions about prior decisions, preferences, incidents, or unfinished work. Depending on auth mode, the tool is named \`search_memories\` or \`memory_search\`.\n- Store only concise, durable decisions, preferences, facts, and incident outcomes. Depending on auth mode, use \`store_memory\` or \`memory_store\`. Do not duplicate a completed turn that hooks already captured.\n- Keep and cite returned \`mrh_...\` handles when a specific memory must be referenced later.\n- Project scope is derived from the canonical git remote when available, so worktrees and machines share identity without uploading local paths. It is the same project memory Claude Code uses through memoryrouter-claude, so decisions made in either agent are available in both.\n- If MemoryRouter is offline, continue the task without memory; never block work on recall or capture.`;
152
+ function agentsBlock(options = {}) {
153
+ const automatic = options.hookMode === 'mcp'
154
+ ? '- Automatic hooks (over the signed-in MemoryRouter connection) search the vault before each prompt and add what they find as context, then save each completed turn: the user\'s message and your final response only, never tool calls, tool output or intermediate steps.\n'
155
+ : '- Automatic hooks search the vault before each prompt and save each completed turn (the user\'s message and your final response only) when a MemoryRouter API key is configured.\n';
156
+ return `## MemoryRouter memory\n\n- MemoryRouter is the durable cross-machine, cross-tool, and team/auditable memory layer. Codex native Memories may remain enabled as the local personal recall layer.\n${automatic}- For explicit recall, search MemoryRouter before answering questions about prior decisions, preferences, incidents, or unfinished work. Depending on auth mode, the tool is named \`search_memories\` or \`memory_search\`.\n- Store only concise, durable decisions, preferences, facts, and incident outcomes. Depending on auth mode, use \`store_memory\` or \`memory_store\`. Do not duplicate a completed turn that hooks already captured.\n- Keep and cite returned \`mrh_...\` handles when a specific memory must be referenced later.\n- Project scope is derived from the canonical git remote when available, so worktrees and machines share identity without uploading local paths. It is the same project memory Claude Code uses through memoryrouter-claude, so decisions made in either agent are available in both.\n- If MemoryRouter is offline, continue the task without memory; never block work on recall or capture.`;
134
157
  }
135
158
 
136
- function installAgents(file) {
159
+ function installAgents(file, options = {}) {
137
160
  const current = readText(file);
138
- const updated = replaceBlock(current, AGENTS_START, AGENTS_END, agentsBlock());
161
+ const updated = replaceBlock(current, AGENTS_START, AGENTS_END, agentsBlock(options));
139
162
  atomicWrite(file, updated);
140
163
  return { changed: updated !== current, file };
141
164
  }
@@ -158,6 +181,7 @@ module.exports = {
158
181
  atomicWrite,
159
182
  replaceBlock,
160
183
  hasUnmanagedMemoryRouterTable,
184
+ externalRemoteServerName,
161
185
  removeUnmanagedMemoryRouterTables,
162
186
  mcpBlock,
163
187
  installToml,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoryrouter-codex",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Automatic MemoryRouter memory for OpenAI Codex: one vault shared by every session, repo and tool, including Claude Code",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const INTEGRATION_VERSION = '1.7.0';
3
+ const INTEGRATION_VERSION = '1.8.0';
4
4
  const RUNTIME_SCHEMA_VERSION = 1;
5
5
  const API_BASE = 'https://api.memoryrouter.ai';
6
6
  const MCP_ENDPOINT = 'https://mcp.memoryrouter.ai/mcp';
package/runtime/hook.cjs CHANGED
@@ -64,8 +64,16 @@ function maybeAppendNudge(context, reflection, sessionId, hookContext, importSta
64
64
  return context;
65
65
  }
66
66
 
67
+ // The prompt that started THIS turn. Never another turn's prompt: falling back
68
+ // to "the last prompt seen" could pair a response with the wrong question.
69
+ function promptForTurn(state, turnId) {
70
+ if (state.prompts?.[turnId]) return state.prompts[turnId];
71
+ if (turnId && state.lastTurnId === turnId) return state.lastPrompt || '';
72
+ return '';
73
+ }
74
+
67
75
  function turnContent(input, state) {
68
- const prompt = state.prompts?.[input.turn_id] || state.lastPrompt || '';
76
+ const prompt = promptForTurn(state, input.turn_id);
69
77
  return [
70
78
  prompt ? `User request:
71
79
  ${truncate(prompt, 40000)}` : null,
@@ -79,11 +87,15 @@ ${truncate(input.last_assistant_message, 60000)}`
79
87
  async function handleInject(input, context) {
80
88
  const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
81
89
  if (!prompt) return;
82
- const state = loadSessionState(input.session_id, context.env);
83
- state.prompts = { ...(state.prompts || {}), [input.turn_id]: prompt.slice(0, 100000) };
84
- state.lastPrompt = prompt.slice(0, 100000);
85
- state.lastTurnId = input.turn_id;
86
- saveSessionState(input.session_id, state, context.env);
90
+ // A thread-spawned subagent's "prompt" is the parent agent's instruction (an
91
+ // intermediate step). Recall for it, but never record it for capture.
92
+ if (!input.agent_id) {
93
+ const state = loadSessionState(input.session_id, context.env);
94
+ state.prompts = { ...(state.prompts || {}), [input.turn_id]: prompt.slice(0, 100000) };
95
+ state.lastPrompt = prompt.slice(0, 100000);
96
+ state.lastTurnId = input.turn_id;
97
+ saveSessionState(input.session_id, state, context.env);
98
+ }
87
99
 
88
100
  const result = await searchScoped(prompt, {
89
101
  ...context,
@@ -125,18 +137,27 @@ async function handleSessionStart(input, context) {
125
137
  emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
126
138
  }
127
139
 
140
+ // Stop (partial=false): save the user's prompt + the final assistant response.
141
+ // SessionEnd / PreCompact (partial=true): only if the last turn never reached
142
+ // Stop (interrupted), save that turn's user prompt so it is not lost. A turn
143
+ // that was already saved is never saved again.
128
144
  async function captureTurn(input, context, partial = false) {
129
145
  const sessionId = input.session_id;
130
146
  const turnId = input.turn_id || loadSessionState(sessionId, context.env).lastTurnId;
131
147
  if (!sessionId || !turnId) return { ok: false, reason: 'missing-turn' };
132
148
  if (!partial && input.stop_hook_active) return { ok: true, skipped: 'stop-hook-active' };
133
149
  const state = loadSessionState(sessionId, context.env);
134
- if (!partial && state.capturedTurns?.[turnId]) {
150
+ if (state.capturedTurns?.[turnId]) {
135
151
  return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[turnId] };
136
152
  }
137
- const prompt = state.prompts?.[turnId] || state.lastPrompt || '';
153
+ if (partial && state.checkpointedTurns?.[turnId]) {
154
+ return { ok: true, skipped: 'deduplicated', handle: state.checkpointedTurns[turnId] };
155
+ }
156
+ const prompt = promptForTurn(state, turnId);
138
157
  if (!partial && !input.last_assistant_message) return { ok: true, skipped: 'empty' };
139
- if (partial && !prompt && !input.last_assistant_message) return { ok: true, skipped: 'empty' };
158
+ if (partial && !prompt) return { ok: true, skipped: 'empty' };
159
+ // PreCompact/SessionEnd carry no final response; never invent one.
160
+ if (partial) input = { ...input, last_assistant_message: undefined };
140
161
  const content = turnContent({ ...input, turn_id: turnId }, state);
141
162
  const kind = partial ? 'checkpoint' : 'turn';
142
163
  const handle = memoryHandle(context.project.handle, kind, content);
@@ -151,9 +172,12 @@ async function captureTurn(input, context, partial = false) {
151
172
  });
152
173
  if (result.ok && result.stored) {
153
174
  state.capturedHandles = [...(state.capturedHandles || []), handle].slice(-500);
154
- if (!partial) state.capturedTurns = { ...(state.capturedTurns || {}), [turnId]: handle };
155
- if (Object.keys(state.capturedTurns || {}).length > 500) {
156
- state.capturedTurns = Object.fromEntries(Object.entries(state.capturedTurns).slice(-500));
175
+ // A checkpoint (prompt of an unfinished turn) never blocks that turn's
176
+ // full save if it does finish later.
177
+ const ledger = partial ? 'checkpointedTurns' : 'capturedTurns';
178
+ state[ledger] = { ...(state[ledger] || {}), [turnId]: handle };
179
+ if (Object.keys(state[ledger] || {}).length > 500) {
180
+ state[ledger] = Object.fromEntries(Object.entries(state[ledger]).slice(-500));
157
181
  }
158
182
  if (!partial && state.prompts) delete state.prompts[turnId];
159
183
  state.lastCaptureAt = new Date().toISOString();
@@ -170,36 +194,16 @@ async function captureTurn(input, context, partial = false) {
170
194
  return result;
171
195
  }
172
196
 
197
+ // Subagent output is intermediate work, not the user's conversation. Saving it
198
+ // broke the "user message + final response only" rule, so 1.8.0 no longer
199
+ // installs a SubagentStop hook and this handler is a no-op for installs that
200
+ // still have one until `init` is re-run.
173
201
  async function captureSubagent(input, context) {
174
- if (!input.last_assistant_message || !input.session_id) return { ok: true, skipped: 'empty' };
175
- const state = loadSessionState(input.session_id, context.env);
176
- const captureKey = `subagent:${input.agent_id || 'unknown'}:${input.turn_id || 'unknown'}`;
177
- if (state.capturedTurns?.[captureKey]) return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[captureKey] };
178
- const content = [
179
- 'Completed Codex subagent outcome.',
180
- `Subagent type: ${input.agent_type || 'unknown'}`,
181
- `Subagent id: ${input.agent_id || 'unknown'}`,
182
- `Outcome:\n${truncate(input.last_assistant_message, 60000)}`,
183
- ].join('\n\n');
184
- const handle = memoryHandle(context.project.handle, 'subagent', content);
185
- const result = await storeScoped(content, {
186
- ...context,
187
- kind: 'subagent',
188
- handle,
189
- skipRemoteDedupe: true,
190
- timeoutMs: 12000,
191
- tags: ['codex', 'subagent', input.agent_type || 'unknown'],
192
- });
193
- if (result.ok && result.stored) {
194
- state.capturedHandles = [...(state.capturedHandles || []), handle].slice(-500);
195
- state.capturedTurns = { ...(state.capturedTurns || {}), [captureKey]: handle };
196
- if (Object.keys(state.capturedTurns).length > 500) state.capturedTurns = Object.fromEntries(Object.entries(state.capturedTurns).slice(-500));
197
- saveSessionState(input.session_id, state, context.env);
198
- }
199
- recordHealth('capture-subagent', { ok: result.ok, status: result.status, project: context.project.handle, handle }, context.env);
200
- return result;
202
+ recordHealth('capture-subagent', { ok: true, skipped: 'subagent-not-captured', project: context.project.handle }, context.env);
203
+ return { ok: true, skipped: 'subagent-not-captured' };
201
204
  }
202
205
 
206
+
203
207
  async function run(kind, input, options = {}) {
204
208
  const env = options.env || process.env;
205
209
  const config = options.config || loadConfig(env);