memoryrouter-codex 1.6.1 → 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 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@1.1.0` is the published npm release. Its automatic capture is message-only: one user request plus the final assistant response, with no tool payloads.
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, project-scoped context as developer context. |
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 with strict project filtering, handles, local suppression, status, and full-vault clear confirmation. |
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@1.1.0 init --scope user --auth oauth --api-key-stdin
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@1.1.0 init --scope project --auth oauth --api-key-stdin
66
+ npx -y memoryrouter-codex init --scope project --auth oauth --api-key-stdin
65
67
  codex mcp login memoryrouter
66
68
  ```
67
69
 
@@ -71,7 +73,7 @@ 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@1.1.0 init --scope user --auth api-key --api-key-stdin
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:
@@ -110,25 +112,27 @@ The installer preserves unrelated TOML byte-for-byte outside its managed block.
110
112
 
111
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.
112
114
 
113
- ## Project identity and scoping
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:
114
118
 
115
- The runtime computes:
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:
116
124
 
117
125
  ```text
118
- proj_<first 20 hex chars of SHA-256(canonical project identity)>
126
+ ccp_<first 24 hex chars of SHA-256(canonical project identity)>
119
127
  ```
120
128
 
121
- Identity preference:
122
-
123
- 1. Canonical `origin` git URL, with credentials removed and `.git` normalized.
124
- 2. Git common directory (so local worktrees share a project).
125
- 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.
126
130
 
127
- Only the opaque handle is stored remotely. Local paths are not sent as identity metadata. A canonical remote makes identity stable across worktrees and machines.
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.
128
132
 
129
- Automatic memories use an envelope containing project handle, stable memory handle, kind, and capture time. Retrieval requests extra semantic candidates and then **strictly filters** to the current project handle before context injection. Legacy/unscoped vault records remain available through explicit remote MCP search but are not automatically injected in project mode.
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.
130
134
 
131
- Set `retrievalScope` to `"vault"` in the private config only if you intentionally want unscoped automatic recall.
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.
132
136
 
133
137
  ## Historical import (agent-driven)
134
138
 
@@ -222,15 +226,15 @@ Doctor checks Codex version, managed config, runtime version, hook count, `AGENT
222
226
  Upgrade idempotently by re-running the pinned/new package:
223
227
 
224
228
  ```bash
225
- npx -y memoryrouter-codex@1.1.0 init --scope user
229
+ npx -y memoryrouter-codex init --scope user
226
230
  ```
227
231
 
228
232
  Uninstall:
229
233
 
230
234
  ```bash
231
- npx -y memoryrouter-codex@1.1.0 uninstall --scope user
235
+ npx -y memoryrouter-codex uninstall --scope user
232
236
  # or
233
- npx -y memoryrouter-codex@1.1.0 uninstall --scope project --project-dir /repo
237
+ npx -y memoryrouter-codex uninstall --scope project --project-dir /repo
234
238
  ```
235
239
 
236
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.
@@ -242,7 +246,7 @@ Uninstall deliberately retains `~/.memoryrouter/codex/` because another project
242
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.
243
247
  - Some specialized tool paths can opt out of hooks. Codex documents hooks as a useful extension surface, not a complete enforcement boundary.
244
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.
245
- - Project scoping is strict for this package's automatic retrieval/local MCP. The existing hosted remote MCP server has no first-class project argument, so use local tools/hooks when cross-project isolation is required.
249
+ - Everything shares one vault per user. Strict isolation between two bodies of work requires a separate account and vault.
246
250
  - Per-memory server deletion is not available through the current API. Local suppression and explicit full-vault clear are accurately labeled.
247
251
  - API-level idempotency is not yet available; timeout-after-acceptance duplicates remain possible.
248
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 or a key saved with --api-key-stdin.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
26
+ return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--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 (result.auth === 'oauth') process.stdout.write('\nNext: codex mcp login memoryrouter\nThen open Codex and run /mcp verbose and /hooks.\n');
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
- process.stdout.write('\nRun: memoryrouter-codex doctor\n');
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
- return { layout, auth, results, automaticMemory: Boolean(options.apiKey || process.env.MEMORYROUTER_API_KEY || loadConfig(options.env).apiKey) };
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 key = process.env.MEMORYROUTER_API_KEY || cfg.apiKey;
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 ? 'API key is valid; automatic retrieval/capture is active.' : `API key validation failed (HTTP ${response.status}).`);
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
- const project = resolveProject(layout.root);
226
- addCheck(checks, 'project-identity', project.portable ? 'pass' : 'warn', project.portable
227
- ? `Project handle ${project.handle} is derived from a canonical git remote and is stable across worktrees/machines.`
228
- : `Project handle ${project.handle} falls back to a local ${project.source}; add an origin remote for cross-machine identity.`);
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.6.1",
4
- "description": "Automatic, project-scoped MemoryRouter memory for OpenAI Codex",
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: { messages, session_id: sessionId, model: 'codex' },
71
+ body,
68
72
  retries: 0,
69
73
  });
70
74
  }
@@ -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
- return { ...defaultConfig(), ...(parsed && typeof parsed === 'object' ? parsed : {}) };
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,
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const INTEGRATION_VERSION = '1.6.1';
3
+ const INTEGRATION_VERSION = '1.7.0';
4
4
  const RUNTIME_SCHEMA_VERSION = 1;
5
5
  const API_BASE = 'https://api.memoryrouter.ai';
6
6
  const MCP_ENDPOINT = 'https://mcp.memoryrouter.ai/mcp';
package/runtime/hook.cjs CHANGED
@@ -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 result = await searchScoped(
109
- 'Current project decisions, conventions, unresolved work, preferences, and recent implementation context',
110
- { ...context, sessionId: `mrcodex:${context.project.handle}:${input.session_id}`, timeoutMs: 6500 },
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
- const project = options.project || resolveProject(input.cwd || process.cwd());
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);
@@ -3,24 +3,24 @@
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, parseEnvelope } = require('./memory.cjs');
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. Search project 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.';
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. Defaults to the current git project and filters other projects out. 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.",
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: 250, description: 'Candidate results before project filtering.' },
23
+ limit: { type: 'integer', minimum: 1, maximum: 250, description: 'Maximum results to return.' },
24
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
  },
@@ -60,7 +60,7 @@ const TOOLS = [
60
60
  },
61
61
  {
62
62
  name: 'memory_store',
63
- description: 'Store a concise durable project memory. Returns a stable content-derived handle and deduplicates exact repeats.',
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.',
64
64
  inputSchema: {
65
65
  type: 'object',
66
66
  properties: {
@@ -141,7 +141,7 @@ async function callTool(name, args = {}, options = {}) {
141
141
  const config = options.config || loadConfig(env);
142
142
  const key = options.key || getApiKey(env, config);
143
143
  const cwd = options.cwd || process.cwd();
144
- const project = options.project || resolveProject(cwd);
144
+ const project = options.project || resolveProject(cwd, { env, config });
145
145
  if (!key) return textResult('MemoryRouter API key is not configured. Run `memoryrouter-codex init` or set MEMORYROUTER_API_KEY.', true);
146
146
 
147
147
  if (name === 'memory_search') {
@@ -160,7 +160,7 @@ async function callTool(name, args = {}, options = {}) {
160
160
  ...(args.importance !== undefined ? { importance: args.importance } : {}),
161
161
  });
162
162
  if (!result.ok) return textResult(`Memory search failed${result.status ? ` (HTTP ${result.status})` : ''}.`, true);
163
- if (!result.context) return textResult(`No memories matched current project ${project.handle}.`);
163
+ if (!result.context) return textResult('No memories matched in this vault.');
164
164
  let text = result.context;
165
165
  const debt = result.reflection;
166
166
  if (debt && debt.suggestion) {
@@ -197,8 +197,7 @@ async function callTool(name, args = {}, options = {}) {
197
197
  const rows = Array.isArray(data.results) ? data.results : [];
198
198
  if (!rows.length) return textResult('No memories in that window matched the filters.');
199
199
  const lines = rows.map(row => {
200
- const parsed = parseEnvelope(row.content);
201
- const body = parsed ? parsed.body : String(row.content || '');
200
+ const body = displayBody(row.content);
202
201
  const tierTag = row.tier != null ? ` | tier ${row.tier}` : '';
203
202
  const importanceTag = row.importance != null ? ` | importance ${row.importance}` : '';
204
203
  return `[${row.date || 'unknown date'}${tierTag}${importanceTag}]\n${body}`;
@@ -219,7 +218,7 @@ async function callTool(name, args = {}, options = {}) {
219
218
  if (!result.ok) return textResult(`Memory store failed${result.status ? ` (HTTP ${result.status})` : ''}.`, true);
220
219
  return textResult(result.deduplicated
221
220
  ? `Already stored. Memory handle: ${result.handle}`
222
- : `Stored for project ${project.handle}. Memory handle: ${result.handle}`);
221
+ : `Stored in your vault (from repository ${project.handle}). Memory handle: ${result.handle}`);
223
222
  }
224
223
  if (name === 'memory_status') {
225
224
  const [response, probe] = await Promise.all([
@@ -233,10 +232,10 @@ async function callTool(name, args = {}, options = {}) {
233
232
  connected: response.ok,
234
233
  status: response.status,
235
234
  project: { handle: project.handle, identitySource: project.source, crossMachineStable: project.portable },
236
- auth: process.env.MEMORYROUTER_API_KEY ? 'environment' : 'private-config-file',
235
+ auth: apiKeySource(env, config) || 'none',
237
236
  configFile: configPath(env),
238
237
  localAuditFile: auditPath(env),
239
- retrievalScope: config.retrievalScope,
238
+ memoryScope: 'vault (one memory for every session, repository, and tool)',
240
239
  vault: response.ok ? response.data : null,
241
240
  reflection: debt,
242
241
  recentHooks: health,
@@ -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: project',
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(/^project:\s*(proj_[a-f0-9]+)\s*$/m)?.[1];
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(/\[MR_SCOPE project=(proj_[a-f0-9]+) handle=(mrh_[a-f0-9]+)\]/);
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(/^\[MR_SCOPE project=proj_[a-f0-9]+ handle=mrh_[a-f0-9]+\]\n?/gm, '');
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
- function filterMemories(memories, projectHandle, config, options = {}) {
66
- const scope = config.retrievalScope || 'project';
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 (scope === 'vault') return true;
76
- if (requestedReflectionTiers && memory.tier != null && memory.tier >= 2) return true;
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, projectHandle, maxChars) {
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 project-scoped context from prior work.',
84
- `Project handle: ${projectHandle}. Memory handles are stable references.`,
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 = parsed ? parsed.body : String(memory.content || '');
91
- const handle = parsed?.handle || memory.id || 'unhandled';
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) break;
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);
183
+ const project = options.project || resolveProject(options.cwd, { env: options.env, config });
110
184
  const limit = Math.max(1, Math.min(250, Number(options.limit || config.maxSearchResults || 12)));
111
- const response = await api.search(
112
- key,
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
- );
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 memories = filterMemories(response.data?.memories, project.handle, config, { tiers: options.tiers });
129
- recordAudit('search', { ok: true, project: project.handle, candidates: response.data?.memories?.length || 0, returned: memories.length }, options.env);
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.handle, Number(config.contextChars || 12000)),
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
- const sessionId = options.sessionId || `mrcodex:${project.handle}:manual`;
176
- const response = await api.ingest(key, [{ role: 'user', content: wrapped }], sessionId, {
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,
@@ -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
- function git(cwd, args) {
32
+ const PROJECT_CONFIG = '.memoryrouter.json';
33
+
34
+ function runGit(args, cwd) {
8
35
  try {
9
- return execFileSync('git', ['-C', cwd, ...args], {
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 { return ''; }
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
- value = value.replace(/^([^@/]+)@([^:]+):/, 'ssh://$2/');
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
- value = `${url.protocol}//${url.host}${url.pathname}`;
114
+ remote = `${url.protocol}//${url.host}${url.pathname}`;
25
115
  } catch { /* local/file-like remote */ }
26
- return value.replace(/\\/g, '/').replace(/\.git\/?$/i, '').replace(/\/+$/, '').toLowerCase();
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
- : commonDir
37
- ? `git:${path.resolve(root, commonDir)}`
119
+ : commonDirRaw
120
+ ? `git:${path.resolve(root, commonDirRaw)}`
38
121
  : `path:${root}`;
39
- const digest = crypto.createHash('sha256')
40
- .update(`memoryrouter-codex-project-v1\0${canonical}`)
41
- .digest('hex');
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: `proj_${digest.slice(0, 20)}`,
169
+ handle: id,
170
+ id,
44
171
  root,
45
- source: remote ? 'git-remote' : commonDir ? 'git-common-dir' : 'path',
46
- portable: Boolean(remote),
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
- module.exports = { git, normalizeRemote, resolveProject };
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 };