memoryrouter-codex 1.1.0 → 1.2.1

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
@@ -142,6 +142,17 @@ The Codex package captures future work after installation. It does not ship a de
142
142
  - Whole-vault deletion is available only when `memory_forget` receives the exact phrase `CLEAR MY ENTIRE MEMORYROUTER VAULT`. It is irreversible and should require Codex approval.
143
143
  - Use the MemoryRouter dashboard for server-side audit/management where available.
144
144
 
145
+ ## Reflections and consolidation
146
+
147
+ MemoryRouter's Reflection Hierarchy consolidates raw memories into higher-tier reflections. This package exposes it in two lanes:
148
+
149
+ - **CLI:** `memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]`. The loop checks out a batch of unconsolidated memories under a 15 minute server lease, runs Codex itself headlessly (`codex exec`, you pay your own inference) with server-authored instructions, and commits the resulting reflection entries. It keeps no local state: if anything dies mid-loop, the lease expires server-side and the memories return to the pool, so re-running always resumes from the oldest unconsolidated memory. `--dry-run` prints the checkout and prompt and deliberately lets the lease expire (this bills one checkout).
150
+ - **Local stdio MCP:** `memory_consolidate` (checkout, returns memory texts plus the server's reflection instructions) and `memory_commit_reflections` (submit entries for the batch, idempotent on replay). The model never sees memory row IDs; it works with bare texts and one opaque `cb_` batch id.
151
+
152
+ `memory_search` accepts optional `tiers` (subset of `[1, 2, 3]`: raw, reflections, high-level reflections) and `importance` (integer 1 to 10 minimum threshold; only reflections carry ratings). `memory_status` reports the current reflection debt. When the server reports that consolidation is suggested, hook-injected context appends a one-line suggestion on every injection until the debt is consolidated.
153
+
154
+ Reflection prompts are always server-authored. This package never writes its own consolidation instructions and refuses to reflect if the server response carries none. See https://docs.memoryrouter.ai/reflections for endpoint semantics, lease rules, and billing.
155
+
145
156
  ## Context budget and failure behavior
146
157
 
147
158
  - Default injected context cap: 12,000 characters, assembled at memory boundaries.
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 mcp-server\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 mcp-server\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\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`;
27
27
  }
28
28
 
29
29
  function optionsFromArgs(args) {
@@ -66,6 +66,17 @@ async function main() {
66
66
  await require('../runtime/mcp-stdio.cjs').main();
67
67
  return;
68
68
  }
69
+ if (command === 'reflect') {
70
+ const { runReflect } = require('../runtime/reflect.cjs');
71
+ const result = await runReflect({
72
+ tier: args.tier,
73
+ batches: args.batches,
74
+ maxTokens: args.maxTokens,
75
+ dryRun: Boolean(args.dryRun),
76
+ });
77
+ process.exitCode = result.ok ? 0 : 1;
78
+ return;
79
+ }
69
80
  const options = optionsFromArgs(args);
70
81
  if (command === 'init') {
71
82
  const result = init(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoryrouter-codex",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Automatic, project-scoped MemoryRouter memory for OpenAI Codex",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/runtime/api.cjs CHANGED
@@ -47,10 +47,13 @@ async function request(method, endpoint, options = {}) {
47
47
  }
48
48
 
49
49
  function search(key, query, limit, options = {}) {
50
+ const body = { query, limit };
51
+ if (options.tiers !== undefined) body.tiers = options.tiers;
52
+ if (options.importance !== undefined) body.importance = options.importance;
50
53
  return request('POST', '/v1/memory/search', {
51
54
  ...options,
52
55
  key,
53
- body: { query, limit },
56
+ body,
54
57
  headers: options.sessionId ? { 'X-Session-ID': options.sessionId } : {},
55
58
  retries: options.retries ?? 2,
56
59
  });
@@ -77,4 +80,84 @@ function clearVault(key, options = {}) {
77
80
  return request('DELETE', '/v1/memory', { ...options, key, retries: 0 });
78
81
  }
79
82
 
80
- module.exports = { sleep, request, search, ingest, stats, warmup, clearVault };
83
+ // Reflection Hierarchy wrappers. Checkout and commit run with retries: 0 because
84
+ // both create or consume leases; blind transport retries are handled by the
85
+ // reflect loop, not this layer.
86
+ function reflectCheckout(key, tier = 1, maxTokens, options = {}) {
87
+ const body = { tier };
88
+ // Default budget is 50000 tokens. No connector-side cap: the server's own
89
+ // checkout limits are the only bound.
90
+ body.max_tokens = maxTokens === undefined ? 50000 : maxTokens;
91
+ return request('POST', '/v1/memory/consolidate/checkout', {
92
+ ...options,
93
+ key,
94
+ body,
95
+ timeoutMs: options.timeoutMs || 20000,
96
+ retries: 0,
97
+ });
98
+ }
99
+
100
+ function reflectCommit(key, batchId, entries, options = {}) {
101
+ return request('POST', '/v1/memory/consolidate/commit', {
102
+ ...options,
103
+ key,
104
+ body: { batch_id: batchId, entries },
105
+ timeoutMs: options.timeoutMs || 30000,
106
+ retries: 0,
107
+ });
108
+ }
109
+
110
+ function inspect(key, memoryId, options = {}) {
111
+ return request('POST', '/v1/memory/inspect', {
112
+ ...options,
113
+ key,
114
+ body: { memory_id: memoryId },
115
+ timeoutMs: options.timeoutMs || 15000,
116
+ retries: options.retries ?? 1,
117
+ });
118
+ }
119
+
120
+ function deleteIds(key, ids, options = {}) {
121
+ return request('POST', '/v1/memory/delete-ids', {
122
+ ...options,
123
+ key,
124
+ body: { ids },
125
+ timeoutMs: options.timeoutMs || 20000,
126
+ retries: 0,
127
+ });
128
+ }
129
+
130
+ function dateSearch(key, params = {}, options = {}) {
131
+ return request('POST', '/v1/memory/date-search', {
132
+ ...options,
133
+ key,
134
+ body: params,
135
+ timeoutMs: options.timeoutMs || 15000,
136
+ retries: options.retries ?? 1,
137
+ });
138
+ }
139
+
140
+ // Sanctioned debt read: an empty-window date-search returns the reflection
141
+ // debt block with zero results and bills zero retrieval tokens.
142
+ function debtProbe(key, options = {}) {
143
+ return dateSearch(key, {
144
+ from: new Date(Date.now() - 1000).toISOString(),
145
+ max_tokens: 1000,
146
+ }, options);
147
+ }
148
+
149
+ module.exports = {
150
+ sleep,
151
+ request,
152
+ search,
153
+ ingest,
154
+ stats,
155
+ warmup,
156
+ clearVault,
157
+ reflectCheckout,
158
+ reflectCommit,
159
+ inspect,
160
+ deleteIds,
161
+ dateSearch,
162
+ debtProbe,
163
+ };
package/runtime/hook.cjs CHANGED
@@ -36,6 +36,25 @@ function emitContext(eventName, context) {
36
36
  return true;
37
37
  }
38
38
 
39
+ function estimateDebtTokens(reflection) {
40
+ const raw = reflection?.unconsolidated?.raw?.est_tokens || 0;
41
+ const tier2 = reflection?.unconsolidated?.tier2?.est_tokens || 0;
42
+ return raw + tier2;
43
+ }
44
+
45
+ // Debt nudge rules (reflection-connectors-spec.md, section d):
46
+ // only when a context is being emitted anyway, and counted inside the
47
+ // existing context char cap so it can never push memory content out.
48
+ // Fires on every run with a non-null suggestion.
49
+ function maybeAppendNudge(context, reflection, sessionId, hookContext) {
50
+ if (!context || !reflection || !reflection.suggestion || !sessionId) return context;
51
+ const tokens = estimateDebtTokens(reflection);
52
+ const nudge = `\n\n[MemoryRouter] Consolidation suggested: ${Math.round(tokens / 1000)}k unconsolidated tokens. Run: memoryrouter-codex reflect`;
53
+ const maxChars = Number(hookContext.config?.contextChars || 12000);
54
+ if (context.length + nudge.length > maxChars) return context;
55
+ return context + nudge;
56
+ }
57
+
39
58
  function turnContent(input, state) {
40
59
  const prompt = state.prompts?.[input.turn_id] || state.lastPrompt || '';
41
60
  return [
@@ -68,7 +87,7 @@ async function handleInject(input, context) {
68
87
  memories: result.memories?.length || 0,
69
88
  status: result.status,
70
89
  }, context.env);
71
- emitContext('UserPromptSubmit', result.context);
90
+ emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context));
72
91
  }
73
92
 
74
93
  async function handleSessionStart(input, context) {
@@ -83,7 +102,7 @@ async function handleSessionStart(input, context) {
83
102
  project: context.project.handle,
84
103
  memories: result.memories?.length || 0,
85
104
  }, context.env);
86
- emitContext('SessionStart', result.context);
105
+ emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context));
87
106
  }
88
107
 
89
108
  async function captureTurn(input, context, partial = false) {
@@ -200,6 +219,8 @@ module.exports = {
200
219
  readStdin,
201
220
  truncate,
202
221
  emitContext,
222
+ maybeAppendNudge,
223
+ estimateDebtTokens,
203
224
  turnContent,
204
225
  handleInject,
205
226
  handleSessionStart,
@@ -7,6 +7,7 @@ const { loadConfig, getApiKey, configPath, auditPath, recordAudit, healthPath }
7
7
  const { resolveProject } = require('./project.cjs');
8
8
  const { searchScoped, storeScoped, forgetLocally } = require('./memory.cjs');
9
9
  const api = require('./api.cjs');
10
+ const { contractFrom, validateEntries } = require('./reflect.cjs');
10
11
  const { INTEGRATION_VERSION } = require('./constants.cjs');
11
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.';
@@ -20,6 +21,8 @@ const TOOLS = [
20
21
  properties: {
21
22
  query: { type: 'string', description: 'Semantic search query.' },
22
23
  limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Candidate results before project filtering.' },
24
+ tiers: { type: 'array', items: { type: 'integer', enum: [1, 2, 3] }, minItems: 1, uniqueItems: true, description: 'Restrict results to these memory tiers. 1 raw memories, 2 reflections, 3 high-level reflections. Omit to search all tiers blended.' },
25
+ importance: { type: 'integer', minimum: 1, maximum: 10, description: 'Only return memories rated at least this important (1 to 10). Only reflections carry ratings, so this filters to tier 2 and 3 content.' },
23
26
  },
24
27
  required: ['query'],
25
28
  },
@@ -57,6 +60,46 @@ const TOOLS = [
57
60
  },
58
61
  annotations: { title: 'Forget MemoryRouter memory', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
59
62
  },
63
+ {
64
+ name: 'memory_consolidate',
65
+ description: 'Use this when the user asks to consolidate, reflect on, or organize their memories, or when memory_status or a search result reports that consolidation is available. Checks out a batch of unconsolidated memories from the connected vault under a 15 minute lease and returns their texts plus server instructions for writing reflection entries. Follow the returned instructions to write the entries, then call memory_commit_reflections with the batch_id. Nothing is modified until commit.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ tier: { type: 'integer', enum: [1, 2], default: 1, description: '1 consolidates raw memories into reflections; 2 consolidates reflections into high-level reflections.' },
70
+ max_tokens: { type: 'integer', minimum: 1000, default: 50000, description: 'Token budget for the checked-out batch. Defaults to 50000. No connector-side cap.' },
71
+ },
72
+ additionalProperties: false,
73
+ },
74
+ annotations: { title: 'Consolidate memories (checkout)', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
75
+ },
76
+ {
77
+ name: 'memory_commit_reflections',
78
+ description: 'Use this to complete a consolidation started with memory_consolidate. Submits the reflection entries you wrote for the checked-out batch. Each entry is standalone prose with an importance rating from 1 to 10. The server stores the reflections, links them to their source memories, and marks the sources consolidated. Safe to retry: committing the same batch twice returns the original result.',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ batch_id: { type: 'string', minLength: 1 },
83
+ entries: {
84
+ type: 'array',
85
+ minItems: 1,
86
+ maxItems: 100,
87
+ items: {
88
+ type: 'object',
89
+ properties: {
90
+ text: { type: 'string', minLength: 1, maxLength: 8000 },
91
+ importance: { type: 'integer', minimum: 1, maximum: 10 },
92
+ },
93
+ required: ['text', 'importance'],
94
+ additionalProperties: false,
95
+ },
96
+ },
97
+ },
98
+ required: ['batch_id', 'entries'],
99
+ additionalProperties: false,
100
+ },
101
+ annotations: { title: 'Commit reflections', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
102
+ },
60
103
  ];
61
104
 
62
105
  function textResult(text, isError = false) {
@@ -73,10 +116,28 @@ async function callTool(name, args = {}, options = {}) {
73
116
 
74
117
  if (name === 'memory_search') {
75
118
  if (!args.query) return textResult('query is required', true);
76
- const result = await searchScoped(args.query, { env, config, key, cwd, project, limit: args.limit });
119
+ if (args.tiers !== undefined) {
120
+ if (!Array.isArray(args.tiers) || args.tiers.length < 1 || args.tiers.some(tier => ![1, 2, 3].includes(tier))) {
121
+ return textResult('tiers must be a non-empty array drawn from [1, 2, 3]', true);
122
+ }
123
+ }
124
+ if (args.importance !== undefined && (!Number.isInteger(args.importance) || args.importance < 1 || args.importance > 10)) {
125
+ return textResult('importance must be an integer 1 to 10', true);
126
+ }
127
+ const result = await searchScoped(args.query, {
128
+ env, config, key, cwd, project, limit: args.limit,
129
+ ...(args.tiers !== undefined ? { tiers: args.tiers } : {}),
130
+ ...(args.importance !== undefined ? { importance: args.importance } : {}),
131
+ });
77
132
  if (!result.ok) return textResult(`Memory search failed${result.status ? ` (HTTP ${result.status})` : ''}.`, true);
78
133
  if (!result.context) return textResult(`No memories matched current project ${project.handle}.`);
79
- return textResult(result.context);
134
+ let text = result.context;
135
+ const debt = result.reflection;
136
+ if (debt && debt.suggestion) {
137
+ const tokens = (debt.unconsolidated?.raw?.est_tokens || 0) + (debt.unconsolidated?.tier2?.est_tokens || 0);
138
+ text += `\n\nNote: ${Math.round(tokens / 1000)}k unconsolidated memory tokens are ready for consolidation. Suggest consolidating when convenient.`;
139
+ }
140
+ return textResult(text);
80
141
  }
81
142
  if (name === 'memory_store') {
82
143
  if (!args.content) return textResult('content is required', true);
@@ -89,10 +150,14 @@ async function callTool(name, args = {}, options = {}) {
89
150
  : `Stored for project ${project.handle}. Memory handle: ${result.handle}`);
90
151
  }
91
152
  if (name === 'memory_status') {
92
- const response = await api.stats(key, { apiBase: config.apiBase, timeoutMs: 8000, retries: 1 });
153
+ const [response, probe] = await Promise.all([
154
+ api.stats(key, { apiBase: config.apiBase, timeoutMs: 8000, retries: 1 }),
155
+ api.debtProbe(key, { apiBase: config.apiBase, timeoutMs: 8000 }).catch(() => null),
156
+ ]);
93
157
  let health = null;
94
158
  try { health = JSON.parse(fs.readFileSync(healthPath(env), 'utf8')); } catch { /* optional */ }
95
- return textResult(JSON.stringify({
159
+ const debt = probe && probe.ok ? probe.data?.reflection || null : null;
160
+ const status = {
96
161
  connected: response.ok,
97
162
  status: response.status,
98
163
  project: { handle: project.handle, identitySource: project.source, crossMachineStable: project.portable },
@@ -101,8 +166,65 @@ async function callTool(name, args = {}, options = {}) {
101
166
  localAuditFile: auditPath(env),
102
167
  retrievalScope: config.retrievalScope,
103
168
  vault: response.ok ? response.data : null,
169
+ reflection: debt,
104
170
  recentHooks: health,
105
- }, null, 2));
171
+ };
172
+ let text = JSON.stringify(status, null, 2);
173
+ if (debt && debt.suggestion) {
174
+ const tokens = (debt.unconsolidated?.raw?.est_tokens || 0) + (debt.unconsolidated?.tier2?.est_tokens || 0);
175
+ text += `\n\nConsolidation is available: ${Math.round(tokens / 1000)}k unconsolidated memory tokens. The user can ask to consolidate memories.`;
176
+ }
177
+ return textResult(text);
178
+ }
179
+ if (name === 'memory_consolidate') {
180
+ const tier = args.tier === undefined ? 1 : args.tier;
181
+ if (tier !== 1 && tier !== 2) return textResult('tier must be 1 or 2', true);
182
+ if (args.max_tokens !== undefined && (!Number.isInteger(args.max_tokens) || args.max_tokens < 1000)) {
183
+ return textResult('max_tokens must be an integer of at least 1000', true);
184
+ }
185
+ const response = await api.reflectCheckout(key, tier, args.max_tokens, { apiBase: config.apiBase });
186
+ if (response.status === 403) return textResult('This memory key is read-only; consolidation requires a writable key.', true);
187
+ if (!response.ok) return textResult(`Consolidation checkout failed (HTTP ${response.status}).`, true);
188
+ const data = response.data || {};
189
+ if (!data.batch_id || !data.count) return textResult('Nothing to consolidate. The unconsolidated pool is empty.');
190
+ const contract = contractFrom(data);
191
+ if (!contract) return textResult('Checkout response carried no reflection contract. Refusing to consolidate: this connector never authors reflection prompts.', true);
192
+ // Invariant: the model never sees memory row IDs. source_ids is dropped
193
+ // here and never rendered; commit needs only the opaque batch_id.
194
+ const dated = (Array.isArray(data.memories) ? data.memories : [])
195
+ .map(memory => `[${memory.date || 'unknown date'}]\n${memory.text}`)
196
+ .join('\n\n');
197
+ recordAudit('consolidate-checkout', { ok: true, tier, count: data.count, project: project.handle }, env);
198
+ return textResult([
199
+ contract.instructions,
200
+ '',
201
+ `Batch of ${data.count} memories (${data.est_tokens} estimated tokens), covering ${data.covers?.from || 'unknown'} to ${data.covers?.to || 'unknown'}.`,
202
+ '',
203
+ dated,
204
+ '',
205
+ `When your entries are ready, call memory_commit_reflections with batch_id ${data.batch_id} before ${data.lease_expires_at}.`,
206
+ ].join('\n'));
207
+ }
208
+ if (name === 'memory_commit_reflections') {
209
+ if (!args.batch_id || typeof args.batch_id !== 'string') return textResult('batch_id is required', true);
210
+ const validated = validateEntries({ entries: args.entries });
211
+ if (!validated.ok) return textResult(`Invalid entries: ${validated.reason}`, true);
212
+ const response = await api.reflectCommit(key, args.batch_id, validated.entries, { apiBase: config.apiBase });
213
+ if (response.status === 409) {
214
+ return textResult('The consolidation lease expired before commit. Ask the user whether to start over with memory_consolidate; the memories were returned to the pool and nothing was lost.', true);
215
+ }
216
+ if (response.status === 403) return textResult('This memory key is read-only; commit requires a writable key.', true);
217
+ if (!response.ok) return textResult(`Commit failed (HTTP ${response.status}).`, true);
218
+ const data = response.data || {};
219
+ // Invariant: created reflection UUIDs are dropped; the model never sees row IDs.
220
+ const created = Array.isArray(data.created) ? data.created.length : 0;
221
+ recordAudit('consolidate-commit', { ok: true, alreadyCommitted: Boolean(data.already_committed), created, sourcesConsolidated: data.sources_consolidated || 0, project: project.handle }, env);
222
+ const summary = data.already_committed
223
+ ? 'This batch was already committed; the original result stands.'
224
+ : `${created} reflections written; ${data.sources_consolidated || 0} memories consolidated.`;
225
+ const debt = data.reflection;
226
+ const remaining = debt && debt.suggestion ? ` Remaining: ${debt.suggestion}.` : '';
227
+ return textResult(`${summary}${remaining}`);
106
228
  }
107
229
  if (name === 'memory_forget') {
108
230
  if (args.clear_vault_confirmation === 'CLEAR MY ENTIRE MEMORYROUTER VAULT') {
@@ -62,13 +62,18 @@ function parseEnvelope(content) {
62
62
  return { project, handle, kind, body };
63
63
  }
64
64
 
65
- function filterMemories(memories, projectHandle, config) {
65
+ function filterMemories(memories, projectHandle, config, options = {}) {
66
66
  const scope = config.retrievalScope || 'project';
67
67
  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);
68
72
  return (Array.isArray(memories) ? memories : []).filter(memory => {
69
73
  const parsed = parseEnvelope(memory.content);
70
74
  if (parsed && forgotten.has(parsed.handle)) return false;
71
75
  if (scope === 'vault') return true;
76
+ if (requestedReflectionTiers && memory.tier != null && memory.tier >= 2) return true;
72
77
  return parsed?.project === projectHandle;
73
78
  });
74
79
  }
@@ -85,7 +90,9 @@ function formatContext(memories, projectHandle, maxChars) {
85
90
  const body = parsed ? parsed.body : String(memory.content || '');
86
91
  const handle = parsed?.handle || memory.id || 'unhandled';
87
92
  const timestamp = memory.timestampHuman || (memory.timestamp ? new Date(memory.timestamp).toISOString() : 'unknown time');
88
- const chunk = `\n\n[${handle} | ${timestamp}]\n${body.slice(0, 5000)}`;
93
+ const tierTag = memory.tier != null && memory.tier !== 1 ? ` | tier ${memory.tier}` : '';
94
+ const importanceTag = memory.importance != null ? ` | importance ${memory.importance}` : '';
95
+ const chunk = `\n\n[${handle} | ${timestamp}${tierTag}${importanceTag}]\n${body.slice(0, 5000)}`;
89
96
  if (output.length + chunk.length > maxChars) break;
90
97
  output += chunk;
91
98
  }
@@ -110,18 +117,21 @@ async function searchScoped(query, options = {}) {
110
117
  sessionId: options.sessionId,
111
118
  timeoutMs: options.timeoutMs || 5500,
112
119
  retries: options.retries,
120
+ ...(options.tiers !== undefined ? { tiers: options.tiers } : {}),
121
+ ...(options.importance !== undefined ? { importance: options.importance } : {}),
113
122
  },
114
123
  );
115
124
  if (!response.ok) {
116
125
  recordAudit('search', { ok: false, status: response.status, project: project.handle }, options.env);
117
126
  return { ok: false, status: response.status, memories: [], context: '' };
118
127
  }
119
- const memories = filterMemories(response.data?.memories, project.handle, config);
128
+ const memories = filterMemories(response.data?.memories, project.handle, config, { tiers: options.tiers });
120
129
  recordAudit('search', { ok: true, project: project.handle, candidates: response.data?.memories?.length || 0, returned: memories.length }, options.env);
121
130
  return {
122
131
  ok: true,
123
132
  project,
124
133
  memories,
134
+ reflection: response.data?.reflection || null,
125
135
  context: formatContext(memories, project.handle, Number(config.contextChars || 12000)),
126
136
  };
127
137
  }
@@ -188,7 +198,7 @@ function forgetLocally(handle, options = {}) {
188
198
  ok: true,
189
199
  handle,
190
200
  deleted: false,
191
- message: 'Suppressed from MemoryRouter Codex retrieval on this machine. The server record was not deleted because the current MemoryRouter API has no per-memory delete endpoint.',
201
+ message: 'Suppressed from MemoryRouter Codex retrieval on this machine. The server record was not deleted; this Codex surface performs local suppression only. Per-memory server deletion is available from other MemoryRouter surfaces.',
192
202
  };
193
203
  }
194
204
 
@@ -0,0 +1,250 @@
1
+ 'use strict';
2
+
3
+ // Reflection Hierarchy consolidation loop for the codex CLI lane.
4
+ //
5
+ // Locked invariants (reflection-connectors-spec.md, section d):
6
+ // 1. The server is the single source of reflection prompts. This module never
7
+ // authors reflection guidance; the model prompt is built exclusively from
8
+ // reflection_contract.instructions plus the dated memory texts. The strict
9
+ // JSON envelope instruction is connector mechanics, which is permitted.
10
+ // 2. No memory IDs ever reach the model. source_ids from checkout are never
11
+ // included in the prompt; batch_id stays with the connector.
12
+ // 3. No local state. Lease expiry on the server is the recovery mechanism;
13
+ // re-running continues from the oldest unconsolidated memory.
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const { spawnSync } = require('child_process');
20
+ const api = require('./api.cjs');
21
+ const { loadConfig, getApiKey, recordAudit } = require('./config.cjs');
22
+
23
+ const MAX_ENTRY_CHARS = 8000;
24
+ const MAX_ENTRIES = 100;
25
+ const JSON_ENVELOPE_INSTRUCTION = [
26
+ 'Output STRICT JSON only, no prose before or after, exactly this shape:',
27
+ '{"entries": [{"text": "...", "importance": N}]}',
28
+ 'Each entry object has exactly two fields: text (string) and importance (integer 1 to 10).',
29
+ ].join('\n');
30
+ const RETRY_SUFFIX = '\n\nYour previous output was not valid JSON in the required shape. Respond again with ONLY the JSON object, nothing else.';
31
+
32
+ function contractFrom(data) {
33
+ if (data && data.reflection_contract && typeof data.reflection_contract.instructions === 'string') {
34
+ return { instructions: data.reflection_contract.instructions, version: data.reflection_contract.version ?? null };
35
+ }
36
+ if (data && data.tier_definitions && typeof data.tier_definitions.instruction === 'string') {
37
+ return { instructions: data.tier_definitions.instruction, version: null };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function buildPrompt(instructions, memories) {
43
+ const texts = (Array.isArray(memories) ? memories : [])
44
+ .map(memory => `[${memory.date || 'unknown date'}]\n${memory.text}`)
45
+ .join('\n\n');
46
+ return `${instructions}\n\nMemories:\n\n${texts}\n\n${JSON_ENVELOPE_INSTRUCTION}`;
47
+ }
48
+
49
+ function extractJson(text) {
50
+ const raw = String(text || '');
51
+ try { return JSON.parse(raw); } catch { /* try fenced or embedded */ }
52
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
53
+ if (fenced) {
54
+ try { return JSON.parse(fenced[1]); } catch { /* fall through */ }
55
+ }
56
+ const start = raw.indexOf('{');
57
+ const end = raw.lastIndexOf('}');
58
+ if (start >= 0 && end > start) {
59
+ try { return JSON.parse(raw.slice(start, end + 1)); } catch { /* invalid */ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function validateEntries(parsed) {
65
+ if (!parsed || !Array.isArray(parsed.entries)) return { ok: false, reason: 'entries array missing' };
66
+ if (parsed.entries.length < 1) return { ok: false, reason: 'entries array is empty' };
67
+ if (parsed.entries.length > MAX_ENTRIES) return { ok: false, reason: `more than ${MAX_ENTRIES} entries` };
68
+ const entries = [];
69
+ for (const entry of parsed.entries) {
70
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { ok: false, reason: 'entry is not an object' };
71
+ const keys = Object.keys(entry);
72
+ const extras = keys.filter(k => k !== 'text' && k !== 'importance');
73
+ if (extras.length) return { ok: false, reason: `entry has forbidden fields: ${extras.join(', ')}` };
74
+ if (typeof entry.text !== 'string' || entry.text.length < 1 || entry.text.length > MAX_ENTRY_CHARS) {
75
+ return { ok: false, reason: `entry text must be a string of 1 to ${MAX_ENTRY_CHARS} chars` };
76
+ }
77
+ if (!Number.isInteger(entry.importance) || entry.importance < 1 || entry.importance > 10) {
78
+ return { ok: false, reason: 'entry importance must be an integer 1 to 10' };
79
+ }
80
+ entries.push({ text: entry.text, importance: entry.importance });
81
+ }
82
+ return { ok: true, entries };
83
+ }
84
+
85
+ function invokeCodex(prompt, options = {}) {
86
+ const outFile = path.join(os.tmpdir(), `mrcodex-reflect-${process.pid}-${crypto.randomBytes(4).toString('hex')}.txt`);
87
+ try {
88
+ const result = spawnSync('codex', ['exec', '--skip-git-repo-check', '--output-last-message', outFile, prompt], {
89
+ encoding: 'utf8',
90
+ timeout: options.timeoutMs || 10 * 60 * 1000,
91
+ maxBuffer: 64 * 1024 * 1024,
92
+ });
93
+ if (result.error) return { ok: false, reason: result.error.message };
94
+ let output = '';
95
+ try { output = fs.readFileSync(outFile, 'utf8'); } catch { output = String(result.stdout || ''); }
96
+ if (!output.trim()) return { ok: false, reason: `codex exec produced no output (exit ${result.status})` };
97
+ return { ok: true, output };
98
+ } finally {
99
+ try { fs.rmSync(outFile, { force: true }); } catch { /* best effort */ }
100
+ }
101
+ }
102
+
103
+ async function runReflect(options = {}) {
104
+ const env = options.env || process.env;
105
+ const config = options.config || loadConfig(env);
106
+ const key = options.key || getApiKey(env, config);
107
+ const log = options.log || (line => process.stdout.write(`${line}\n`));
108
+ const invokeModel = options.invokeModel || invokeCodex;
109
+ if (!key) {
110
+ log('MemoryRouter API key is not configured. Run `memoryrouter-codex init` or set MEMORYROUTER_API_KEY.');
111
+ return { ok: false, reason: 'no-api-key' };
112
+ }
113
+ const tier = Number(options.tier || 1);
114
+ if (tier !== 1 && tier !== 2) {
115
+ log('tier must be 1 or 2');
116
+ return { ok: false, reason: 'bad-tier' };
117
+ }
118
+ const batchesAll = options.batches === 'all';
119
+ const maxBatches = batchesAll ? Infinity : Math.max(1, Number(options.batches || 1));
120
+ const maxTokens = options.maxTokens === undefined ? undefined : Number(options.maxTokens);
121
+ const apiOptions = { apiBase: config.apiBase };
122
+
123
+ let completed = 0;
124
+ let totalCreated = 0;
125
+ let totalConsolidated = 0;
126
+ let leaseWarned = false;
127
+ let leaseRetries = 0;
128
+ const maxLeaseRetries = 3;
129
+
130
+ while (completed < maxBatches) {
131
+ const checkout = await api.reflectCheckout(key, tier, maxTokens, apiOptions);
132
+ if (!checkout.ok) {
133
+ log(`Checkout failed (HTTP ${checkout.status}).`);
134
+ recordAudit('reflect-checkout', { ok: false, status: checkout.status, tier }, env);
135
+ return { ok: false, reason: 'checkout-failed', status: checkout.status, completed };
136
+ }
137
+ const data = checkout.data || {};
138
+ if (!data.batch_id || !data.count) {
139
+ log(completed ? 'Pool is empty. Nothing more to consolidate.' : 'Nothing to consolidate.');
140
+ return { ok: true, completed, created: totalCreated, consolidated: totalConsolidated, empty: true };
141
+ }
142
+ recordAudit('reflect-checkout', { ok: true, tier, count: data.count, estTokens: data.est_tokens }, env);
143
+
144
+ const contract = contractFrom(data);
145
+ if (!contract) {
146
+ log('Checkout response carried no reflection contract. Refusing to reflect: this connector never authors its own reflection prompt. Update memoryrouter-codex or contact support.');
147
+ return { ok: false, reason: 'no-contract', completed };
148
+ }
149
+
150
+ const prompt = buildPrompt(contract.instructions, data.memories);
151
+
152
+ if (options.dryRun) {
153
+ log(`Dry run. Batch of ${data.count} memories (${data.est_tokens} est tokens), lease expires ${data.lease_expires_at}.`);
154
+ log('The lease will be allowed to expire; nothing will be committed.');
155
+ log('\n----- reflection contract instructions -----\n');
156
+ log(contract.instructions);
157
+ log('\n----- full model prompt -----\n');
158
+ log(prompt);
159
+ return { ok: true, dryRun: true, completed };
160
+ }
161
+
162
+ log(`Batch ${completed + 1}: ${data.count} memories, ${data.est_tokens} est tokens, lease expires ${data.lease_expires_at}.`);
163
+
164
+ let entries = null;
165
+ let attemptPrompt = prompt;
166
+ for (let attempt = 0; attempt < 2; attempt += 1) {
167
+ const invocation = invokeModel(attemptPrompt, options);
168
+ if (!invocation.ok) {
169
+ log(`Model invocation failed: ${invocation.reason}`);
170
+ break;
171
+ }
172
+ const validated = validateEntries(extractJson(invocation.output));
173
+ if (validated.ok) { entries = validated.entries; break; }
174
+ log(`Model output invalid (${validated.reason}).${attempt === 0 ? ' Retrying once.' : ''}`);
175
+ attemptPrompt = prompt + RETRY_SUFFIX;
176
+ }
177
+ if (!entries) {
178
+ log('Giving up on this batch. The lease will expire on its own and the memories return to the pool. No entries were invented.');
179
+ recordAudit('reflect-abandon', { tier, batchAbandoned: true }, env);
180
+ return { ok: false, reason: 'model-output-invalid', completed };
181
+ }
182
+
183
+ if (data.lease_expires_at && Date.now() >= Date.parse(data.lease_expires_at)) {
184
+ log('The model step outlasted the lease; skipping commit and re-checking out a fresh batch.');
185
+ if (!leaseWarned) {
186
+ log('Hint: pass a smaller --max-tokens so each batch fits inside the 15 minute lease.');
187
+ leaseWarned = true;
188
+ }
189
+ leaseRetries += 1;
190
+ if (leaseRetries >= maxLeaseRetries) {
191
+ log('Repeated lease expiries; stopping. Re-run with a smaller --max-tokens.');
192
+ return { ok: false, reason: 'lease-expired-repeatedly', completed };
193
+ }
194
+ continue;
195
+ }
196
+
197
+ const commit = await api.reflectCommit(key, data.batch_id, entries, apiOptions);
198
+ if (commit.status === 409) {
199
+ log('Lease expired before commit. The memories returned to the pool; re-checking out.');
200
+ if (!leaseWarned) {
201
+ log('Hint: pass a smaller --max-tokens so each batch fits inside the 15 minute lease.');
202
+ leaseWarned = true;
203
+ }
204
+ leaseRetries += 1;
205
+ if (leaseRetries >= maxLeaseRetries) {
206
+ log('Repeated lease expiries; stopping. Re-run with a smaller --max-tokens.');
207
+ return { ok: false, reason: 'lease-expired-repeatedly', completed };
208
+ }
209
+ continue;
210
+ }
211
+ leaseRetries = 0;
212
+ if (!commit.ok) {
213
+ log(`Commit failed (HTTP ${commit.status}).`);
214
+ recordAudit('reflect-commit', { ok: false, status: commit.status, tier }, env);
215
+ return { ok: false, reason: 'commit-failed', status: commit.status, completed };
216
+ }
217
+ const committed = commit.data || {};
218
+ const createdCount = Array.isArray(committed.created) ? committed.created.length : 0;
219
+ totalCreated += createdCount;
220
+ totalConsolidated += committed.sources_consolidated || 0;
221
+ completed += 1;
222
+ recordAudit('reflect-commit', {
223
+ ok: true, tier, alreadyCommitted: Boolean(committed.already_committed), created: createdCount, sourcesConsolidated: committed.sources_consolidated || 0,
224
+ }, env);
225
+ if (committed.already_committed) {
226
+ log('Batch was already committed; treated as success.');
227
+ } else {
228
+ log(`Committed ${createdCount} reflections covering ${committed.sources_consolidated || 0} memories.`);
229
+ }
230
+ const debt = committed.reflection;
231
+ if (debt && debt.suggestion) log(`Remaining: ${debt.suggestion}.`);
232
+ else if (debt && !debt.suggestion && batchesAll) {
233
+ log('Debt is below threshold; stopping.');
234
+ break;
235
+ }
236
+ }
237
+
238
+ log(`Done. ${completed} batch${completed === 1 ? '' : 'es'} committed, ${totalCreated} reflections created, ${totalConsolidated} memories consolidated.`);
239
+ return { ok: true, completed, created: totalCreated, consolidated: totalConsolidated };
240
+ }
241
+
242
+ module.exports = {
243
+ JSON_ENVELOPE_INSTRUCTION,
244
+ contractFrom,
245
+ buildPrompt,
246
+ extractJson,
247
+ validateEntries,
248
+ invokeCodex,
249
+ runReflect,
250
+ };