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/README.md +67 -32
- package/bin/cli.cjs +33 -6
- package/lib/hooks.cjs +47 -8
- package/lib/installer.cjs +80 -19
- package/lib/managed.cjs +28 -4
- package/package.json +2 -2
- package/runtime/api.cjs +5 -1
- package/runtime/config.cjs +38 -3
- package/runtime/constants.cjs +1 -1
- package/runtime/hook.cjs +62 -47
- package/runtime/mcp-stdio.cjs +12 -13
- package/runtime/memory.cjs +108 -41
- package/runtime/project.cjs +171 -23
- package/runtime/vault-binding.cjs +51 -0
package/runtime/api.cjs
CHANGED
|
@@ -60,11 +60,15 @@ function search(key, query, limit, options = {}) {
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// session_id is only sent when a caller passes one explicitly. The hooks never
|
|
64
|
+
// do (one vault per user since 1.7.0): a session_id is a hard vault partition.
|
|
63
65
|
function ingest(key, messages, sessionId, options = {}) {
|
|
66
|
+
const body = { messages, model: 'codex' };
|
|
67
|
+
if (sessionId) body.session_id = sessionId;
|
|
64
68
|
return request('POST', '/v1/memory/ingest', {
|
|
65
69
|
...options,
|
|
66
70
|
key,
|
|
67
|
-
body
|
|
71
|
+
body,
|
|
68
72
|
retries: 0,
|
|
69
73
|
});
|
|
70
74
|
}
|
package/runtime/config.cjs
CHANGED
|
@@ -38,26 +38,58 @@ function defaultConfig() {
|
|
|
38
38
|
apiBase: API_BASE,
|
|
39
39
|
contextChars: DEFAULT_CONTEXT_CHARS,
|
|
40
40
|
maxSearchResults: DEFAULT_SEARCH_RESULTS,
|
|
41
|
-
retrievalScope: 'project',
|
|
42
41
|
forgottenHandles: [],
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
// Pre-1.7 scoping keys (retrievalScope, includeGlobal, memoryScope) are dropped
|
|
46
|
+
// on load: there is one vault per user and nothing left to scope.
|
|
47
|
+
const RETIRED_KEYS = ['retrievalScope', 'includeGlobal', 'memoryScope'];
|
|
48
|
+
|
|
46
49
|
function loadConfig(env = process.env) {
|
|
47
50
|
const file = configPath(env);
|
|
48
51
|
let parsed = {};
|
|
49
52
|
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* defaults */ }
|
|
50
|
-
|
|
53
|
+
const merged = { ...defaultConfig(), ...(parsed && typeof parsed === 'object' ? parsed : {}) };
|
|
54
|
+
for (const retired of RETIRED_KEYS) delete merged[retired];
|
|
55
|
+
return merged;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function saveConfig(config, env = process.env) {
|
|
54
59
|
const safe = { ...defaultConfig(), ...config, version: RUNTIME_SCHEMA_VERSION };
|
|
60
|
+
for (const retired of RETIRED_KEYS) delete safe[retired];
|
|
55
61
|
atomicWriteJson(configPath(env), safe, 0o600);
|
|
56
62
|
return safe;
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
// memoryrouter-claude keeps its key in ~/.memoryrouter/config.json. When the
|
|
66
|
+
// user already set up Claude Code, Codex reuses that key so both agents land in
|
|
67
|
+
// the same vault (one memory for every session, repo and tool) with no second
|
|
68
|
+
// paste.
|
|
69
|
+
function claudeConfigPath(env = process.env) {
|
|
70
|
+
const dir = env.MEMORYROUTER_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.memoryrouter');
|
|
71
|
+
return path.join(path.resolve(dir), 'config.json');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function claudeApiKey(env = process.env) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf8'));
|
|
77
|
+
const key = parsed && typeof parsed.apiKey === 'string' ? parsed.apiKey.trim() : '';
|
|
78
|
+
return /^mk[-_]/.test(key) ? key : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function apiKeySource(env = process.env, config = loadConfig(env)) {
|
|
85
|
+
if (env.MEMORYROUTER_API_KEY) return 'environment';
|
|
86
|
+
if (config.apiKey) return 'codex-config';
|
|
87
|
+
if (claudeApiKey(env)) return 'claude-code-config';
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
function getApiKey(env = process.env, config = loadConfig(env)) {
|
|
60
|
-
return env.MEMORYROUTER_API_KEY || config.apiKey || null;
|
|
92
|
+
return env.MEMORYROUTER_API_KEY || config.apiKey || claudeApiKey(env) || null;
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
function safeId(value) {
|
|
@@ -147,6 +179,9 @@ module.exports = {
|
|
|
147
179
|
loadConfig,
|
|
148
180
|
saveConfig,
|
|
149
181
|
getApiKey,
|
|
182
|
+
apiKeySource,
|
|
183
|
+
claudeApiKey,
|
|
184
|
+
claudeConfigPath,
|
|
150
185
|
safeId,
|
|
151
186
|
stateDir,
|
|
152
187
|
sessionStatePath,
|
package/runtime/constants.cjs
CHANGED
package/runtime/hook.cjs
CHANGED
|
@@ -64,8 +64,16 @@ function maybeAppendNudge(context, reflection, sessionId, hookContext, importSta
|
|
|
64
64
|
return context;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The prompt that started THIS turn. Never another turn's prompt: falling back
|
|
68
|
+
// to "the last prompt seen" could pair a response with the wrong question.
|
|
69
|
+
function promptForTurn(state, turnId) {
|
|
70
|
+
if (state.prompts?.[turnId]) return state.prompts[turnId];
|
|
71
|
+
if (turnId && state.lastTurnId === turnId) return state.lastPrompt || '';
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
function turnContent(input, state) {
|
|
68
|
-
const prompt = state
|
|
76
|
+
const prompt = promptForTurn(state, input.turn_id);
|
|
69
77
|
return [
|
|
70
78
|
prompt ? `User request:
|
|
71
79
|
${truncate(prompt, 40000)}` : null,
|
|
@@ -79,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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
// A thread-spawned subagent's "prompt" is the parent agent's instruction (an
|
|
91
|
+
// intermediate step). Recall for it, but never record it for capture.
|
|
92
|
+
if (!input.agent_id) {
|
|
93
|
+
const state = loadSessionState(input.session_id, context.env);
|
|
94
|
+
state.prompts = { ...(state.prompts || {}), [input.turn_id]: prompt.slice(0, 100000) };
|
|
95
|
+
state.lastPrompt = prompt.slice(0, 100000);
|
|
96
|
+
state.lastTurnId = input.turn_id;
|
|
97
|
+
saveSessionState(input.session_id, state, context.env);
|
|
98
|
+
}
|
|
87
99
|
|
|
88
100
|
const result = await searchScoped(prompt, {
|
|
89
101
|
...context,
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
150
|
+
if (state.capturedTurns?.[turnId]) {
|
|
129
151
|
return { ok: true, skipped: 'deduplicated', handle: state.capturedTurns[turnId] };
|
|
130
152
|
}
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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);
|
package/runtime/mcp-stdio.cjs
CHANGED
|
@@ -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,
|
|
8
|
+
const { searchScoped, storeScoped, forgetLocally, displayBody } = require('./memory.cjs');
|
|
9
9
|
const api = require('./api.cjs');
|
|
10
10
|
const { contractFrom, validateEntries } = require('./reflect.cjs');
|
|
11
11
|
const { INTEGRATION_VERSION } = require('./constants.cjs');
|
|
12
12
|
|
|
13
|
-
const SERVER_INSTRUCTIONS = 'MemoryRouter provides durable cross-machine, cross-tool, team-shareable memory.
|
|
13
|
+
const SERVER_INSTRUCTIONS = 'MemoryRouter provides durable cross-machine, cross-tool, team-shareable memory. The vault is one memory shared by every session, repository, and connected tool (Codex, Claude Code via memoryrouter-claude, the hosted MemoryRouter MCP, ChatGPT, Claude), so decisions made anywhere are recalled everywhere. Search memory before answering questions about prior decisions, preferences, incidents, or unfinished work. Store concise durable decisions and preferences after they are established. Automatic Codex hooks may already capture completed turns, so do not duplicate them. Use returned memory handles when referring to a record. Codex native Memories remain the local personal recall layer; MemoryRouter is the shared and auditable layer.';
|
|
14
14
|
|
|
15
15
|
const TOOLS = [
|
|
16
16
|
{
|
|
17
17
|
name: 'memory_search',
|
|
18
|
-
description: "Search durable MemoryRouter memories
|
|
18
|
+
description: "Search durable MemoryRouter memories across the whole vault: every session, repository, and tool connected to it, including Claude Code. Results saved from the current repository are labelled. Tier guide: tier 3 holds the highest-level consolidated reflections (identity, principles, what matters most), tier 2 holds reflections of specific meaningful events, tier 1 holds raw verbatim memories. Match filters to the question. Broad 'most important things we have done' questions: tiers [3], importance 8 or higher, large limit such as 250. Big-picture overviews like 'what do you know about this area or period': tiers [3] with no importance filter. Specific lookups like 'find the decision about X': omit tiers to search all tiers blended and leave importance unset; a high importance filter hides the specific detail you are looking for. Specific meaningful events without raw noise: tiers [2] or [2, 3]. Exact verbatim detail: if the detail is not already in context, drill down by lineage: search tiers [3] to find the relevant thread, then use memory_inspect on the result to see the tier 2 reflections it consolidated, then inspect those to reach the raw memories underneath. Direct tiers [1] search works when you know exact wording to match. Mixing tiers is legitimate; adjust the importance threshold to the question rather than always setting it high. Only reflections (tiers 2 and 3) carry importance ratings. For time-window questions like 'what happened last week' or 'lately' use memory_date_search instead.",
|
|
19
19
|
inputSchema: {
|
|
20
20
|
type: 'object',
|
|
21
21
|
properties: {
|
|
22
22
|
query: { type: 'string', description: 'Semantic search query.' },
|
|
23
|
-
limit: { type: 'integer', minimum: 1, maximum: 250, description: '
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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:
|
|
235
|
+
auth: apiKeySource(env, config) || 'none',
|
|
237
236
|
configFile: configPath(env),
|
|
238
237
|
localAuditFile: auditPath(env),
|
|
239
|
-
|
|
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,
|
package/runtime/memory.cjs
CHANGED
|
@@ -6,6 +6,8 @@ const { ENVELOPE_HEADER } = require('./constants.cjs');
|
|
|
6
6
|
const { loadConfig, getApiKey, forgetHandle, recordAudit } = require('./config.cjs');
|
|
7
7
|
const { resolveProject } = require('./project.cjs');
|
|
8
8
|
|
|
9
|
+
const PROJECT_HANDLE = '(?:proj|ccp)_[a-f0-9]+';
|
|
10
|
+
|
|
9
11
|
function normalizeContent(content) {
|
|
10
12
|
return String(content || '').replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
|
11
13
|
}
|
|
@@ -37,7 +39,7 @@ function envelope({ projectHandle, handle, kind = 'note', content, capturedAt =
|
|
|
37
39
|
return [
|
|
38
40
|
ENVELOPE_HEADER,
|
|
39
41
|
`project: ${projectHandle}`,
|
|
40
|
-
'scope:
|
|
42
|
+
'scope: vault',
|
|
41
43
|
`handle: ${handle}`,
|
|
42
44
|
`kind: ${kind}`,
|
|
43
45
|
`captured_at: ${capturedAt}`,
|
|
@@ -49,56 +51,128 @@ function envelope({ projectHandle, handle, kind = 'note', content, capturedAt =
|
|
|
49
51
|
|
|
50
52
|
function parseEnvelope(content) {
|
|
51
53
|
const text = String(content || '');
|
|
52
|
-
const headerProject = text.match(
|
|
54
|
+
const headerProject = text.match(new RegExp(`^project:\\s*(${PROJECT_HANDLE})\\s*$`, 'm'))?.[1];
|
|
53
55
|
const headerHandle = text.match(/^handle:\s*(mrh_[a-f0-9]+)\s*$/m)?.[1];
|
|
54
|
-
const stamp = text.match(
|
|
56
|
+
const stamp = text.match(new RegExp(`\\[MR_SCOPE project=(${PROJECT_HANDLE}) handle=(mrh_[a-f0-9]+)\\]`));
|
|
55
57
|
const project = headerProject || stamp?.[1];
|
|
56
58
|
const handle = headerHandle || stamp?.[2];
|
|
57
59
|
if (!project || !handle) return null;
|
|
58
60
|
const kind = text.match(/^kind:\s*([^\n]+)$/m)?.[1]?.trim();
|
|
59
61
|
const separator = text.indexOf('\n---\n');
|
|
60
62
|
const rawBody = separator >= 0 ? text.slice(separator + 5) : text;
|
|
61
|
-
const body = rawBody.replace(
|
|
63
|
+
const body = rawBody.replace(new RegExp(`^\\[MR_SCOPE project=${PROJECT_HANDLE} handle=mrh_[a-f0-9]+\\]\\n?`, 'gm'), '');
|
|
62
64
|
return { project, handle, kind, body };
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// In one shared vault a stored chunk can hold several writers' text back to
|
|
68
|
+
// back: a Claude Code "[USER] ..." turn, then a Codex envelope, then another
|
|
69
|
+
// turn. Rendering must keep ALL of it and drop only envelope metadata (header
|
|
70
|
+
// blocks, a header fragment cut at a chunk boundary, and MR_SCOPE stamps).
|
|
71
|
+
const HEADER_BLOCK = new RegExp(
|
|
72
|
+
`(?:\\[(?:USER|ASSISTANT)\\] )?\\[MemoryRouter Codex Memory v1\\]\\n(?:(?:project|scope|handle|kind|captured_at|tags):[^\\n]*\\n)*---\\n`,
|
|
73
|
+
'g',
|
|
74
|
+
);
|
|
75
|
+
const LEADING_HEADER_FRAGMENT = /^(?:(?:project|scope|handle|kind|captured_at|tags):[^\n]*\n){1,6}---\n/;
|
|
76
|
+
const STAMP = new RegExp(`\\[MR_SCOPE project=${PROJECT_HANDLE} handle=mrh_[a-f0-9]+\\]\\n?`, 'g');
|
|
77
|
+
|
|
78
|
+
function displayBody(content) {
|
|
79
|
+
return String(content || '')
|
|
80
|
+
.replace(HEADER_BLOCK, '')
|
|
81
|
+
.replace(LEADING_HEADER_FRAGMENT, '')
|
|
82
|
+
.replace(STAMP, '')
|
|
83
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// True when the row is exactly one Codex envelope (its handle can label it).
|
|
88
|
+
function isSingleEnvelope(content) {
|
|
89
|
+
const text = String(content || '');
|
|
90
|
+
return /^(?:\[(?:USER|ASSISTANT)\] )?\[MemoryRouter Codex Memory v1\]\n/.test(text)
|
|
91
|
+
&& (text.match(/\[MemoryRouter Codex Memory v1\]/g) || []).length === 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mentionsProject(content, handles) {
|
|
95
|
+
const text = String(content || '');
|
|
96
|
+
for (const handle of handles) {
|
|
97
|
+
if (text.includes(`project: ${handle}`) || text.includes(`[MR_SCOPE project=${handle} `)) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function projectHandles(project) {
|
|
103
|
+
if (!project) return new Set();
|
|
104
|
+
if (typeof project === 'string') return new Set([project]);
|
|
105
|
+
return new Set([project.handle, project.id, project.legacyHandle].filter(Boolean));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Since 1.7.0 there is one vault per user and no project filtering at all:
|
|
109
|
+
// every row the core vault returns is recalled, whatever wrote it (Codex,
|
|
110
|
+
// Claude Code typed captures "[USER] ..." / "[ASSISTANT] ...", MCP saves,
|
|
111
|
+
// ChatGPT or Claude connector saves, reflections, the pending buffer) and
|
|
112
|
+
// whichever repository it came from. The only rows dropped are ones the user
|
|
113
|
+
// suppressed locally with memory_forget.
|
|
114
|
+
function filterMemories(memories, _project, config = {}) {
|
|
67
115
|
const forgotten = new Set(config.forgottenHandles || []);
|
|
68
|
-
// Reflections (tier 2 and 3) are vault-level distilled prose without project
|
|
69
|
-
// envelopes. When the caller explicitly asked for reflection tiers, let those
|
|
70
|
-
// rows through the project filter instead of silently dropping every result.
|
|
71
|
-
const requestedReflectionTiers = Array.isArray(options.tiers) && options.tiers.some(tier => tier >= 2);
|
|
72
116
|
return (Array.isArray(memories) ? memories : []).filter(memory => {
|
|
73
|
-
const parsed = parseEnvelope(memory.content);
|
|
117
|
+
const parsed = parseEnvelope(memory && memory.content);
|
|
74
118
|
if (parsed && forgotten.has(parsed.handle)) return false;
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
return parsed?.project === projectHandle;
|
|
119
|
+
if (memory && memory.id && forgotten.has(memory.id)) return false;
|
|
120
|
+
return true;
|
|
78
121
|
});
|
|
79
122
|
}
|
|
80
123
|
|
|
81
|
-
function formatContext(memories,
|
|
124
|
+
function formatContext(memories, project, maxChars) {
|
|
125
|
+
const own = projectHandles(project);
|
|
126
|
+
const projectHandle = typeof project === 'string' ? project : project && project.handle;
|
|
82
127
|
const prefix = [
|
|
83
|
-
'MemoryRouter recalled the following
|
|
84
|
-
`
|
|
128
|
+
'MemoryRouter recalled the following context from your vault: one memory shared by every session, repository, and tool connected to it (Codex, Claude Code, the MemoryRouter MCP, ChatGPT, Claude).',
|
|
129
|
+
`Current repository handle: ${projectHandle}. Entries tagged "this repo" were saved from this repository; untagged entries may come from any session or tool. Memory handles are stable references.`,
|
|
85
130
|
'Use this as recollection; current repository state and explicit user directions take precedence.',
|
|
86
131
|
].join('\n');
|
|
87
132
|
let output = prefix;
|
|
88
133
|
for (const memory of memories) {
|
|
89
134
|
const parsed = parseEnvelope(memory.content);
|
|
90
|
-
const body =
|
|
91
|
-
|
|
135
|
+
const body = displayBody(memory.content);
|
|
136
|
+
if (!body) continue;
|
|
137
|
+
const handle = (isSingleEnvelope(memory.content) && parsed?.handle) || memory.id || parsed?.handle || (memory.source === 'buffer' ? 'recent' : 'unhandled');
|
|
92
138
|
const timestamp = memory.timestampHuman || (memory.timestamp ? new Date(memory.timestamp).toISOString() : 'unknown time');
|
|
93
139
|
const tierTag = memory.tier != null && memory.tier !== 1 ? ` | tier ${memory.tier}` : '';
|
|
140
|
+
const repoTag = mentionsProject(memory.content, own) ? ' | this repo' : '';
|
|
94
141
|
const importanceTag = memory.importance != null ? ` | importance ${memory.importance}` : '';
|
|
95
|
-
const chunk = `\n\n[${handle} | ${timestamp}${tierTag}${importanceTag}]\n${body.slice(0, 5000)}`;
|
|
96
|
-
if (output.length + chunk.length > maxChars)
|
|
142
|
+
const chunk = `\n\n[${handle} | ${timestamp}${repoTag}${tierTag}${importanceTag}]\n${body.slice(0, 5000)}`;
|
|
143
|
+
if (output.length + chunk.length > maxChars) continue;
|
|
97
144
|
output += chunk;
|
|
98
145
|
}
|
|
99
146
|
return output === prefix ? '' : output;
|
|
100
147
|
}
|
|
101
148
|
|
|
149
|
+
function searchOnce(key, query, limit, config, options) {
|
|
150
|
+
return api.search(key, query, limit, {
|
|
151
|
+
apiBase: config.apiBase,
|
|
152
|
+
timeoutMs: options.timeoutMs || 5500,
|
|
153
|
+
retries: options.retries,
|
|
154
|
+
...(options.tiers !== undefined ? { tiers: options.tiers } : {}),
|
|
155
|
+
...(options.importance !== undefined ? { importance: options.importance } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mergeMemories(lists) {
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const merged = [];
|
|
162
|
+
for (const list of lists) {
|
|
163
|
+
for (const memory of Array.isArray(list) ? list : []) {
|
|
164
|
+
const identity = memory && (memory.id || `${memory.source || ''}\0${memory.content || ''}`);
|
|
165
|
+
if (seen.has(identity)) continue;
|
|
166
|
+
seen.add(identity);
|
|
167
|
+
merged.push(memory);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return merged;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One search against the user's whole vault (no session_id). The vault is the
|
|
174
|
+
// same one the hosted MCP, the ChatGPT and Claude connectors, and Claude Code
|
|
175
|
+
// use, so whatever any of them saved is recalled here.
|
|
102
176
|
async function searchScoped(query, options = {}) {
|
|
103
177
|
const config = options.config || loadConfig(options.env);
|
|
104
178
|
const key = options.key || getApiKey(options.env, config);
|
|
@@ -106,34 +180,24 @@ async function searchScoped(query, options = {}) {
|
|
|
106
180
|
recordAudit('search', { ok: false, reason: 'no-api-key' }, options.env);
|
|
107
181
|
return { ok: false, reason: 'no-api-key', memories: [], context: '' };
|
|
108
182
|
}
|
|
109
|
-
const project = options.project || resolveProject(options.cwd);
|
|
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
|
|
112
|
-
|
|
113
|
-
`${normalizeContent(query)}\nMemoryRouter project handle ${project.handle}`,
|
|
114
|
-
limit,
|
|
115
|
-
{
|
|
116
|
-
apiBase: config.apiBase,
|
|
117
|
-
sessionId: options.sessionId,
|
|
118
|
-
timeoutMs: options.timeoutMs || 5500,
|
|
119
|
-
retries: options.retries,
|
|
120
|
-
...(options.tiers !== undefined ? { tiers: options.tiers } : {}),
|
|
121
|
-
...(options.importance !== undefined ? { importance: options.importance } : {}),
|
|
122
|
-
},
|
|
123
|
-
);
|
|
185
|
+
const text = normalizeContent(query);
|
|
186
|
+
const response = await searchOnce(key, text, limit, config, options);
|
|
124
187
|
if (!response.ok) {
|
|
125
188
|
recordAudit('search', { ok: false, status: response.status, project: project.handle }, options.env);
|
|
126
189
|
return { ok: false, status: response.status, memories: [], context: '' };
|
|
127
190
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
191
|
+
const candidates = mergeMemories([response.data?.memories]);
|
|
192
|
+
const memories = filterMemories(candidates, project, config);
|
|
193
|
+
recordAudit('search', { ok: true, project: project.handle, candidates: candidates.length, returned: memories.length }, options.env);
|
|
130
194
|
return {
|
|
131
195
|
ok: true,
|
|
132
196
|
project,
|
|
133
197
|
memories,
|
|
134
198
|
reflection: response.data?.reflection || null,
|
|
135
199
|
importState: response.data?.import || null,
|
|
136
|
-
context: formatContext(memories, project
|
|
200
|
+
context: formatContext(memories, project, Number(config.contextChars || 12000)),
|
|
137
201
|
};
|
|
138
202
|
}
|
|
139
203
|
|
|
@@ -144,7 +208,7 @@ async function storeScoped(content, options = {}) {
|
|
|
144
208
|
recordAudit('store', { ok: false, reason: 'no-api-key' }, options.env);
|
|
145
209
|
return { ok: false, reason: 'no-api-key' };
|
|
146
210
|
}
|
|
147
|
-
const project = options.project || resolveProject(options.cwd);
|
|
211
|
+
const project = options.project || resolveProject(options.cwd, { env: options.env, config });
|
|
148
212
|
const normalized = normalizeContent(content);
|
|
149
213
|
if (!normalized) return { ok: false, reason: 'empty-content' };
|
|
150
214
|
const kind = options.kind || 'note';
|
|
@@ -172,8 +236,8 @@ async function storeScoped(content, options = {}) {
|
|
|
172
236
|
content: normalized,
|
|
173
237
|
tags: options.tags || [],
|
|
174
238
|
});
|
|
175
|
-
|
|
176
|
-
const response = await api.ingest(key, [{ role: 'user', content: wrapped }],
|
|
239
|
+
// No session_id: the write lands in the user's one core vault.
|
|
240
|
+
const response = await api.ingest(key, [{ role: 'user', content: wrapped }], undefined, {
|
|
177
241
|
apiBase: config.apiBase,
|
|
178
242
|
timeoutMs: options.timeoutMs || 12000,
|
|
179
243
|
});
|
|
@@ -210,8 +274,11 @@ module.exports = {
|
|
|
210
274
|
stampBody,
|
|
211
275
|
envelope,
|
|
212
276
|
parseEnvelope,
|
|
277
|
+
displayBody,
|
|
278
|
+
projectHandles,
|
|
213
279
|
filterMemories,
|
|
214
280
|
formatContext,
|
|
281
|
+
mergeMemories,
|
|
215
282
|
searchScoped,
|
|
216
283
|
storeScoped,
|
|
217
284
|
forgetLocally,
|