memoryrouter-codex 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.8.0';
4
4
  const RUNTIME_SCHEMA_VERSION = 1;
5
5
  const API_BASE = 'https://api.memoryrouter.ai';
6
6
  const MCP_ENDPOINT = 'https://mcp.memoryrouter.ai/mcp';
package/runtime/hook.cjs CHANGED
@@ -64,8 +64,16 @@ function maybeAppendNudge(context, reflection, sessionId, hookContext, importSta
64
64
  return context;
65
65
  }
66
66
 
67
+ // The prompt that started THIS turn. Never another turn's prompt: falling back
68
+ // to "the last prompt seen" could pair a response with the wrong question.
69
+ function promptForTurn(state, turnId) {
70
+ if (state.prompts?.[turnId]) return state.prompts[turnId];
71
+ if (turnId && state.lastTurnId === turnId) return state.lastPrompt || '';
72
+ return '';
73
+ }
74
+
67
75
  function turnContent(input, state) {
68
- const prompt = state.prompts?.[input.turn_id] || state.lastPrompt || '';
76
+ const prompt = promptForTurn(state, input.turn_id);
69
77
  return [
70
78
  prompt ? `User request:
71
79
  ${truncate(prompt, 40000)}` : null,
@@ -79,15 +87,18 @@ ${truncate(input.last_assistant_message, 60000)}`
79
87
  async function handleInject(input, context) {
80
88
  const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
81
89
  if (!prompt) return;
82
- const state = loadSessionState(input.session_id, context.env);
83
- state.prompts = { ...(state.prompts || {}), [input.turn_id]: prompt.slice(0, 100000) };
84
- state.lastPrompt = prompt.slice(0, 100000);
85
- state.lastTurnId = input.turn_id;
86
- saveSessionState(input.session_id, state, context.env);
90
+ // A thread-spawned subagent's "prompt" is the parent agent's instruction (an
91
+ // intermediate step). Recall for it, but never record it for capture.
92
+ if (!input.agent_id) {
93
+ const state = loadSessionState(input.session_id, context.env);
94
+ state.prompts = { ...(state.prompts || {}), [input.turn_id]: prompt.slice(0, 100000) };
95
+ state.lastPrompt = prompt.slice(0, 100000);
96
+ state.lastTurnId = input.turn_id;
97
+ saveSessionState(input.session_id, state, context.env);
98
+ }
87
99
 
88
100
  const result = await searchScoped(prompt, {
89
101
  ...context,
90
- sessionId: `mrcodex:${context.project.handle}:${input.session_id}`,
91
102
  timeoutMs: 5500,
92
103
  });
93
104
  recordHealth('inject', {
@@ -103,12 +114,19 @@ async function handleInject(input, context) {
103
114
  emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
104
115
  }
105
116
 
117
+ const SESSION_START_QUERY = 'Current project decisions, conventions, unresolved work, preferences, and recent implementation context';
118
+
119
+ // SessionStart fires for new, resumed, cleared, and compacted sessions. After
120
+ // compaction (and on resume) the most useful recall is whatever the user was
121
+ // last working on, so the query is the session's last prompt when one exists.
106
122
  async function handleSessionStart(input, context) {
107
123
  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
- );
124
+ const state = input.session_id ? loadSessionState(input.session_id, context.env) : {};
125
+ const continuing = input.source === 'compact' || input.source === 'resume';
126
+ const query = continuing && state.lastPrompt
127
+ ? `${state.lastPrompt.slice(0, 4000)}\n\n${SESSION_START_QUERY}`
128
+ : SESSION_START_QUERY;
129
+ const result = await searchScoped(query, { ...context, timeoutMs: 6500 });
112
130
  recordHealth('session-start', {
113
131
  ok: result.ok,
114
132
  source: input.source,
@@ -119,18 +137,27 @@ async function handleSessionStart(input, context) {
119
137
  emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
120
138
  }
121
139
 
140
+ // Stop (partial=false): save the user's prompt + the final assistant response.
141
+ // SessionEnd / PreCompact (partial=true): only if the last turn never reached
142
+ // Stop (interrupted), save that turn's user prompt so it is not lost. A turn
143
+ // that was already saved is never saved again.
122
144
  async function captureTurn(input, context, partial = false) {
123
145
  const sessionId = input.session_id;
124
146
  const turnId = input.turn_id || loadSessionState(sessionId, context.env).lastTurnId;
125
147
  if (!sessionId || !turnId) return { ok: false, reason: 'missing-turn' };
126
148
  if (!partial && input.stop_hook_active) return { ok: true, skipped: 'stop-hook-active' };
127
149
  const state = loadSessionState(sessionId, context.env);
128
- if (!partial && state.capturedTurns?.[turnId]) {
150
+ if (state.capturedTurns?.[turnId]) {
129
151
  return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[turnId] };
130
152
  }
131
- const prompt = state.prompts?.[turnId] || state.lastPrompt || '';
153
+ if (partial && state.checkpointedTurns?.[turnId]) {
154
+ return { ok: true, skipped: 'deduplicated', handle: state.checkpointedTurns[turnId] };
155
+ }
156
+ const prompt = promptForTurn(state, turnId);
132
157
  if (!partial && !input.last_assistant_message) return { ok: true, skipped: 'empty' };
133
- if (partial && !prompt && !input.last_assistant_message) return { ok: true, skipped: 'empty' };
158
+ if (partial && !prompt) return { ok: true, skipped: 'empty' };
159
+ // PreCompact/SessionEnd carry no final response; never invent one.
160
+ if (partial) input = { ...input, last_assistant_message: undefined };
134
161
  const content = turnContent({ ...input, turn_id: turnId }, state);
135
162
  const kind = partial ? 'checkpoint' : 'turn';
136
163
  const handle = memoryHandle(context.project.handle, kind, content);
@@ -139,16 +166,18 @@ async function captureTurn(input, context, partial = false) {
139
166
  ...context,
140
167
  kind,
141
168
  handle,
142
- sessionId: `mrcodex:${context.project.handle}:${sessionId}`,
143
169
  skipRemoteDedupe: true,
144
170
  timeoutMs: partial ? 10000 : 15000,
145
171
  tags: ['codex', kind],
146
172
  });
147
173
  if (result.ok && result.stored) {
148
174
  state.capturedHandles = [...(state.capturedHandles || []), handle].slice(-500);
149
- if (!partial) state.capturedTurns = { ...(state.capturedTurns || {}), [turnId]: handle };
150
- if (Object.keys(state.capturedTurns || {}).length > 500) {
151
- state.capturedTurns = Object.fromEntries(Object.entries(state.capturedTurns).slice(-500));
175
+ // A checkpoint (prompt of an unfinished turn) never blocks that turn's
176
+ // full save if it does finish later.
177
+ const ledger = partial ? 'checkpointedTurns' : 'capturedTurns';
178
+ state[ledger] = { ...(state[ledger] || {}), [turnId]: handle };
179
+ if (Object.keys(state[ledger] || {}).length > 500) {
180
+ state[ledger] = Object.fromEntries(Object.entries(state[ledger]).slice(-500));
152
181
  }
153
182
  if (!partial && state.prompts) delete state.prompts[turnId];
154
183
  state.lastCaptureAt = new Date().toISOString();
@@ -165,47 +194,33 @@ async function captureTurn(input, context, partial = false) {
165
194
  return result;
166
195
  }
167
196
 
197
+ // Subagent output is intermediate work, not the user's conversation. Saving it
198
+ // broke the "user message + final response only" rule, so 1.8.0 no longer
199
+ // installs a SubagentStop hook and this handler is a no-op for installs that
200
+ // still have one until `init` is re-run.
168
201
  async function captureSubagent(input, context) {
169
- if (!input.last_assistant_message || !input.session_id) return { ok: true, skipped: 'empty' };
170
- const state = loadSessionState(input.session_id, context.env);
171
- const captureKey = `subagent:${input.agent_id || 'unknown'}:${input.turn_id || 'unknown'}`;
172
- if (state.capturedTurns?.[captureKey]) return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[captureKey] };
173
- const content = [
174
- 'Completed Codex subagent outcome.',
175
- `Subagent type: ${input.agent_type || 'unknown'}`,
176
- `Subagent id: ${input.agent_id || 'unknown'}`,
177
- `Outcome:\n${truncate(input.last_assistant_message, 60000)}`,
178
- ].join('\n\n');
179
- const handle = memoryHandle(context.project.handle, 'subagent', content);
180
- const result = await storeScoped(content, {
181
- ...context,
182
- kind: 'subagent',
183
- handle,
184
- sessionId: `mrcodex:${context.project.handle}:${input.session_id}`,
185
- skipRemoteDedupe: true,
186
- timeoutMs: 12000,
187
- tags: ['codex', 'subagent', input.agent_type || 'unknown'],
188
- });
189
- if (result.ok && result.stored) {
190
- state.capturedHandles = [...(state.capturedHandles || []), handle].slice(-500);
191
- state.capturedTurns = { ...(state.capturedTurns || {}), [captureKey]: handle };
192
- if (Object.keys(state.capturedTurns).length > 500) state.capturedTurns = Object.fromEntries(Object.entries(state.capturedTurns).slice(-500));
193
- saveSessionState(input.session_id, state, context.env);
194
- }
195
- recordHealth('capture-subagent', { ok: result.ok, status: result.status, project: context.project.handle, handle }, context.env);
196
- return result;
202
+ recordHealth('capture-subagent', { ok: true, skipped: 'subagent-not-captured', project: context.project.handle }, context.env);
203
+ return { ok: true, skipped: 'subagent-not-captured' };
197
204
  }
198
205
 
206
+
199
207
  async function run(kind, input, options = {}) {
200
208
  const env = options.env || process.env;
201
209
  const config = options.config || loadConfig(env);
202
210
  const key = options.key || getApiKey(env, config);
203
- const project = options.project || resolveProject(input.cwd || process.cwd());
211
+ // One vault per user: every Codex session (new, resumed, or compacted), every
212
+ // repository, and every Claude Code session read and write the same core
213
+ // vault. The repository identity is metadata only (capture labels).
214
+ const project = options.project || resolveProject(input.cwd || process.cwd(), { env, config });
204
215
  const context = { env, config, key, project, cwd: input.cwd || process.cwd() };
205
216
  if (!key) {
206
217
  recordHealth(kind, { ok: false, reason: 'no-api-key', project: project.handle }, env);
207
218
  return;
208
219
  }
220
+ if (project.ignored) {
221
+ recordHealth(kind, { ok: true, skipped: 'project-disabled', project: project.handle }, env);
222
+ return;
223
+ }
209
224
  switch (kind) {
210
225
  case 'inject': return handleInject(input, context);
211
226
  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,