memoryrouter-codex 1.3.0 → 1.4.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 +2 -0
- package/bin/cli.cjs +10 -1
- package/package.json +1 -1
- package/runtime/auto.cjs +235 -0
- package/runtime/constants.cjs +1 -1
- package/runtime/hook.cjs +6 -0
package/README.md
CHANGED
|
@@ -165,6 +165,8 @@ MemoryRouter's Reflection Hierarchy consolidates raw memories into higher-tier r
|
|
|
165
165
|
|
|
166
166
|
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.
|
|
167
167
|
|
|
168
|
+
**Automatic consolidation (default on):** when a hook's retrieval response reports debt above the server's threshold, the hooks spawn the same reflect loop as a detached background process (`codex exec` headless, your own inference). Rails: max 3 batches per trigger with a debt re-check between batches, a machine-wide lockfile at `~/.memoryrouter/consolidate.lock` (stale after 20 minutes), a 30 minute cooldown after each completed run, and silent failure with logs at `~/.memoryrouter/consolidate.log`. The threshold always comes from the server response. Opt out with `"autoConsolidate": false` in `~/.memoryrouter/codex/config.json`; the suggestion line, stdio tools, and manual `reflect` keep working.
|
|
169
|
+
|
|
168
170
|
## Context budget and failure behavior
|
|
169
171
|
|
|
170
172
|
- 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 reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex import [--instructions [--run]] [--file PATH] [--dismiss] [--yes]\n memoryrouter-codex mcp-server\n\nImport:\n Import historical Codex conversations into your vault. --instructions\n fetches the server's extraction prompt for the host agent to execute\n (--run spawns it via codex exec); --file uploads a JSONL extract with a\n preview, price quote, explicit approval (--yes for non-interactive),\n idempotent batches, and resume; --dismiss silences the import nudge\n permanently. Re-running --file is always safe: committed batches replay\n for free and duplicates are never re-billed.\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY or a key saved with --api-key-stdin.\n api-key Local stdio MCP fallback plus automatic hooks. The key is read only\n from the environment/private config, never from project files.\n\nSecure key setup:\n printf '%s' "$MEMORYROUTER_API_KEY" | memoryrouter-codex init --api-key-stdin\n`;
|
|
26
|
+
return `memoryrouter-codex ${INTEGRATION_VERSION}\n\nUsage:\n memoryrouter-codex init [--scope user|project] [--auth oauth|api-key]\n [--project-dir PATH] [--api-key-stdin]\n [--no-hooks] [--no-agents]\n memoryrouter-codex doctor [--scope user|project] [--project-dir PATH] [--offline]\n memoryrouter-codex uninstall [--scope user|project] [--project-dir PATH]\n memoryrouter-codex reflect [--tier 1|2] [--batches N|all] [--max-tokens N] [--dry-run]\n memoryrouter-codex import [--instructions [--run]] [--file PATH] [--dismiss] [--yes]\n memoryrouter-codex mcp-server\n\nImport:\n Import historical Codex conversations into your vault. --instructions\n fetches the server's extraction prompt for the host agent to execute\n (--run spawns it via codex exec); --file uploads a JSONL extract with a\n preview, price quote, explicit approval (--yes for non-interactive),\n idempotent batches, and resume; --dismiss silences the import nudge\n permanently. Re-running --file is always safe: committed batches replay\n for free and duplicates are never re-billed.\n\nReflect:\n Consolidates unconsolidated memories into reflections using Codex itself as\n the model (codex exec, you pay your own inference). Server-authored\n instructions; resumable; no local state. --dry-run prints the checkout and\n lets the lease expire without committing.\n Automatic mode: when the server reports consolidation debt above its\n threshold, the hooks spawn this same loop in the background (max 3 batches\n per trigger, 30 minute cooldown, machine-wide lockfile at\n ~/.memoryrouter/consolidate.lock). Opt out with "autoConsolidate": false in\n ~/.memoryrouter/codex/config.json. Logs: ~/.memoryrouter/consolidate.log\n\nAuth modes:\n oauth Current remote MCP at ${'https://mcp.memoryrouter.ai/mcp'} (default).\n Run \`codex mcp login memoryrouter\` after init. Hooks additionally\n require MEMORYROUTER_API_KEY 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) {
|
|
@@ -77,6 +77,15 @@ async function main() {
|
|
|
77
77
|
process.exitCode = result.ok ? 0 : 1;
|
|
78
78
|
return;
|
|
79
79
|
}
|
|
80
|
+
if (command === 'reflect-auto') {
|
|
81
|
+
// Internal entrypoint for the detached automatic consolidation run
|
|
82
|
+
// spawned by the hooks. Silent to the user's session; logs to
|
|
83
|
+
// ~/.memoryrouter/consolidate.log. Always exits 0.
|
|
84
|
+
const { runAutoConsolidate } = require('../runtime/auto.cjs');
|
|
85
|
+
try { await runAutoConsolidate(); } catch { /* logged */ }
|
|
86
|
+
process.exitCode = 0;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
80
89
|
if (command === 'import') {
|
|
81
90
|
const { loadConfig, getApiKey } = require('../runtime/config.cjs');
|
|
82
91
|
const { runInstructions, runDismiss, runImportFile } = require('../runtime/import.cjs');
|
package/package.json
CHANGED
package/runtime/auto.cjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Automatic threshold-activated consolidation for the codex connector.
|
|
3
|
+
//
|
|
4
|
+
// When a hook's search/prepare response carries a non-null
|
|
5
|
+
// reflection.suggestion (the server's own threshold decision; no threshold is
|
|
6
|
+
// ever hardcoded client-side), the hook spawns the existing reflect loop
|
|
7
|
+
// (runtime/reflect.cjs, codex exec headless) as a detached background
|
|
8
|
+
// process. The user's session is never blocked; failures are silent to the
|
|
9
|
+
// session and logged to ~/.memoryrouter/consolidate.log.
|
|
10
|
+
//
|
|
11
|
+
// Rails: machine-wide lockfile (~/.memoryrouter/consolidate.lock, pid +
|
|
12
|
+
// timestamp, stale after 20 minutes, shared with the other MemoryRouter
|
|
13
|
+
// connectors), max 3 batches per trigger with a debt re-check between
|
|
14
|
+
// batches, 30 minute cooldown after a completed run (plus a 10 minute
|
|
15
|
+
// attempt cooldown so persistent failures cannot spam the host model), and
|
|
16
|
+
// config autoConsolidate: false (default true) to opt out.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const childProcess = require('child_process');
|
|
22
|
+
|
|
23
|
+
const LOCK_STALE_MS = 20 * 60 * 1000;
|
|
24
|
+
const COOLDOWN_MS = 30 * 60 * 1000;
|
|
25
|
+
const ATTEMPT_COOLDOWN_MS = 10 * 60 * 1000;
|
|
26
|
+
const MAX_BATCHES_PER_TRIGGER = 3;
|
|
27
|
+
|
|
28
|
+
// The lock/cooldown directory is intentionally the shared ~/.memoryrouter
|
|
29
|
+
// root (not the codex-specific subdirectory) so all MemoryRouter connectors
|
|
30
|
+
// on one machine coordinate through the same lockfile.
|
|
31
|
+
function autoDir(env = process.env) {
|
|
32
|
+
return env.MEMORYROUTER_AUTO_DIR || path.join(os.homedir(), '.memoryrouter');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lockPath(env) { return path.join(autoDir(env), 'consolidate.lock'); }
|
|
36
|
+
function statePath(env) { return path.join(autoDir(env), 'consolidate-state.json'); }
|
|
37
|
+
function logPath(env) { return path.join(autoDir(env), 'consolidate.log'); }
|
|
38
|
+
|
|
39
|
+
function ensureDir(dir) {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function appendLog(line, env = process.env) {
|
|
44
|
+
try {
|
|
45
|
+
ensureDir(autoDir(env));
|
|
46
|
+
const file = logPath(env);
|
|
47
|
+
try {
|
|
48
|
+
if (fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, `${file}.1`);
|
|
49
|
+
} catch { /* new file */ }
|
|
50
|
+
fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`, { mode: 0o600 });
|
|
51
|
+
} catch { /* logging never throws */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readState(env = process.env) {
|
|
55
|
+
try { return JSON.parse(fs.readFileSync(statePath(env), 'utf8')) || {}; } catch { return {}; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeState(patch, env = process.env) {
|
|
59
|
+
try {
|
|
60
|
+
ensureDir(autoDir(env));
|
|
61
|
+
const next = { ...readState(env), ...patch };
|
|
62
|
+
fs.writeFileSync(statePath(env), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
63
|
+
} catch { /* state is best-effort */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lockFresh(env = process.env, now = Date.now()) {
|
|
67
|
+
try {
|
|
68
|
+
const info = JSON.parse(fs.readFileSync(lockPath(env), 'utf8'));
|
|
69
|
+
return now - Number(info.at) <= LOCK_STALE_MS;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err && err.code === 'ENOENT') return false;
|
|
72
|
+
try { return now - fs.statSync(lockPath(env)).mtimeMs <= LOCK_STALE_MS; } catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function acquireLock(env = process.env, now = Date.now()) {
|
|
77
|
+
ensureDir(autoDir(env));
|
|
78
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
79
|
+
try {
|
|
80
|
+
const fd = fs.openSync(lockPath(env), 'wx', 0o600);
|
|
81
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now }) + '\n');
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!err || err.code !== 'EEXIST') return false;
|
|
86
|
+
if (lockFresh(env, now)) return false;
|
|
87
|
+
try { fs.unlinkSync(lockPath(env)); } catch { return false; }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function releaseLock(env = process.env) {
|
|
94
|
+
try { fs.unlinkSync(lockPath(env)); } catch { /* already gone */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cooldownActive(env = process.env, now = Date.now()) {
|
|
98
|
+
const state = readState(env);
|
|
99
|
+
const completed = Number(state.lastCompletedAt);
|
|
100
|
+
if (Number.isFinite(completed) && now - completed < COOLDOWN_MS) return true;
|
|
101
|
+
const attempted = Number(state.lastAttemptAt);
|
|
102
|
+
if (Number.isFinite(attempted) && now - attempted < ATTEMPT_COOLDOWN_MS) return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Tier choice comes entirely from the server's debt block: per-tier debt and
|
|
107
|
+
// the threshold are both server-reported.
|
|
108
|
+
function pickTier(debt) {
|
|
109
|
+
const threshold = Number(debt && debt.threshold_importance);
|
|
110
|
+
const rawEquiv = Number(debt && debt.unconsolidated && debt.unconsolidated.raw
|
|
111
|
+
&& debt.unconsolidated.raw.importance_equiv);
|
|
112
|
+
const tier2 = Number(debt && debt.unconsolidated && debt.unconsolidated.tier2
|
|
113
|
+
&& debt.unconsolidated.tier2.importance);
|
|
114
|
+
if (Number.isFinite(threshold) && threshold > 0) {
|
|
115
|
+
if (Number.isFinite(rawEquiv) && rawEquiv >= threshold) return 1;
|
|
116
|
+
if (Number.isFinite(tier2) && tier2 >= threshold) return 2;
|
|
117
|
+
}
|
|
118
|
+
return /tier 2/i.test(String(debt && debt.suggestion || '')) ? 2 : 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function maxBatches(env = process.env) {
|
|
122
|
+
const raw = parseInt(env.MEMORYROUTER_AUTO_MAX_BATCHES, 10);
|
|
123
|
+
if (Number.isInteger(raw) && raw >= 1) return Math.min(raw, MAX_BATCHES_PER_TRIGGER);
|
|
124
|
+
return MAX_BATCHES_PER_TRIGGER;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Trigger check + detached spawn, called from the hook path with the
|
|
129
|
+
* reflection debt block the hook already received. Never blocks, never
|
|
130
|
+
* throws into the hook.
|
|
131
|
+
*/
|
|
132
|
+
function maybeSpawnAutoConsolidate(reflection, config = {}, options = {}) {
|
|
133
|
+
const env = options.env || process.env;
|
|
134
|
+
try {
|
|
135
|
+
if (env.MEMORYROUTER_DISABLE === '1') return false;
|
|
136
|
+
if (config.autoConsolidate === false) return false;
|
|
137
|
+
if (!reflection || !reflection.suggestion) return false;
|
|
138
|
+
const now = options.now || Date.now();
|
|
139
|
+
if (cooldownActive(env, now)) return false;
|
|
140
|
+
if (lockFresh(env, now)) return false;
|
|
141
|
+
|
|
142
|
+
const spawner = options.spawn || childProcess.spawn;
|
|
143
|
+
const cliPath = options.cliPath || path.resolve(__dirname, '..', 'bin', 'cli.cjs');
|
|
144
|
+
ensureDir(autoDir(env));
|
|
145
|
+
const out = fs.openSync(logPath(env), 'a');
|
|
146
|
+
try {
|
|
147
|
+
const child = spawner(process.execPath, [cliPath, 'reflect-auto'], {
|
|
148
|
+
detached: true,
|
|
149
|
+
stdio: ['ignore', out, out],
|
|
150
|
+
env: { ...env },
|
|
151
|
+
});
|
|
152
|
+
if (child && child.unref) child.unref();
|
|
153
|
+
} finally {
|
|
154
|
+
try { fs.closeSync(out); } catch { /* fd handed to child */ }
|
|
155
|
+
}
|
|
156
|
+
appendLog(`trigger: spawned reflect-auto (suggestion: ${reflection.suggestion})`, env);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
appendLog(`trigger error: ${err && err.message}`, env);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The detached background run: lock, consolidate up to 3 batches re-checking
|
|
166
|
+
* debt between each via the API, record completion, release. Reuses the
|
|
167
|
+
* EXISTING reflect loop (runtime/reflect.cjs); nothing is duplicated.
|
|
168
|
+
*/
|
|
169
|
+
async function runAutoConsolidate(options = {}) {
|
|
170
|
+
const env = options.env || process.env;
|
|
171
|
+
const now = options.now || (() => Date.now());
|
|
172
|
+
const api = options.api || require('./api.cjs');
|
|
173
|
+
const runReflect = options.runReflect || require('./reflect.cjs').runReflect;
|
|
174
|
+
const { loadConfig, getApiKey } = require('./config.cjs');
|
|
175
|
+
const config = options.config || loadConfig(env);
|
|
176
|
+
const key = options.key || getApiKey(env, config);
|
|
177
|
+
const log = (line) => appendLog(line, env);
|
|
178
|
+
if (!key) { log('auto: no API key available; skipping'); return { committed: 0, reason: 'no-key' }; }
|
|
179
|
+
|
|
180
|
+
if (cooldownActive(env, now())) { log('auto: cooldown active; skipping'); return { committed: 0, reason: 'cooldown' }; }
|
|
181
|
+
if (!acquireLock(env, now())) { log('auto: another consolidation run holds the lock; skipping'); return { committed: 0, reason: 'locked' }; }
|
|
182
|
+
|
|
183
|
+
writeState({ lastAttemptAt: now() }, env);
|
|
184
|
+
let committed = 0;
|
|
185
|
+
try {
|
|
186
|
+
const bound = maxBatches(env);
|
|
187
|
+
for (let batch = 0; batch < bound; batch += 1) {
|
|
188
|
+
const probe = await api.debtProbe(key, { apiBase: config.apiBase });
|
|
189
|
+
const debt = probe && probe.ok && probe.data && probe.data.reflection;
|
|
190
|
+
if (!debt || !debt.suggestion) {
|
|
191
|
+
log(`auto: debt below threshold after ${committed} batch(es); stopping`);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const tier = pickTier(debt);
|
|
195
|
+
log(`auto: batch ${batch + 1}/${bound} starting (tier ${tier}; ${debt.suggestion})`);
|
|
196
|
+
let result;
|
|
197
|
+
try {
|
|
198
|
+
result = await runReflect({ key, tier, batches: '1', env, config, log });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
log(`auto: reflect failed: ${err && err.message}`);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (!result || !result.completed) {
|
|
204
|
+
log('auto: no batch committed; stopping');
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
committed += result.completed;
|
|
208
|
+
}
|
|
209
|
+
if (committed > 0) {
|
|
210
|
+
writeState({ lastCompletedAt: now() }, env);
|
|
211
|
+
log(`auto: run complete, ${committed} batch(es) committed`);
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
releaseLock(env);
|
|
215
|
+
}
|
|
216
|
+
return { committed };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
maybeSpawnAutoConsolidate,
|
|
221
|
+
runAutoConsolidate,
|
|
222
|
+
acquireLock,
|
|
223
|
+
releaseLock,
|
|
224
|
+
lockFresh,
|
|
225
|
+
cooldownActive,
|
|
226
|
+
pickTier,
|
|
227
|
+
appendLog,
|
|
228
|
+
readState,
|
|
229
|
+
writeState,
|
|
230
|
+
autoDir,
|
|
231
|
+
LOCK_STALE_MS,
|
|
232
|
+
COOLDOWN_MS,
|
|
233
|
+
ATTEMPT_COOLDOWN_MS,
|
|
234
|
+
MAX_BATCHES_PER_TRIGGER,
|
|
235
|
+
};
|
package/runtime/constants.cjs
CHANGED
package/runtime/hook.cjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const { loadConfig, getApiKey, loadSessionState, saveSessionState, pruneState, recordHealth } = require('./config.cjs');
|
|
5
5
|
const { resolveProject } = require('./project.cjs');
|
|
6
6
|
const { searchScoped, storeScoped, memoryHandle } = require('./memory.cjs');
|
|
7
|
+
const { maybeSpawnAutoConsolidate } = require('./auto.cjs');
|
|
7
8
|
|
|
8
9
|
function debug(...values) {
|
|
9
10
|
if (process.env.MEMORYROUTER_DEBUG) console.error('[memoryrouter-codex]', ...values);
|
|
@@ -95,6 +96,10 @@ async function handleInject(input, context) {
|
|
|
95
96
|
memories: result.memories?.length || 0,
|
|
96
97
|
status: result.status,
|
|
97
98
|
}, context.env);
|
|
99
|
+
// Automatic consolidation (default on; config autoConsolidate: false opts
|
|
100
|
+
// out): detached fire-and-forget background run when the server says
|
|
101
|
+
// should-consolidate. Never blocks the hook.
|
|
102
|
+
maybeSpawnAutoConsolidate(result.reflection, context.config, { env: context.env });
|
|
98
103
|
emitContext('UserPromptSubmit', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -110,6 +115,7 @@ async function handleSessionStart(input, context) {
|
|
|
110
115
|
project: context.project.handle,
|
|
111
116
|
memories: result.memories?.length || 0,
|
|
112
117
|
}, context.env);
|
|
118
|
+
maybeSpawnAutoConsolidate(result.reflection, context.config, { env: context.env });
|
|
113
119
|
emitContext('SessionStart', maybeAppendNudge(result.context, result.reflection, input.session_id, context, result.importState));
|
|
114
120
|
}
|
|
115
121
|
|