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 +42 -11
- package/bin/cli.cjs +12 -5
- package/lib/hooks.cjs +47 -8
- package/lib/installer.cjs +54 -13
- package/lib/managed.cjs +28 -4
- package/package.json +1 -1
- package/runtime/constants.cjs +1 -1
- package/runtime/hook.cjs +43 -39
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
|
|
12
|
-
| `Stop` hook |
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
|
|
|
16
|
-
|
|
|
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
|
-
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
55
|
-
Stop: [{ hooks: [handler('capture', 18, 'Saving
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
187
|
-
|
|
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', '
|
|
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
|
-
|
|
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
package/runtime/constants.cjs
CHANGED
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 (
|
|
150
|
+
if (state.capturedTurns?.[turnId]) {
|
|
135
151
|
return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[turnId] };
|
|
136
152
|
}
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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);
|