memoryrouter-codex 1.6.0 → 1.7.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 +28 -23
- package/bin/cli.cjs +23 -3
- package/lib/installer.cjs +28 -8
- package/lib/managed.cjs +1 -1
- package/package.json +2 -2
- package/runtime/api.cjs +5 -1
- package/runtime/config.cjs +38 -3
- package/runtime/constants.cjs +1 -1
- package/runtime/hook.cjs +19 -8
- package/runtime/mcp-stdio.cjs +38 -16
- package/runtime/memory.cjs +109 -42
- package/runtime/project.cjs +171 -23
- package/runtime/vault-binding.cjs +51 -0
package/README.md
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Native Codex integration for durable MemoryRouter memory. It uses Codex's current MCP, lifecycle hooks, and `AGENTS.md` surfaces rather than pretending that model-selected MCP calls alone are automatic memory.
|
|
4
4
|
|
|
5
|
-
> **Release status:** `memoryrouter-codex
|
|
5
|
+
> **Release status:** `memoryrouter-codex` is the published npm release. Its automatic capture is message-only: one user request plus the final assistant response, with no tool payloads.
|
|
6
6
|
|
|
7
7
|
## What this integration does
|
|
8
8
|
|
|
9
9
|
| Layer | Behavior |
|
|
10
10
|
| --- | --- |
|
|
11
|
-
| `UserPromptSubmit` hook | Searches MemoryRouter before every prompt and injects bounded
|
|
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
12
|
| `Stop` hook | Captures only the user request and final assistant response after each completed turn. Tool names, inputs, and results are excluded. |
|
|
13
13
|
| `SubagentStop` hook | Captures the stable final outcome of completed subagents without parsing their unstable transcript files. |
|
|
14
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
15
|
| `SessionEnd` | Best-effort message-only final checkpoint within Codex's three-second SessionEnd budget. |
|
|
16
16
|
| Remote MCP | OAuth-based explicit search/store through `https://mcp.memoryrouter.ai/mcp`. |
|
|
17
|
-
| Local MCP fallback | API-key-authenticated stdio tools
|
|
17
|
+
| Local MCP fallback | API-key-authenticated stdio tools over the same whole vault, with handles, local suppression, status, and full-vault clear confirmation. |
|
|
18
18
|
| `AGENTS.md` | Tells Codex when to recall/store and how MemoryRouter coexists with native Memories. |
|
|
19
19
|
|
|
20
|
-
MemoryRouter is the **cross-machine, cross-tool, team-shareable, and auditable** layer. Codex's native Memories remain useful as an automated local personal recall layer.
|
|
20
|
+
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
21
|
|
|
22
22
|
## Requirements and supported surfaces
|
|
23
23
|
|
|
@@ -39,13 +39,13 @@ Obtain a MemoryRouter memory key from your account. Pass it on stdin so it does
|
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
printf '%s' "$MEMORYROUTER_API_KEY" | \
|
|
42
|
-
npx -y memoryrouter-codex
|
|
42
|
+
npx -y memoryrouter-codex init --scope user --auth oauth --api-key-stdin
|
|
43
43
|
|
|
44
44
|
codex mcp login memoryrouter
|
|
45
45
|
memoryrouter-codex doctor --scope user
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Then start Codex and inspect:
|
|
48
|
+
Then start Codex and inspect (trust the MemoryRouter hook definitions in `/hooks`):
|
|
49
49
|
|
|
50
50
|
```text
|
|
51
51
|
/mcp verbose
|
|
@@ -54,6 +54,8 @@ Then start Codex and inspect:
|
|
|
54
54
|
|
|
55
55
|
The API key is written to `~/.memoryrouter/codex/config.json` with mode `0600`; the directory is `0700`. It is never written to `config.toml`, `hooks.json`, `AGENTS.md`, or a repository.
|
|
56
56
|
|
|
57
|
+
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
|
+
|
|
57
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.
|
|
58
60
|
|
|
59
61
|
### Project scope
|
|
@@ -61,7 +63,7 @@ If you prefer not to persist the key, omit `--api-key-stdin` and ensure `MEMORYR
|
|
|
61
63
|
```bash
|
|
62
64
|
cd /path/to/repository
|
|
63
65
|
printf '%s' "$MEMORYROUTER_API_KEY" | \
|
|
64
|
-
npx -y memoryrouter-codex
|
|
66
|
+
npx -y memoryrouter-codex init --scope project --auth oauth --api-key-stdin
|
|
65
67
|
codex mcp login memoryrouter
|
|
66
68
|
```
|
|
67
69
|
|
|
@@ -71,13 +73,14 @@ This writes managed blocks/files under the repository's `AGENTS.md` and `.codex/
|
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
75
|
printf '%s' "$MEMORYROUTER_API_KEY" | \
|
|
74
|
-
npx -y memoryrouter-codex
|
|
76
|
+
npx -y memoryrouter-codex init --scope user --auth api-key --api-key-stdin
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
This uses a local stdio MCP server and the MemoryRouter API. It does not put the memory key in Codex config. The fallback tools are:
|
|
78
80
|
|
|
79
81
|
- `memory_search`
|
|
80
82
|
- `memory_date_search`
|
|
83
|
+
- `memory_inspect`
|
|
81
84
|
- `memory_store`
|
|
82
85
|
- `memory_status`
|
|
83
86
|
- `memory_forget`
|
|
@@ -109,25 +112,27 @@ The installer preserves unrelated TOML byte-for-byte outside its managed block.
|
|
|
109
112
|
|
|
110
113
|
Hooks are merged into `hooks.json`; foreign hooks remain. Re-running `init` upgrades/replaces only MemoryRouter-owned handlers. `uninstall` removes only owned handlers and managed blocks.
|
|
111
114
|
|
|
112
|
-
##
|
|
115
|
+
## One vault per user (shared with Claude Code and every MemoryRouter surface)
|
|
116
|
+
|
|
117
|
+
Since **1.7.0**, every hook call (recall before each prompt, capture after each turn, compaction checkpoints, post-compaction recall) and every local MCP tool call reads and writes your **whole vault**. No `session_id` is sent, so there is no per-session or per-repository partition. That gives you:
|
|
113
118
|
|
|
114
|
-
|
|
119
|
+
- **A new Codex session recalls earlier sessions**, in this repository and every other one.
|
|
120
|
+
- **Codex and Claude Code share one memory.** A decision captured in Claude Code in repo A is recalled by Codex in repo B, and the other way round (requires `memoryrouter-claude` 2.7.0 or newer on the same vault).
|
|
121
|
+
- **Everything else on the vault is recalled too:** saves from the hosted MCP (`store_memory` from ChatGPT, Claude, or `codex mcp login`), consolidated reflections, the server's pending buffer, and Codex envelopes from any release. Only handles you suppressed locally with `memory_forget` are hidden.
|
|
122
|
+
|
|
123
|
+
**Repository tag.** Codex still computes the same `ccp_` id as Claude Code:
|
|
115
124
|
|
|
116
125
|
```text
|
|
117
|
-
|
|
126
|
+
ccp_<first 24 hex chars of SHA-256(canonical project identity)>
|
|
118
127
|
```
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
1. Canonical `origin` git URL, with credentials removed and `.git` normalized.
|
|
123
|
-
2. Git common directory (so local worktrees share a project).
|
|
124
|
-
3. Absolute path fallback.
|
|
129
|
+
from, in order, an explicit id (`MEMORYROUTER_PROJECT_ID` or `projectId` in `.memoryrouter.json`), the normalized git remote, the git common directory, then the directory path. It is metadata only: Codex stamps it on the turns it saves and labels recalled entries from the current repository as `this repo`. It never limits what is recalled. Only the opaque hash leaves the machine.
|
|
125
130
|
|
|
126
|
-
|
|
131
|
+
**One user, one vault.** When you also connect MemoryRouter over OAuth (`codex mcp login memoryrouter`, ChatGPT, Claude), pick the same vault your hook key belongs to. `init` asks `https://auth.memoryrouter.ai/v1/vault-binding` whether the hook key is the vault your OAuth connections selected: on a mismatch it stops before writing anything and names the selected vault; with no connection yet it tells you which vault to pick at login. `doctor` reports the same `one-vault` check. The response never contains a key.
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
`.memoryrouterignore` (or `enabled: false` in `.memoryrouter.json`) still disables hooks for a repository. The pre-1.7 settings `retrievalScope`, `includeGlobal`, and `memoryScope` are ignored and removed from the private config.
|
|
129
134
|
|
|
130
|
-
|
|
135
|
+
**Upgrading from 1.6.x.** Releases before 1.7.0 used a per-Codex-session partition (`mrcodex:<project>:<session>`), so every new session started empty and Codex never saw Claude Code's memory. When 1.7.0 shipped, those partitions (and `memoryrouter-claude`'s per-repository `ccp_` partitions) were moved into each vault's main memory server-side, with stored embeddings kept, exact repeats skipped, and unflushed buffers saved, so old memories are recalled again with nothing lost or duplicated. Re-run `init` to upgrade the installed runtime.
|
|
131
136
|
|
|
132
137
|
## Historical import (agent-driven)
|
|
133
138
|
|
|
@@ -221,15 +226,15 @@ Doctor checks Codex version, managed config, runtime version, hook count, `AGENT
|
|
|
221
226
|
Upgrade idempotently by re-running the pinned/new package:
|
|
222
227
|
|
|
223
228
|
```bash
|
|
224
|
-
npx -y memoryrouter-codex
|
|
229
|
+
npx -y memoryrouter-codex init --scope user
|
|
225
230
|
```
|
|
226
231
|
|
|
227
232
|
Uninstall:
|
|
228
233
|
|
|
229
234
|
```bash
|
|
230
|
-
npx -y memoryrouter-codex
|
|
235
|
+
npx -y memoryrouter-codex uninstall --scope user
|
|
231
236
|
# or
|
|
232
|
-
npx -y memoryrouter-codex
|
|
237
|
+
npx -y memoryrouter-codex uninstall --scope project --project-dir /repo
|
|
233
238
|
```
|
|
234
239
|
|
|
235
240
|
Uninstall deliberately retains `~/.memoryrouter/codex/` because another project installation may use the credentials and local suppression state. Delete that private directory manually only when no integration uses it.
|
|
@@ -241,7 +246,7 @@ Uninstall deliberately retains `~/.memoryrouter/codex/` because another project
|
|
|
241
246
|
- Tool-call records are intentionally excluded. MemoryRouter does not store tool names, tool IDs, tool inputs, tool results, commands, patches, or other `PostToolUse` payloads from automatic Codex capture. Important outcomes must appear in the final assistant response to be retained.
|
|
242
247
|
- Some specialized tool paths can opt out of hooks. Codex documents hooks as a useful extension surface, not a complete enforcement boundary.
|
|
243
248
|
- SessionEnd can happen after a 30-minute idle period and has a three-second maximum. It is a best-effort checkpoint; normal `Stop` capture is the primary write path.
|
|
244
|
-
-
|
|
249
|
+
- Everything shares one vault per user. Strict isolation between two bodies of work requires a separate account and vault.
|
|
245
250
|
- Per-memory server deletion is not available through the current API. Local suppression and explicit full-vault clear are accurately labeled.
|
|
246
251
|
- API-level idempotency is not yet available; timeout-after-acceptance duplicates remain possible.
|
|
247
252
|
|
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
|
|
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`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function optionsFromArgs(args) {
|
|
@@ -111,14 +111,34 @@ async function main() {
|
|
|
111
111
|
}
|
|
112
112
|
const options = optionsFromArgs(args);
|
|
113
113
|
if (command === 'init') {
|
|
114
|
+
// One user = one vault: before writing anything, make sure the Memory Key
|
|
115
|
+
// the hooks will use is the vault selected in OAuth (codex mcp login).
|
|
116
|
+
const { loadConfig, getApiKey } = require('../runtime/config.cjs');
|
|
117
|
+
const { checkVaultBinding, vaultLabel, VaultMismatchError } = require('../runtime/vault-binding.cjs');
|
|
118
|
+
const hookKey = options.apiKey || getApiKey(process.env, loadConfig(process.env));
|
|
119
|
+
let binding = null;
|
|
120
|
+
if (hookKey && !options.offline) {
|
|
121
|
+
binding = await checkVaultBinding(hookKey);
|
|
122
|
+
if (binding.status === 'mismatch') throw new VaultMismatchError(binding);
|
|
123
|
+
}
|
|
114
124
|
const result = init(options);
|
|
115
125
|
process.stdout.write(`MemoryRouter Codex ${INTEGRATION_VERSION} installed (${result.layout.scope}, ${result.auth}).\n`);
|
|
116
126
|
process.stdout.write(` config: ${result.layout.configFile}\n`);
|
|
117
127
|
if (options.hooks !== false) process.stdout.write(` hooks: ${result.layout.hooksFile}\n`);
|
|
118
128
|
if (options.agents !== false) process.stdout.write(` guide: ${result.layout.agentsFile}\n`);
|
|
119
|
-
if (
|
|
129
|
+
if (binding && binding.status === 'match') process.stdout.write(`\nVault check: the hooks use the same vault you selected in OAuth, ${vaultLabel(binding)}.\n`);
|
|
130
|
+
else if (binding && binding.status === 'mixed') process.stdout.write(`\nVault check: ${binding.message}\n`);
|
|
131
|
+
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
|
+
const pick = binding && binding.vault ? ` and select the vault ${vaultLabel(binding)} (the one your hooks use)` : '';
|
|
134
|
+
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
|
+
}
|
|
136
|
+
else process.stdout.write('\nNext: open Codex, run /hooks, and trust the MemoryRouter hooks.\n');
|
|
137
|
+
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');
|
|
120
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');
|
|
121
|
-
|
|
139
|
+
const scopeArgs = result.layout.scope === 'project' ? `--scope project --project-dir ${JSON.stringify(result.layout.root)}` : '--scope user';
|
|
140
|
+
process.stdout.write(`\nVerify: npx -y memoryrouter-codex@${INTEGRATION_VERSION} doctor ${scopeArgs}\n`);
|
|
141
|
+
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');
|
|
122
142
|
return;
|
|
123
143
|
}
|
|
124
144
|
if (command === 'doctor') {
|
package/lib/installer.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { execFileSync } = require('child_process');
|
|
7
|
-
const { codexHome, dataHome, loadConfig, saveConfig, configPath } = require('../runtime/config.cjs');
|
|
7
|
+
const { codexHome, dataHome, loadConfig, saveConfig, configPath, getApiKey, apiKeySource } = require('../runtime/config.cjs');
|
|
8
8
|
const { resolveProject } = require('../runtime/project.cjs');
|
|
9
9
|
const { MCP_ENDPOINT, INTEGRATION_VERSION } = require('../runtime/constants.cjs');
|
|
10
10
|
const {
|
|
@@ -90,7 +90,14 @@ function init(options = {}) {
|
|
|
90
90
|
fs.mkdirSync(manifestDir, { recursive: true, mode: 0o700 });
|
|
91
91
|
const manifestName = Buffer.from(`${layout.scope}:${layout.root}`).toString('base64url');
|
|
92
92
|
fs.writeFileSync(path.join(manifestDir, `${manifestName}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
-
|
|
93
|
+
const env = options.env || process.env;
|
|
94
|
+
return {
|
|
95
|
+
layout,
|
|
96
|
+
auth,
|
|
97
|
+
results,
|
|
98
|
+
automaticMemory: Boolean(options.apiKey || getApiKey(env, loadConfig(options.env))),
|
|
99
|
+
keySource: options.apiKey ? 'codex-config' : apiKeySource(env, loadConfig(options.env)),
|
|
100
|
+
};
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
function uninstall(options = {}) {
|
|
@@ -181,14 +188,26 @@ async function doctor(options = {}) {
|
|
|
181
188
|
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.');
|
|
182
189
|
|
|
183
190
|
const cfg = loadConfig(options.env);
|
|
184
|
-
const
|
|
191
|
+
const keyEnv = options.env || process.env;
|
|
192
|
+
const key = getApiKey(keyEnv, cfg);
|
|
193
|
+
const keyFrom = apiKeySource(keyEnv, cfg);
|
|
185
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.');
|
|
186
195
|
else {
|
|
187
196
|
try {
|
|
188
197
|
const { stats } = require('../runtime/api.cjs');
|
|
189
198
|
const response = options.offline ? { ok: true, status: 0 } : await stats(key, { apiBase: cfg.apiBase, timeoutMs: 6000, retries: 0 });
|
|
190
|
-
addCheck(checks, 'automatic-auth', response.ok ? 'pass' : 'error', options.offline ? 'API key is configured (network check skipped).' : response.ok ?
|
|
199
|
+
addCheck(checks, 'automatic-auth', response.ok ? 'pass' : 'error', options.offline ? 'API key is configured (network check skipped).' : response.ok ? `API key is valid${keyFrom === 'claude-code-config' ? ' (reusing the Claude Code key in ~/.memoryrouter/config.json)' : ''}; automatic retrieval/capture is active.` : `API key validation failed (HTTP ${response.status}).`);
|
|
191
200
|
} catch (error) { addCheck(checks, 'automatic-auth', 'error', `API key validation failed: ${error.message}`); }
|
|
201
|
+
if (!options.offline) {
|
|
202
|
+
const { checkVaultBinding, vaultLabel } = require('../runtime/vault-binding.cjs');
|
|
203
|
+
const binding = await checkVaultBinding(key, { env: options.env || process.env, timeoutMs: 5000 });
|
|
204
|
+
const levels = { match: 'pass', mismatch: 'error', mixed: 'warn', 'not-connected': 'info', unknown: 'warn' };
|
|
205
|
+
addCheck(checks, 'one-vault', levels[binding.status] || 'warn',
|
|
206
|
+
binding.status === 'match' ? `Hooks and your OAuth connection use the same vault ${vaultLabel(binding)}.`
|
|
207
|
+
: binding.status === 'not-connected' ? `No MemoryRouter OAuth connection yet. When you run \`codex mcp login memoryrouter\`, select ${vaultLabel(binding)}.`
|
|
208
|
+
: binding.status === 'unknown' ? `Vault match not checked (${binding.message}).`
|
|
209
|
+
: binding.message);
|
|
210
|
+
}
|
|
192
211
|
}
|
|
193
212
|
|
|
194
213
|
if (configText.includes('auth = "oauth"')) {
|
|
@@ -222,10 +241,11 @@ async function doctor(options = {}) {
|
|
|
222
241
|
? `Codex native Memories are enabled${externalDisabled ? ' and external-context generation is disabled.' : '. Consider [memories] disable_on_external_context = true to avoid re-extracting MemoryRouter-assisted chats.'}`
|
|
223
242
|
: 'Codex native Memories are off or configured in another layer. This integration does not overwrite native-memory settings.');
|
|
224
243
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
244
|
+
// One vault per user: report the scope plainly, plus the repository label
|
|
245
|
+
// that captures from this directory carry (metadata only, never a wall).
|
|
246
|
+
const identityDir = layout.scope === 'user' ? (options.projectDir || process.cwd()) : layout.root;
|
|
247
|
+
const project = resolveProject(identityDir, { env: options.env, config: loadConfig(options.env) });
|
|
248
|
+
addCheck(checks, 'memory-scope', 'pass', `One vault for everything: every Codex session, every repository, Claude Code (memoryrouter-claude), and the MemoryRouter MCP read and write the same memory. Captures from this directory are tagged ${project.handle} (${project.source}) so recall can label them; the tag never limits what is recalled.`);
|
|
229
249
|
addCheck(checks, 'cloud-surface', layout.scope === 'project' ? 'info' : 'warn', layout.scope === 'project'
|
|
230
250
|
? 'Project config/hooks can travel with the repository, but cloud environments must trust the project, install Node, and provide MEMORYROUTER_API_KEY. ChatGPT web does not consume local Codex config; use a hosted plugin/connector there.'
|
|
231
251
|
: 'User-scope config covers local CLI, desktop app, and IDE on this host. Codex cloud and ChatGPT web do not inherit this machine-local config.');
|
package/lib/managed.cjs
CHANGED
|
@@ -130,7 +130,7 @@ function uninstallToml(file) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
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.\n- If MemoryRouter is offline, continue the task without memory; never block work on recall or capture.`;
|
|
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.`;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
function installAgents(file) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoryrouter-codex",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Automatic
|
|
3
|
+
"version": "1.7.0",
|
|
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",
|
|
7
7
|
"bin": {
|
package/runtime/api.cjs
CHANGED
|
@@ -60,11 +60,15 @@ function search(key, query, limit, options = {}) {
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// session_id is only sent when a caller passes one explicitly. The hooks never
|
|
64
|
+
// do (one vault per user since 1.7.0): a session_id is a hard vault partition.
|
|
63
65
|
function ingest(key, messages, sessionId, options = {}) {
|
|
66
|
+
const body = { messages, model: 'codex' };
|
|
67
|
+
if (sessionId) body.session_id = sessionId;
|
|
64
68
|
return request('POST', '/v1/memory/ingest', {
|
|
65
69
|
...options,
|
|
66
70
|
key,
|
|
67
|
-
body
|
|
71
|
+
body,
|
|
68
72
|
retries: 0,
|
|
69
73
|
});
|
|
70
74
|
}
|
package/runtime/config.cjs
CHANGED
|
@@ -38,26 +38,58 @@ function defaultConfig() {
|
|
|
38
38
|
apiBase: API_BASE,
|
|
39
39
|
contextChars: DEFAULT_CONTEXT_CHARS,
|
|
40
40
|
maxSearchResults: DEFAULT_SEARCH_RESULTS,
|
|
41
|
-
retrievalScope: 'project',
|
|
42
41
|
forgottenHandles: [],
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
// Pre-1.7 scoping keys (retrievalScope, includeGlobal, memoryScope) are dropped
|
|
46
|
+
// on load: there is one vault per user and nothing left to scope.
|
|
47
|
+
const RETIRED_KEYS = ['retrievalScope', 'includeGlobal', 'memoryScope'];
|
|
48
|
+
|
|
46
49
|
function loadConfig(env = process.env) {
|
|
47
50
|
const file = configPath(env);
|
|
48
51
|
let parsed = {};
|
|
49
52
|
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* defaults */ }
|
|
50
|
-
|
|
53
|
+
const merged = { ...defaultConfig(), ...(parsed && typeof parsed === 'object' ? parsed : {}) };
|
|
54
|
+
for (const retired of RETIRED_KEYS) delete merged[retired];
|
|
55
|
+
return merged;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function saveConfig(config, env = process.env) {
|
|
54
59
|
const safe = { ...defaultConfig(), ...config, version: RUNTIME_SCHEMA_VERSION };
|
|
60
|
+
for (const retired of RETIRED_KEYS) delete safe[retired];
|
|
55
61
|
atomicWriteJson(configPath(env), safe, 0o600);
|
|
56
62
|
return safe;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
// memoryrouter-claude keeps its key in ~/.memoryrouter/config.json. When the
|
|
66
|
+
// user already set up Claude Code, Codex reuses that key so both agents land in
|
|
67
|
+
// the same vault (one memory for every session, repo and tool) with no second
|
|
68
|
+
// paste.
|
|
69
|
+
function claudeConfigPath(env = process.env) {
|
|
70
|
+
const dir = env.MEMORYROUTER_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.memoryrouter');
|
|
71
|
+
return path.join(path.resolve(dir), 'config.json');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function claudeApiKey(env = process.env) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf8'));
|
|
77
|
+
const key = parsed && typeof parsed.apiKey === 'string' ? parsed.apiKey.trim() : '';
|
|
78
|
+
return /^mk[-_]/.test(key) ? key : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function apiKeySource(env = process.env, config = loadConfig(env)) {
|
|
85
|
+
if (env.MEMORYROUTER_API_KEY) return 'environment';
|
|
86
|
+
if (config.apiKey) return 'codex-config';
|
|
87
|
+
if (claudeApiKey(env)) return 'claude-code-config';
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
function getApiKey(env = process.env, config = loadConfig(env)) {
|
|
60
|
-
return env.MEMORYROUTER_API_KEY || config.apiKey || null;
|
|
92
|
+
return env.MEMORYROUTER_API_KEY || config.apiKey || claudeApiKey(env) || null;
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
function safeId(value) {
|
|
@@ -147,6 +179,9 @@ module.exports = {
|
|
|
147
179
|
loadConfig,
|
|
148
180
|
saveConfig,
|
|
149
181
|
getApiKey,
|
|
182
|
+
apiKeySource,
|
|
183
|
+
claudeApiKey,
|
|
184
|
+
claudeConfigPath,
|
|
150
185
|
safeId,
|
|
151
186
|
stateDir,
|
|
152
187
|
sessionStatePath,
|
package/runtime/constants.cjs
CHANGED
package/runtime/hook.cjs
CHANGED
|
@@ -87,7 +87,6 @@ async function handleInject(input, context) {
|
|
|
87
87
|
|
|
88
88
|
const result = await searchScoped(prompt, {
|
|
89
89
|
...context,
|
|
90
|
-
sessionId: `mrcodex:${context.project.handle}:${input.session_id}`,
|
|
91
90
|
timeoutMs: 5500,
|
|
92
91
|
});
|
|
93
92
|
recordHealth('inject', {
|
|
@@ -103,12 +102,19 @@ async function handleInject(input, context) {
|
|
|
103
102
|
emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
|
|
104
103
|
}
|
|
105
104
|
|
|
105
|
+
const SESSION_START_QUERY = 'Current project decisions, conventions, unresolved work, preferences, and recent implementation context';
|
|
106
|
+
|
|
107
|
+
// SessionStart fires for new, resumed, cleared, and compacted sessions. After
|
|
108
|
+
// compaction (and on resume) the most useful recall is whatever the user was
|
|
109
|
+
// last working on, so the query is the session's last prompt when one exists.
|
|
106
110
|
async function handleSessionStart(input, context) {
|
|
107
111
|
try { pruneState(context.env); } catch { /* cleanup is best effort */ }
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
const state = input.session_id ? loadSessionState(input.session_id, context.env) : {};
|
|
113
|
+
const continuing = input.source === 'compact' || input.source === 'resume';
|
|
114
|
+
const query = continuing && state.lastPrompt
|
|
115
|
+
? `${state.lastPrompt.slice(0, 4000)}\n\n${SESSION_START_QUERY}`
|
|
116
|
+
: SESSION_START_QUERY;
|
|
117
|
+
const result = await searchScoped(query, { ...context, timeoutMs: 6500 });
|
|
112
118
|
recordHealth('session-start', {
|
|
113
119
|
ok: result.ok,
|
|
114
120
|
source: input.source,
|
|
@@ -139,7 +145,6 @@ async function captureTurn(input, context, partial = false) {
|
|
|
139
145
|
...context,
|
|
140
146
|
kind,
|
|
141
147
|
handle,
|
|
142
|
-
sessionId: `mrcodex:${context.project.handle}:${sessionId}`,
|
|
143
148
|
skipRemoteDedupe: true,
|
|
144
149
|
timeoutMs: partial ? 10000 : 15000,
|
|
145
150
|
tags: ['codex', kind],
|
|
@@ -181,7 +186,6 @@ async function captureSubagent(input, context) {
|
|
|
181
186
|
...context,
|
|
182
187
|
kind: 'subagent',
|
|
183
188
|
handle,
|
|
184
|
-
sessionId: `mrcodex:${context.project.handle}:${input.session_id}`,
|
|
185
189
|
skipRemoteDedupe: true,
|
|
186
190
|
timeoutMs: 12000,
|
|
187
191
|
tags: ['codex', 'subagent', input.agent_type || 'unknown'],
|
|
@@ -200,12 +204,19 @@ async function run(kind, input, options = {}) {
|
|
|
200
204
|
const env = options.env || process.env;
|
|
201
205
|
const config = options.config || loadConfig(env);
|
|
202
206
|
const key = options.key || getApiKey(env, config);
|
|
203
|
-
|
|
207
|
+
// One vault per user: every Codex session (new, resumed, or compacted), every
|
|
208
|
+
// repository, and every Claude Code session read and write the same core
|
|
209
|
+
// vault. The repository identity is metadata only (capture labels).
|
|
210
|
+
const project = options.project || resolveProject(input.cwd || process.cwd(), { env, config });
|
|
204
211
|
const context = { env, config, key, project, cwd: input.cwd || process.cwd() };
|
|
205
212
|
if (!key) {
|
|
206
213
|
recordHealth(kind, { ok: false, reason: 'no-api-key', project: project.handle }, env);
|
|
207
214
|
return;
|
|
208
215
|
}
|
|
216
|
+
if (project.ignored) {
|
|
217
|
+
recordHealth(kind, { ok: true, skipped: 'project-disabled', project: project.handle }, env);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
209
220
|
switch (kind) {
|
|
210
221
|
case 'inject': return handleInject(input, context);
|
|
211
222
|
case 'session-start': return handleSessionStart(input, context);
|
package/runtime/mcp-stdio.cjs
CHANGED
|
@@ -3,25 +3,25 @@
|
|
|
3
3
|
|
|
4
4
|
const readline = require('readline');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
-
const { loadConfig, getApiKey, configPath, auditPath, recordAudit, healthPath } = require('./config.cjs');
|
|
6
|
+
const { loadConfig, getApiKey, apiKeySource, configPath, auditPath, recordAudit, healthPath } = require('./config.cjs');
|
|
7
7
|
const { resolveProject } = require('./project.cjs');
|
|
8
|
-
const { searchScoped, storeScoped, forgetLocally,
|
|
8
|
+
const { searchScoped, storeScoped, forgetLocally, displayBody } = require('./memory.cjs');
|
|
9
9
|
const api = require('./api.cjs');
|
|
10
10
|
const { contractFrom, validateEntries } = require('./reflect.cjs');
|
|
11
11
|
const { INTEGRATION_VERSION } = require('./constants.cjs');
|
|
12
12
|
|
|
13
|
-
const SERVER_INSTRUCTIONS = 'MemoryRouter provides durable cross-machine, cross-tool, team-shareable memory.
|
|
13
|
+
const SERVER_INSTRUCTIONS = 'MemoryRouter provides durable cross-machine, cross-tool, team-shareable memory. The vault is one memory shared by every session, repository, and connected tool (Codex, Claude Code via memoryrouter-claude, the hosted MemoryRouter MCP, ChatGPT, Claude), so decisions made anywhere are recalled everywhere. Search memory before answering questions about prior decisions, preferences, incidents, or unfinished work. Store concise durable decisions and preferences after they are established. Automatic Codex hooks may already capture completed turns, so do not duplicate them. Use returned memory handles when referring to a record. Codex native Memories remain the local personal recall layer; MemoryRouter is the shared and auditable layer.';
|
|
14
14
|
|
|
15
15
|
const TOOLS = [
|
|
16
16
|
{
|
|
17
17
|
name: 'memory_search',
|
|
18
|
-
description: "Search durable MemoryRouter memories
|
|
18
|
+
description: "Search durable MemoryRouter memories across the whole vault: every session, repository, and tool connected to it, including Claude Code. Results saved from the current repository are labelled. Tier guide: tier 3 holds the highest-level consolidated reflections (identity, principles, what matters most), tier 2 holds reflections of specific meaningful events, tier 1 holds raw verbatim memories. Match filters to the question. Broad 'most important things we have done' questions: tiers [3], importance 8 or higher, large limit such as 250. Big-picture overviews like 'what do you know about this area or period': tiers [3] with no importance filter. Specific lookups like 'find the decision about X': omit tiers to search all tiers blended and leave importance unset; a high importance filter hides the specific detail you are looking for. Specific meaningful events without raw noise: tiers [2] or [2, 3]. Exact verbatim detail: if the detail is not already in context, drill down by lineage: search tiers [3] to find the relevant thread, then use memory_inspect on the result to see the tier 2 reflections it consolidated, then inspect those to reach the raw memories underneath. Direct tiers [1] search works when you know exact wording to match. Mixing tiers is legitimate; adjust the importance threshold to the question rather than always setting it high. Only reflections (tiers 2 and 3) carry importance ratings. For time-window questions like 'what happened last week' or 'lately' use memory_date_search instead.",
|
|
19
19
|
inputSchema: {
|
|
20
20
|
type: 'object',
|
|
21
21
|
properties: {
|
|
22
22
|
query: { type: 'string', description: 'Semantic search query.' },
|
|
23
|
-
limit: { type: 'integer', minimum: 1, maximum:
|
|
24
|
-
tiers: { type: 'array', items: { type: 'integer', enum: [1, 2, 3] }, minItems: 1, uniqueItems: true, description: "Restrict results to these memory tiers. 3 highest-level consolidated reflections (identity, principles, what matters most), 2 reflections of specific meaningful events, 1 raw verbatim memories. Use [3] for broad 'most important' or big-picture questions, [2] or [2, 3] for specific meaningful events, [1] for exact verbatim detail. Omit to search all tiers blended, the right default for specific lookups. Mixing tiers is fine. Only tiers 2 and 3 carry importance ratings." },
|
|
23
|
+
limit: { type: 'integer', minimum: 1, maximum: 250, description: 'Maximum results to return.' },
|
|
24
|
+
tiers: { type: 'array', items: { type: 'integer', enum: [1, 2, 3] }, minItems: 1, uniqueItems: true, description: "Restrict results to these memory tiers. 3 highest-level consolidated reflections (identity, principles, what matters most), 2 reflections of specific meaningful events, 1 raw verbatim memories. Use [3] for broad 'most important' or big-picture questions, [2] or [2, 3] for specific meaningful events, [1] for exact verbatim detail when you know the wording to match; otherwise drill down from [3] with memory_inspect. Omit to search all tiers blended, the right default for specific lookups. Mixing tiers is fine. Only tiers 2 and 3 carry importance ratings." },
|
|
25
25
|
importance: { type: 'integer', minimum: 1, maximum: 10, description: "Minimum importance threshold, 1 to 10. Only reflections (tiers 2 and 3) carry ratings, so raw memories are excluded whenever this is set. Use 8 or higher with tiers [3] for broad 'most important' questions. Leave unset for general or specific searches; a high threshold hides relevant material." },
|
|
26
26
|
},
|
|
27
27
|
required: ['query'],
|
|
@@ -30,14 +30,14 @@ const TOOLS = [
|
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
name: 'memory_date_search',
|
|
33
|
-
description: "Retrieve memories from a specific time window. Use for questions like 'what happened last week' or 'most important things this month or lately'. Tier guide: tier 3 holds the highest-level consolidated reflections (identity, principles, what matters most), tier 2 holds reflections of specific meaningful events, tier 1 holds raw verbatim memories. Match filters to the question. Time-bounded 'most important things last month' questions: tiers [3], importance 8 or higher, generous max_tokens. Big-picture overviews of a period like 'what do you know about July': tiers [3] with no importance filter. Specific lookups in a window like 'find the decision about X': omit tiers to include all tiers and leave importance unset; a high importance filter hides the specific detail you are looking for. Specific meaningful events without raw noise: tiers [2] or [2, 3]. Exact verbatim detail: tiers [1]. Mixing tiers is legitimate; adjust the importance threshold to the question rather than always setting it high. Only reflections (tiers 2 and 3) carry importance ratings. Omit query for a chronological review; include query to rank by relevance within the window. Resolve relative phrases like 'lately' to concrete ISO dates before calling.",
|
|
33
|
+
description: "Retrieve memories from a specific time window. Use for questions like 'what happened last week' or 'most important things this month or lately'. Tier guide: tier 3 holds the highest-level consolidated reflections (identity, principles, what matters most), tier 2 holds reflections of specific meaningful events, tier 1 holds raw verbatim memories. Match filters to the question. Time-bounded 'most important things last month' questions: tiers [3], importance 8 or higher, generous max_tokens. Big-picture overviews of a period like 'what do you know about July': tiers [3] with no importance filter. Specific lookups in a window like 'find the decision about X': omit tiers to include all tiers and leave importance unset; a high importance filter hides the specific detail you are looking for. Specific meaningful events without raw noise: tiers [2] or [2, 3]. Exact verbatim detail: if the detail is not already in context, drill down by lineage: search tiers [3] to find the relevant thread, then use memory_inspect on the result to see the tier 2 reflections it consolidated, then inspect those to reach the raw memories underneath. Direct tiers [1] search works when you know exact wording to match. Mixing tiers is legitimate; adjust the importance threshold to the question rather than always setting it high. Only reflections (tiers 2 and 3) carry importance ratings. Omit query for a chronological review; include query to rank by relevance within the window. Resolve relative phrases like 'lately' to concrete ISO dates before calling.",
|
|
34
34
|
inputSchema: {
|
|
35
35
|
type: 'object',
|
|
36
36
|
properties: {
|
|
37
37
|
from: { type: 'string', description: 'Window start, ISO 8601 date or datetime (e.g. 2026-08-10 or 2026-08-10T00:00:00Z). Required. Must be a concrete date, not a relative phrase.' },
|
|
38
38
|
to: { type: 'string', description: 'Window end, ISO 8601 date or datetime. Optional, defaults to now.' },
|
|
39
39
|
query: { type: 'string', description: 'Optional relevance query. Omit for a pure chronological browse; include to rank results within the window.' },
|
|
40
|
-
tiers: { type: 'array', items: { type: 'integer', enum: [1, 2, 3] }, minItems: 1, uniqueItems: true, description: "Restrict results to these memory tiers. 3 highest-level consolidated reflections (identity, principles, what matters most), 2 reflections of specific meaningful events, 1 raw verbatim memories. Use [3] for broad 'most important' or big-picture questions, [2] or [2, 3] for specific meaningful events, [1] for exact verbatim detail. Omit to search all tiers blended, the right default for specific lookups. Mixing tiers is fine. Only tiers 2 and 3 carry importance ratings." },
|
|
40
|
+
tiers: { type: 'array', items: { type: 'integer', enum: [1, 2, 3] }, minItems: 1, uniqueItems: true, description: "Restrict results to these memory tiers. 3 highest-level consolidated reflections (identity, principles, what matters most), 2 reflections of specific meaningful events, 1 raw verbatim memories. Use [3] for broad 'most important' or big-picture questions, [2] or [2, 3] for specific meaningful events, [1] for exact verbatim detail when you know the wording to match; otherwise drill down from [3] with memory_inspect. Omit to search all tiers blended, the right default for specific lookups. Mixing tiers is fine. Only tiers 2 and 3 carry importance ratings." },
|
|
41
41
|
importance: { type: 'integer', minimum: 1, maximum: 10, description: "Minimum importance threshold, 1 to 10. Only reflections (tiers 2 and 3) carry ratings, so raw memories are excluded whenever this is set. Use 8 or higher with tiers [3] for broad 'most important' questions. Leave unset for general or specific searches; a high threshold hides relevant material." },
|
|
42
42
|
max_tokens: { type: 'integer', minimum: 1000, maximum: 200000, description: 'Token budget for returned results. Server default 50000; use a smaller value like 5000 for a quick look.' },
|
|
43
43
|
},
|
|
@@ -45,9 +45,22 @@ const TOOLS = [
|
|
|
45
45
|
},
|
|
46
46
|
annotations: { title: 'Date search MemoryRouter', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
47
47
|
},
|
|
48
|
+
{
|
|
49
|
+
name: 'memory_inspect',
|
|
50
|
+
description: "Inspect a memory's consolidation lineage by memory_id: its tier, the sources it consolidated, and what it was consolidated into. Use repeatedly to drill down from a high-level reflection to the tier 2 reflections and raw memories beneath it. Read only.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
memory_id: { type: 'string', description: 'A memory id (UUID) from a search result or a previous inspect.' },
|
|
55
|
+
},
|
|
56
|
+
required: ['memory_id'],
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
},
|
|
59
|
+
annotations: { title: 'Inspect memory lineage', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
60
|
+
},
|
|
48
61
|
{
|
|
49
62
|
name: 'memory_store',
|
|
50
|
-
description: 'Store a concise durable
|
|
63
|
+
description: 'Store a concise durable memory in the vault (tagged with the current repository). Returns a stable content-derived handle and deduplicates exact repeats.',
|
|
51
64
|
inputSchema: {
|
|
52
65
|
type: 'object',
|
|
53
66
|
properties: {
|
|
@@ -128,7 +141,7 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
128
141
|
const config = options.config || loadConfig(env);
|
|
129
142
|
const key = options.key || getApiKey(env, config);
|
|
130
143
|
const cwd = options.cwd || process.cwd();
|
|
131
|
-
const project = options.project || resolveProject(cwd);
|
|
144
|
+
const project = options.project || resolveProject(cwd, { env, config });
|
|
132
145
|
if (!key) return textResult('MemoryRouter API key is not configured. Run `memoryrouter-codex init` or set MEMORYROUTER_API_KEY.', true);
|
|
133
146
|
|
|
134
147
|
if (name === 'memory_search') {
|
|
@@ -147,7 +160,7 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
147
160
|
...(args.importance !== undefined ? { importance: args.importance } : {}),
|
|
148
161
|
});
|
|
149
162
|
if (!result.ok) return textResult(`Memory search failed${result.status ? ` (HTTP ${result.status})` : ''}.`, true);
|
|
150
|
-
if (!result.context) return textResult(
|
|
163
|
+
if (!result.context) return textResult('No memories matched in this vault.');
|
|
151
164
|
let text = result.context;
|
|
152
165
|
const debt = result.reflection;
|
|
153
166
|
if (debt && debt.suggestion) {
|
|
@@ -184,8 +197,7 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
184
197
|
const rows = Array.isArray(data.results) ? data.results : [];
|
|
185
198
|
if (!rows.length) return textResult('No memories in that window matched the filters.');
|
|
186
199
|
const lines = rows.map(row => {
|
|
187
|
-
const
|
|
188
|
-
const body = parsed ? parsed.body : String(row.content || '');
|
|
200
|
+
const body = displayBody(row.content);
|
|
189
201
|
const tierTag = row.tier != null ? ` | tier ${row.tier}` : '';
|
|
190
202
|
const importanceTag = row.importance != null ? ` | importance ${row.importance}` : '';
|
|
191
203
|
return `[${row.date || 'unknown date'}${tierTag}${importanceTag}]\n${body}`;
|
|
@@ -206,7 +218,7 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
206
218
|
if (!result.ok) return textResult(`Memory store failed${result.status ? ` (HTTP ${result.status})` : ''}.`, true);
|
|
207
219
|
return textResult(result.deduplicated
|
|
208
220
|
? `Already stored. Memory handle: ${result.handle}`
|
|
209
|
-
: `Stored
|
|
221
|
+
: `Stored in your vault (from repository ${project.handle}). Memory handle: ${result.handle}`);
|
|
210
222
|
}
|
|
211
223
|
if (name === 'memory_status') {
|
|
212
224
|
const [response, probe] = await Promise.all([
|
|
@@ -220,10 +232,10 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
220
232
|
connected: response.ok,
|
|
221
233
|
status: response.status,
|
|
222
234
|
project: { handle: project.handle, identitySource: project.source, crossMachineStable: project.portable },
|
|
223
|
-
auth:
|
|
235
|
+
auth: apiKeySource(env, config) || 'none',
|
|
224
236
|
configFile: configPath(env),
|
|
225
237
|
localAuditFile: auditPath(env),
|
|
226
|
-
|
|
238
|
+
memoryScope: 'vault (one memory for every session, repository, and tool)',
|
|
227
239
|
vault: response.ok ? response.data : null,
|
|
228
240
|
reflection: debt,
|
|
229
241
|
recentHooks: health,
|
|
@@ -235,6 +247,16 @@ async function callTool(name, args = {}, options = {}) {
|
|
|
235
247
|
}
|
|
236
248
|
return textResult(text);
|
|
237
249
|
}
|
|
250
|
+
if (name === 'memory_inspect') {
|
|
251
|
+
if (!args.memory_id || typeof args.memory_id !== 'string' || !args.memory_id.trim()) {
|
|
252
|
+
return textResult('memory_id is required', true);
|
|
253
|
+
}
|
|
254
|
+
const response = await api.inspect(key, args.memory_id.trim(), { apiBase: config.apiBase });
|
|
255
|
+
if (response.status === 404) return textResult('No memory with that id in the connected vault.', true);
|
|
256
|
+
if (!response.ok) return textResult(`Inspect failed (HTTP ${response.status}).`, true);
|
|
257
|
+
recordAudit('inspect', { ok: true, project: project.handle }, env);
|
|
258
|
+
return textResult(JSON.stringify(response.data || {}, null, 2));
|
|
259
|
+
}
|
|
238
260
|
if (name === 'memory_consolidate') {
|
|
239
261
|
const tier = args.tier === undefined ? 1 : args.tier;
|
|
240
262
|
if (tier !== 1 && tier !== 2) return textResult('tier must be 1 or 2', true);
|
package/runtime/memory.cjs
CHANGED
|
@@ -6,6 +6,8 @@ const { ENVELOPE_HEADER } = require('./constants.cjs');
|
|
|
6
6
|
const { loadConfig, getApiKey, forgetHandle, recordAudit } = require('./config.cjs');
|
|
7
7
|
const { resolveProject } = require('./project.cjs');
|
|
8
8
|
|
|
9
|
+
const PROJECT_HANDLE = '(?:proj|ccp)_[a-f0-9]+';
|
|
10
|
+
|
|
9
11
|
function normalizeContent(content) {
|
|
10
12
|
return String(content || '').replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
|
11
13
|
}
|
|
@@ -37,7 +39,7 @@ function envelope({ projectHandle, handle, kind = 'note', content, capturedAt =
|
|
|
37
39
|
return [
|
|
38
40
|
ENVELOPE_HEADER,
|
|
39
41
|
`project: ${projectHandle}`,
|
|
40
|
-
'scope:
|
|
42
|
+
'scope: vault',
|
|
41
43
|
`handle: ${handle}`,
|
|
42
44
|
`kind: ${kind}`,
|
|
43
45
|
`captured_at: ${capturedAt}`,
|
|
@@ -49,56 +51,128 @@ function envelope({ projectHandle, handle, kind = 'note', content, capturedAt =
|
|
|
49
51
|
|
|
50
52
|
function parseEnvelope(content) {
|
|
51
53
|
const text = String(content || '');
|
|
52
|
-
const headerProject = text.match(
|
|
54
|
+
const headerProject = text.match(new RegExp(`^project:\\s*(${PROJECT_HANDLE})\\s*$`, 'm'))?.[1];
|
|
53
55
|
const headerHandle = text.match(/^handle:\s*(mrh_[a-f0-9]+)\s*$/m)?.[1];
|
|
54
|
-
const stamp = text.match(
|
|
56
|
+
const stamp = text.match(new RegExp(`\\[MR_SCOPE project=(${PROJECT_HANDLE}) handle=(mrh_[a-f0-9]+)\\]`));
|
|
55
57
|
const project = headerProject || stamp?.[1];
|
|
56
58
|
const handle = headerHandle || stamp?.[2];
|
|
57
59
|
if (!project || !handle) return null;
|
|
58
60
|
const kind = text.match(/^kind:\s*([^\n]+)$/m)?.[1]?.trim();
|
|
59
61
|
const separator = text.indexOf('\n---\n');
|
|
60
62
|
const rawBody = separator >= 0 ? text.slice(separator + 5) : text;
|
|
61
|
-
const body = rawBody.replace(
|
|
63
|
+
const body = rawBody.replace(new RegExp(`^\\[MR_SCOPE project=${PROJECT_HANDLE} handle=mrh_[a-f0-9]+\\]\\n?`, 'gm'), '');
|
|
62
64
|
return { project, handle, kind, body };
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// In one shared vault a stored chunk can hold several writers' text back to
|
|
68
|
+
// back: a Claude Code "[USER] ..." turn, then a Codex envelope, then another
|
|
69
|
+
// turn. Rendering must keep ALL of it and drop only envelope metadata (header
|
|
70
|
+
// blocks, a header fragment cut at a chunk boundary, and MR_SCOPE stamps).
|
|
71
|
+
const HEADER_BLOCK = new RegExp(
|
|
72
|
+
`(?:\\[(?:USER|ASSISTANT)\\] )?\\[MemoryRouter Codex Memory v1\\]\\n(?:(?:project|scope|handle|kind|captured_at|tags):[^\\n]*\\n)*---\\n`,
|
|
73
|
+
'g',
|
|
74
|
+
);
|
|
75
|
+
const LEADING_HEADER_FRAGMENT = /^(?:(?:project|scope|handle|kind|captured_at|tags):[^\n]*\n){1,6}---\n/;
|
|
76
|
+
const STAMP = new RegExp(`\\[MR_SCOPE project=${PROJECT_HANDLE} handle=mrh_[a-f0-9]+\\]\\n?`, 'g');
|
|
77
|
+
|
|
78
|
+
function displayBody(content) {
|
|
79
|
+
return String(content || '')
|
|
80
|
+
.replace(HEADER_BLOCK, '')
|
|
81
|
+
.replace(LEADING_HEADER_FRAGMENT, '')
|
|
82
|
+
.replace(STAMP, '')
|
|
83
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// True when the row is exactly one Codex envelope (its handle can label it).
|
|
88
|
+
function isSingleEnvelope(content) {
|
|
89
|
+
const text = String(content || '');
|
|
90
|
+
return /^(?:\[(?:USER|ASSISTANT)\] )?\[MemoryRouter Codex Memory v1\]\n/.test(text)
|
|
91
|
+
&& (text.match(/\[MemoryRouter Codex Memory v1\]/g) || []).length === 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mentionsProject(content, handles) {
|
|
95
|
+
const text = String(content || '');
|
|
96
|
+
for (const handle of handles) {
|
|
97
|
+
if (text.includes(`project: ${handle}`) || text.includes(`[MR_SCOPE project=${handle} `)) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function projectHandles(project) {
|
|
103
|
+
if (!project) return new Set();
|
|
104
|
+
if (typeof project === 'string') return new Set([project]);
|
|
105
|
+
return new Set([project.handle, project.id, project.legacyHandle].filter(Boolean));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Since 1.7.0 there is one vault per user and no project filtering at all:
|
|
109
|
+
// every row the core vault returns is recalled, whatever wrote it (Codex,
|
|
110
|
+
// Claude Code typed captures "[USER] ..." / "[ASSISTANT] ...", MCP saves,
|
|
111
|
+
// ChatGPT or Claude connector saves, reflections, the pending buffer) and
|
|
112
|
+
// whichever repository it came from. The only rows dropped are ones the user
|
|
113
|
+
// suppressed locally with memory_forget.
|
|
114
|
+
function filterMemories(memories, _project, config = {}) {
|
|
67
115
|
const forgotten = new Set(config.forgottenHandles || []);
|
|
68
|
-
// Reflections (tier 2 and 3) are vault-level distilled prose without project
|
|
69
|
-
// envelopes. When the caller explicitly asked for reflection tiers, let those
|
|
70
|
-
// rows through the project filter instead of silently dropping every result.
|
|
71
|
-
const requestedReflectionTiers = Array.isArray(options.tiers) && options.tiers.some(tier => tier >= 2);
|
|
72
116
|
return (Array.isArray(memories) ? memories : []).filter(memory => {
|
|
73
|
-
const parsed = parseEnvelope(memory.content);
|
|
117
|
+
const parsed = parseEnvelope(memory && memory.content);
|
|
74
118
|
if (parsed && forgotten.has(parsed.handle)) return false;
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
return parsed?.project === projectHandle;
|
|
119
|
+
if (memory && memory.id && forgotten.has(memory.id)) return false;
|
|
120
|
+
return true;
|
|
78
121
|
});
|
|
79
122
|
}
|
|
80
123
|
|
|
81
|
-
function formatContext(memories,
|
|
124
|
+
function formatContext(memories, project, maxChars) {
|
|
125
|
+
const own = projectHandles(project);
|
|
126
|
+
const projectHandle = typeof project === 'string' ? project : project && project.handle;
|
|
82
127
|
const prefix = [
|
|
83
|
-
'MemoryRouter recalled the following
|
|
84
|
-
`
|
|
128
|
+
'MemoryRouter recalled the following context from your vault: one memory shared by every session, repository, and tool connected to it (Codex, Claude Code, the MemoryRouter MCP, ChatGPT, Claude).',
|
|
129
|
+
`Current repository handle: ${projectHandle}. Entries tagged "this repo" were saved from this repository; untagged entries may come from any session or tool. Memory handles are stable references.`,
|
|
85
130
|
'Use this as recollection; current repository state and explicit user directions take precedence.',
|
|
86
131
|
].join('\n');
|
|
87
132
|
let output = prefix;
|
|
88
133
|
for (const memory of memories) {
|
|
89
134
|
const parsed = parseEnvelope(memory.content);
|
|
90
|
-
const body =
|
|
91
|
-
|
|
135
|
+
const body = displayBody(memory.content);
|
|
136
|
+
if (!body) continue;
|
|
137
|
+
const handle = (isSingleEnvelope(memory.content) && parsed?.handle) || memory.id || parsed?.handle || (memory.source === 'buffer' ? 'recent' : 'unhandled');
|
|
92
138
|
const timestamp = memory.timestampHuman || (memory.timestamp ? new Date(memory.timestamp).toISOString() : 'unknown time');
|
|
93
139
|
const tierTag = memory.tier != null && memory.tier !== 1 ? ` | tier ${memory.tier}` : '';
|
|
140
|
+
const repoTag = mentionsProject(memory.content, own) ? ' | this repo' : '';
|
|
94
141
|
const importanceTag = memory.importance != null ? ` | importance ${memory.importance}` : '';
|
|
95
|
-
const chunk = `\n\n[${handle} | ${timestamp}${tierTag}${importanceTag}]\n${body.slice(0, 5000)}`;
|
|
96
|
-
if (output.length + chunk.length > maxChars)
|
|
142
|
+
const chunk = `\n\n[${handle} | ${timestamp}${repoTag}${tierTag}${importanceTag}]\n${body.slice(0, 5000)}`;
|
|
143
|
+
if (output.length + chunk.length > maxChars) continue;
|
|
97
144
|
output += chunk;
|
|
98
145
|
}
|
|
99
146
|
return output === prefix ? '' : output;
|
|
100
147
|
}
|
|
101
148
|
|
|
149
|
+
function searchOnce(key, query, limit, config, options) {
|
|
150
|
+
return api.search(key, query, limit, {
|
|
151
|
+
apiBase: config.apiBase,
|
|
152
|
+
timeoutMs: options.timeoutMs || 5500,
|
|
153
|
+
retries: options.retries,
|
|
154
|
+
...(options.tiers !== undefined ? { tiers: options.tiers } : {}),
|
|
155
|
+
...(options.importance !== undefined ? { importance: options.importance } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mergeMemories(lists) {
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const merged = [];
|
|
162
|
+
for (const list of lists) {
|
|
163
|
+
for (const memory of Array.isArray(list) ? list : []) {
|
|
164
|
+
const identity = memory && (memory.id || `${memory.source || ''}\0${memory.content || ''}`);
|
|
165
|
+
if (seen.has(identity)) continue;
|
|
166
|
+
seen.add(identity);
|
|
167
|
+
merged.push(memory);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return merged;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One search against the user's whole vault (no session_id). The vault is the
|
|
174
|
+
// same one the hosted MCP, the ChatGPT and Claude connectors, and Claude Code
|
|
175
|
+
// use, so whatever any of them saved is recalled here.
|
|
102
176
|
async function searchScoped(query, options = {}) {
|
|
103
177
|
const config = options.config || loadConfig(options.env);
|
|
104
178
|
const key = options.key || getApiKey(options.env, config);
|
|
@@ -106,34 +180,24 @@ async function searchScoped(query, options = {}) {
|
|
|
106
180
|
recordAudit('search', { ok: false, reason: 'no-api-key' }, options.env);
|
|
107
181
|
return { ok: false, reason: 'no-api-key', memories: [], context: '' };
|
|
108
182
|
}
|
|
109
|
-
const project = options.project || resolveProject(options.cwd);
|
|
110
|
-
const limit = Math.max(1, Math.min(
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
`${normalizeContent(query)}\nMemoryRouter project handle ${project.handle}`,
|
|
114
|
-
limit,
|
|
115
|
-
{
|
|
116
|
-
apiBase: config.apiBase,
|
|
117
|
-
sessionId: options.sessionId,
|
|
118
|
-
timeoutMs: options.timeoutMs || 5500,
|
|
119
|
-
retries: options.retries,
|
|
120
|
-
...(options.tiers !== undefined ? { tiers: options.tiers } : {}),
|
|
121
|
-
...(options.importance !== undefined ? { importance: options.importance } : {}),
|
|
122
|
-
},
|
|
123
|
-
);
|
|
183
|
+
const project = options.project || resolveProject(options.cwd, { env: options.env, config });
|
|
184
|
+
const limit = Math.max(1, Math.min(250, Number(options.limit || config.maxSearchResults || 12)));
|
|
185
|
+
const text = normalizeContent(query);
|
|
186
|
+
const response = await searchOnce(key, text, limit, config, options);
|
|
124
187
|
if (!response.ok) {
|
|
125
188
|
recordAudit('search', { ok: false, status: response.status, project: project.handle }, options.env);
|
|
126
189
|
return { ok: false, status: response.status, memories: [], context: '' };
|
|
127
190
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
191
|
+
const candidates = mergeMemories([response.data?.memories]);
|
|
192
|
+
const memories = filterMemories(candidates, project, config);
|
|
193
|
+
recordAudit('search', { ok: true, project: project.handle, candidates: candidates.length, returned: memories.length }, options.env);
|
|
130
194
|
return {
|
|
131
195
|
ok: true,
|
|
132
196
|
project,
|
|
133
197
|
memories,
|
|
134
198
|
reflection: response.data?.reflection || null,
|
|
135
199
|
importState: response.data?.import || null,
|
|
136
|
-
context: formatContext(memories, project
|
|
200
|
+
context: formatContext(memories, project, Number(config.contextChars || 12000)),
|
|
137
201
|
};
|
|
138
202
|
}
|
|
139
203
|
|
|
@@ -144,7 +208,7 @@ async function storeScoped(content, options = {}) {
|
|
|
144
208
|
recordAudit('store', { ok: false, reason: 'no-api-key' }, options.env);
|
|
145
209
|
return { ok: false, reason: 'no-api-key' };
|
|
146
210
|
}
|
|
147
|
-
const project = options.project || resolveProject(options.cwd);
|
|
211
|
+
const project = options.project || resolveProject(options.cwd, { env: options.env, config });
|
|
148
212
|
const normalized = normalizeContent(content);
|
|
149
213
|
if (!normalized) return { ok: false, reason: 'empty-content' };
|
|
150
214
|
const kind = options.kind || 'note';
|
|
@@ -172,8 +236,8 @@ async function storeScoped(content, options = {}) {
|
|
|
172
236
|
content: normalized,
|
|
173
237
|
tags: options.tags || [],
|
|
174
238
|
});
|
|
175
|
-
|
|
176
|
-
const response = await api.ingest(key, [{ role: 'user', content: wrapped }],
|
|
239
|
+
// No session_id: the write lands in the user's one core vault.
|
|
240
|
+
const response = await api.ingest(key, [{ role: 'user', content: wrapped }], undefined, {
|
|
177
241
|
apiBase: config.apiBase,
|
|
178
242
|
timeoutMs: options.timeoutMs || 12000,
|
|
179
243
|
});
|
|
@@ -210,8 +274,11 @@ module.exports = {
|
|
|
210
274
|
stampBody,
|
|
211
275
|
envelope,
|
|
212
276
|
parseEnvelope,
|
|
277
|
+
displayBody,
|
|
278
|
+
projectHandles,
|
|
213
279
|
filterMemories,
|
|
214
280
|
formatContext,
|
|
281
|
+
mergeMemories,
|
|
215
282
|
searchScoped,
|
|
216
283
|
storeScoped,
|
|
217
284
|
forgetLocally,
|
package/runtime/project.cjs
CHANGED
|
@@ -1,50 +1,198 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Project identity for Codex hooks and the local MCP fallback.
|
|
4
|
+
//
|
|
5
|
+
// Since 1.7.0 MemoryRouter is one vault per user: every Codex session, every
|
|
6
|
+
// Claude Code session, every repository, and every MemoryRouter surface (the
|
|
7
|
+
// hosted MCP, ChatGPT, Claude) read and write the same core vault. The hooks
|
|
8
|
+
// therefore send NO session_id (the API treats session_id as a hard vault
|
|
9
|
+
// partition, so any session_id would wall memories off again).
|
|
10
|
+
//
|
|
11
|
+
// The repository identity below is still computed, byte-for-byte the same as
|
|
12
|
+
// Claude Code (memoryrouter-claude lib/project.js): the same overrides, the
|
|
13
|
+
// same git remote normalization, the same `ccp_` SHA-256 prefix. It is only
|
|
14
|
+
// metadata now: it is stamped on Codex captures and used locally to label
|
|
15
|
+
// which recalled memories came from the current repository.
|
|
16
|
+
//
|
|
17
|
+
// History: 1.1.0 to 1.6.1 sent `mrcodex:<proj>:<codex session uuid>` (one pile
|
|
18
|
+
// per Codex session, so a new session recalled nothing), and memoryrouter-claude
|
|
19
|
+
// up to 2.6.1 used the per-repo `ccp_` id as a partition (one pile per repo).
|
|
20
|
+
// Both kinds of pile were merged into each user's core vault server-side when
|
|
21
|
+
// 1.7.0 shipped, so nothing saved earlier is lost.
|
|
22
|
+
//
|
|
23
|
+
// Only the opaque hash leaves the machine; local paths and remotes stay local.
|
|
24
|
+
// Keep this file self-contained: it is copied into the Codex runtime
|
|
25
|
+
// directory and must not require anything outside runtime/.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
3
28
|
const path = require('path');
|
|
4
29
|
const crypto = require('crypto');
|
|
5
30
|
const { execFileSync } = require('child_process');
|
|
6
31
|
|
|
7
|
-
|
|
32
|
+
const PROJECT_CONFIG = '.memoryrouter.json';
|
|
33
|
+
|
|
34
|
+
function runGit(args, cwd) {
|
|
8
35
|
try {
|
|
9
|
-
return execFileSync('git',
|
|
36
|
+
return execFileSync('git', args, {
|
|
37
|
+
cwd,
|
|
10
38
|
encoding: 'utf8',
|
|
11
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
39
|
timeout: 2000,
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
13
41
|
}).trim();
|
|
14
|
-
} catch {
|
|
42
|
+
} catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
15
45
|
}
|
|
16
46
|
|
|
47
|
+
// Back-compat helper used by older tests and callers: git -C <cwd> <args>.
|
|
48
|
+
function git(cwd, args) {
|
|
49
|
+
return runGit(args, cwd);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function realpathSafe(value) {
|
|
53
|
+
const resolved = path.resolve(value || process.cwd());
|
|
54
|
+
try {
|
|
55
|
+
return fs.realpathSync.native(resolved);
|
|
56
|
+
} catch {
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Identical to memoryrouter-claude normalizeRemote(). Do not "improve" one
|
|
62
|
+
// side without the other: any difference splits the shared project memory.
|
|
17
63
|
function normalizeRemote(remote) {
|
|
18
64
|
let value = String(remote || '').trim();
|
|
19
|
-
|
|
65
|
+
if (!value) return '';
|
|
66
|
+
|
|
67
|
+
// SCP-like git syntax: git@github.com:Org/Repo.git
|
|
68
|
+
const scp = value.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
69
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
70
|
+
value = `ssh://${scp[1]}/${scp[2]}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
20
73
|
try {
|
|
21
74
|
const url = new URL(value);
|
|
75
|
+
const host = url.hostname.toLowerCase();
|
|
76
|
+
const port = url.port && !((url.protocol === 'https:' && url.port === '443') || (url.protocol === 'ssh:' && url.port === '22'))
|
|
77
|
+
? `:${url.port}`
|
|
78
|
+
: '';
|
|
79
|
+
const pathname = decodeURIComponent(url.pathname).replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
|
|
80
|
+
return host && pathname ? `${host}${port}/${pathname}`.toLowerCase() : '';
|
|
81
|
+
} catch {
|
|
82
|
+
return value
|
|
83
|
+
.replace(/^[^@/]+@/, '')
|
|
84
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
85
|
+
.replace(/[?#].*$/, '')
|
|
86
|
+
.replace(/^\/+|\/+$/g, '')
|
|
87
|
+
.replace(/\.git$/i, '')
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readProjectConfig(root) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, PROJECT_CONFIG), 'utf8'));
|
|
95
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hashIdentity(canonical) {
|
|
102
|
+
return crypto.createHash('sha256').update(canonical).digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pre-1.7 Codex-only handle. Kept so envelopes written by older releases are
|
|
106
|
+
// still recognised as belonging to this repository.
|
|
107
|
+
function legacyHandle(root, rawRemote, commonDirRaw) {
|
|
108
|
+
let remote = String(rawRemote || '').trim();
|
|
109
|
+
remote = remote.replace(/^([^@/]+)@([^:]+):/, 'ssh://$2/');
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(remote);
|
|
22
112
|
url.username = '';
|
|
23
113
|
url.password = '';
|
|
24
|
-
|
|
114
|
+
remote = `${url.protocol}//${url.host}${url.pathname}`;
|
|
25
115
|
} catch { /* local/file-like remote */ }
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function resolveProject(cwd = process.cwd()) {
|
|
30
|
-
const absoluteCwd = path.resolve(cwd);
|
|
31
|
-
const root = git(absoluteCwd, ['rev-parse', '--show-toplevel']) || absoluteCwd;
|
|
32
|
-
const remote = normalizeRemote(git(root, ['remote', 'get-url', 'origin']));
|
|
33
|
-
const commonDir = git(root, ['rev-parse', '--git-common-dir']);
|
|
116
|
+
remote = remote.replace(/\\/g, '/').replace(/\.git\/?$/i, '').replace(/\/+$/, '').toLowerCase();
|
|
34
117
|
const canonical = remote
|
|
35
118
|
? `remote:${remote}`
|
|
36
|
-
:
|
|
37
|
-
? `git:${path.resolve(root,
|
|
119
|
+
: commonDirRaw
|
|
120
|
+
? `git:${path.resolve(root, commonDirRaw)}`
|
|
38
121
|
: `path:${root}`;
|
|
39
|
-
const digest = crypto.createHash('sha256')
|
|
40
|
-
|
|
41
|
-
|
|
122
|
+
const digest = crypto.createHash('sha256').update(`memoryrouter-codex-project-v1\0${canonical}`).digest('hex');
|
|
123
|
+
return `proj_${digest.slice(0, 20)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveProject(cwd = process.cwd(), options = {}) {
|
|
127
|
+
const env = options.env || process.env;
|
|
128
|
+
const config = options.config || {};
|
|
129
|
+
const requestedRoot = realpathSafe(cwd || process.cwd());
|
|
130
|
+
const gitRootRaw = runGit(['rev-parse', '--show-toplevel'], requestedRoot);
|
|
131
|
+
const root = realpathSafe(gitRootRaw || requestedRoot);
|
|
132
|
+
const projectConfig = readProjectConfig(root);
|
|
133
|
+
const ignored = fs.existsSync(path.join(root, '.memoryrouterignore')) || projectConfig.enabled === false;
|
|
134
|
+
|
|
135
|
+
const explicit = env.MEMORYROUTER_PROJECT_ID
|
|
136
|
+
|| projectConfig.projectId
|
|
137
|
+
|| projectConfig.project
|
|
138
|
+
|| config.projectId
|
|
139
|
+
|| config.project;
|
|
140
|
+
|
|
141
|
+
const originUrl = runGit(['config', '--get', 'remote.origin.url'], root);
|
|
142
|
+
const anyRemoteUrl = originUrl || runGit(['config', '--get-regexp', '^remote\\..*\\.url$'], root).split(/\s+/).pop();
|
|
143
|
+
const remote = normalizeRemote(anyRemoteUrl);
|
|
144
|
+
|
|
145
|
+
const commonDirRaw = gitRootRaw
|
|
146
|
+
? runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], root)
|
|
147
|
+
: '';
|
|
148
|
+
const commonDir = commonDirRaw ? realpathSafe(commonDirRaw) : '';
|
|
149
|
+
|
|
150
|
+
let source;
|
|
151
|
+
let canonical;
|
|
152
|
+
if (explicit && String(explicit).trim()) {
|
|
153
|
+
source = 'explicit';
|
|
154
|
+
canonical = `explicit:${String(explicit).trim().toLowerCase()}`;
|
|
155
|
+
} else if (remote) {
|
|
156
|
+
source = 'git-remote';
|
|
157
|
+
canonical = `git:${remote}`;
|
|
158
|
+
} else if (commonDir) {
|
|
159
|
+
source = 'git-common-dir';
|
|
160
|
+
canonical = `git-local:${commonDir}`;
|
|
161
|
+
} else {
|
|
162
|
+
source = 'directory';
|
|
163
|
+
canonical = `dir:${root}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const id = `ccp_${hashIdentity(canonical).slice(0, 24)}`;
|
|
167
|
+
|
|
42
168
|
return {
|
|
43
|
-
handle:
|
|
169
|
+
handle: id,
|
|
170
|
+
id,
|
|
44
171
|
root,
|
|
45
|
-
source
|
|
46
|
-
portable:
|
|
172
|
+
source,
|
|
173
|
+
portable: source === 'git-remote' || source === 'explicit',
|
|
174
|
+
label: path.basename(root) || 'workspace',
|
|
175
|
+
ignored,
|
|
176
|
+
legacyHandle: legacyHandle(gitRootRaw || path.resolve(cwd || process.cwd()), originUrl, gitRootRaw ? runGit(['rev-parse', '--git-common-dir'], root) : ''),
|
|
47
177
|
};
|
|
48
178
|
}
|
|
49
179
|
|
|
50
|
-
|
|
180
|
+
// The MemoryRouter session_id (vault partition) the hooks use. Always
|
|
181
|
+
// undefined since 1.7.0: one vault per user, no per-session or per-repo walls.
|
|
182
|
+
// Kept as a function so callers and tests have one place that states the rule.
|
|
183
|
+
function sessionIdFor() {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
PROJECT_CONFIG,
|
|
189
|
+
git,
|
|
190
|
+
runGit,
|
|
191
|
+
realpathSafe,
|
|
192
|
+
normalizeRemote,
|
|
193
|
+
readProjectConfig,
|
|
194
|
+
hashIdentity,
|
|
195
|
+
legacyHandle,
|
|
196
|
+
resolveProject,
|
|
197
|
+
sessionIdFor,
|
|
198
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// One user = one vault. The vault a user selects when connecting MemoryRouter
|
|
3
|
+
// over OAuth (the MCP login used by Claude Code, Codex, ChatGPT, Claude) must
|
|
4
|
+
// be the same vault the automatic hooks write to with the Memory Key. The auth
|
|
5
|
+
// server compares the two: GET /v1/vault-binding with the Memory Key as Bearer.
|
|
6
|
+
// Kept self-contained (copied into the Codex runtime directory).
|
|
7
|
+
// The response never contains a key, only opaque vault refs and names.
|
|
8
|
+
|
|
9
|
+
const AUTH_BASE = 'https://auth.memoryrouter.ai';
|
|
10
|
+
|
|
11
|
+
function authBase(env = process.env) {
|
|
12
|
+
return String(env.MEMORYROUTER_AUTH_BASE || AUTH_BASE).replace(/\/+$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function checkVaultBinding(key, options = {}) {
|
|
16
|
+
const env = options.env || process.env;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 6000);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${authBase(env)}/v1/vault-binding`, {
|
|
21
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
});
|
|
24
|
+
let data = null;
|
|
25
|
+
try { data = await res.json(); } catch { /* not json */ }
|
|
26
|
+
if (!res.ok || !data || typeof data.status !== 'string') {
|
|
27
|
+
return { status: 'unknown', httpStatus: res.status, message: `vault check unavailable (HTTP ${res.status})` };
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return { status: 'unknown', message: `vault check unavailable (${err && err.name === 'AbortError' ? 'timeout' : (err && err.message) || 'network error'})` };
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function vaultLabel(binding) {
|
|
38
|
+
const vault = binding && binding.vault;
|
|
39
|
+
if (!vault) return 'this vault';
|
|
40
|
+
return vault.name ? `"${vault.name}" (${vault.ref})` : vault.ref;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class VaultMismatchError extends Error {
|
|
44
|
+
constructor(binding) {
|
|
45
|
+
super(`${binding.message || 'This Memory Key is not the vault you selected in OAuth.'}\n Nothing was installed. Run init again with the Memory Key of the vault you selected (app.memoryrouter.ai), or reconnect MemoryRouter and select ${vaultLabel(binding)}.`);
|
|
46
|
+
this.name = 'VaultMismatchError';
|
|
47
|
+
this.binding = binding;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { AUTH_BASE, authBase, checkVaultBinding, vaultLabel, VaultMismatchError };
|