mneme-ai 2.73.0 → 2.75.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.
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * v2.75.0 — MNEME preinstall reaper (HANDLE-ORACLE + PID-LEASE + CMDLINE-MATCH).
4
+ *
5
+ * THE BUG THIS CLOSES. The old inline preinstall did `taskkill /F /IM
6
+ * mneme.exe /T` — but the Mneme daemon on Windows runs as
7
+ * `node.exe …\bin\mneme.js nucleus daemon`, whose IMAGE NAME is
8
+ * `node.exe`, NOT `mneme.exe`. So image-name kill never touched it. A
9
+ * daemon that loaded a native module (libvips-42.dll via the OPTIONAL
10
+ * @huggingface/transformers→sharp path) keeps that DLL memory-mapped and
11
+ * locked → `npm i -g mneme-ai` fails with EBUSY when it tries to
12
+ * overwrite the DLL.
13
+ *
14
+ * THE FIX — three deterministic, pure-JS layers (no native, no deps):
15
+ *
16
+ * 1. PID-LEASE read ~/.mneme-global/heartbeats/*.beat — every
17
+ * live Mneme process leases its {pid, holdsPaths}
18
+ * there. Authoritative for daemons that registered.
19
+ *
20
+ * 2. CMDLINE-MATCH query the OS process table (wmic → PowerShell on
21
+ * Windows; `ps` on POSIX) and kill any process whose
22
+ * COMMAND LINE looks like a Mneme daemon
23
+ * (`mneme.js`/`nucleus daemon`) even if it never
24
+ * wrote a heartbeat (old version, crashed registry,
25
+ * deleted beat file). This is the real node.exe fix.
26
+ *
27
+ * 3. HANDLE-ORACLE replace the old blind `wait(300)`/`wait(500)` with
28
+ * a DETERMINISTIC gate: loop `fs.openSync(dll,'r+')`
29
+ * (exclusive on Windows) and proceed the instant the
30
+ * OS confirms the handle is free — proof, not hope.
31
+ * Falls back to the v2.19.61 rename-sideways trick if
32
+ * the handle never frees.
33
+ *
34
+ * CONTRACT: this script must NEVER block an install. Every path is wrapped
35
+ * best-effort and the process ALWAYS exits 0. It depends on ZERO node_modules
36
+ * (preinstall runs before deps are installed) — node builtins only. It is
37
+ * ALSO importable: `require()` exposes the pure functions for the test suite
38
+ * without running the IO side effects (guarded by require.main === module).
39
+ */
40
+
41
+ "use strict";
42
+
43
+ const fs = require("node:fs");
44
+ const path = require("node:path");
45
+ const os = require("node:os");
46
+ const { spawnSync } = require("node:child_process");
47
+ const crypto = require("node:crypto");
48
+
49
+ const IS_WIN = process.platform === "win32";
50
+
51
+ /* ── tiny helpers ─────────────────────────────────────────────────────── */
52
+
53
+ /** Bounded synchronous sleep. preinstall must be synchronous (npm waits on
54
+ * the process), so we busy-wait — but the HANDLE-ORACLE exits early the
55
+ * instant the lock frees, so total spin time is normally a few ms. */
56
+ function busyWait(ms) {
57
+ const end = Date.now() + Math.max(0, ms);
58
+ while (Date.now() < end) { /* spin */ }
59
+ }
60
+
61
+ function organDir() { return path.join(os.homedir(), ".mneme-global"); }
62
+ function heartbeatDir() { return path.join(organDir(), "heartbeats"); }
63
+
64
+ /* ── PURE FUNCTION 1: does a command line look like a Mneme daemon? ────────
65
+ *
66
+ * The daemon is spawned as `node …/bin/mneme.js nucleus daemon [--detach]`
67
+ * (see nucleus_daemon.ts). We match the SCRIPT path + the daemon subcommand,
68
+ * NOT a bare "mneme" substring (which would false-positive on, say, an editor
69
+ * opened on a file called mneme.ts, or this very preinstall process). */
70
+ function matchesMnemeDaemonCmdline(cmdline) {
71
+ if (typeof cmdline !== "string" || cmdline.length === 0) return false;
72
+ const c = cmdline.toLowerCase();
73
+ // Never match the installer/preinstall itself.
74
+ if (c.includes("preinstall-mneme") || c.includes("postinstall-mneme")) return false;
75
+ // Must reference the mneme CLI entrypoint (bin/mneme.js or a `mneme` shim
76
+ // invoking a .js) AND a daemon-shaped subcommand.
77
+ const refsMnemeJs = /(^|[\\/\s"'])mneme\.js([\s"']|$)/.test(c) || c.includes("bin\\mneme.js") || c.includes("bin/mneme.js") || c.includes("mneme-ai");
78
+ const isDaemonish = c.includes("nucleus") || c.includes("daemon") || c.includes("--detach");
79
+ return refsMnemeJs && isDaemonish;
80
+ }
81
+
82
+ /* ── PURE FUNCTION 2: parse the Windows `wmic … /format:csv` table ────────
83
+ *
84
+ * wmic CSV columns are: Node,CommandLine,ProcessId (header order varies, so
85
+ * we locate columns by header name). CommandLine itself can contain commas,
86
+ * so we DON'T naive-split: we take the FIRST field (Node=hostname) and the
87
+ * LAST field (ProcessId) and treat everything between as the command line. */
88
+ function parseWmicCsv(stdout) {
89
+ if (typeof stdout !== "string") return [];
90
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
91
+ if (lines.length < 2) return [];
92
+ // Find header row (contains "ProcessId").
93
+ let headerIdx = lines.findIndex((l) => /ProcessId/i.test(l) && l.includes(","));
94
+ if (headerIdx < 0) return [];
95
+ const out = [];
96
+ for (let i = headerIdx + 1; i < lines.length; i++) {
97
+ const row = lines[i];
98
+ const first = row.indexOf(",");
99
+ const last = row.lastIndexOf(",");
100
+ if (first < 0 || last <= first) continue;
101
+ const pid = parseInt(row.slice(last + 1).trim(), 10);
102
+ const cmdline = row.slice(first + 1, last).trim();
103
+ if (Number.isFinite(pid) && pid > 0) out.push({ pid, cmdline });
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /* ── PURE FUNCTION 3: parse PowerShell CSV (ProcessId,CommandLine) ──────── */
109
+ function parsePowershellCsv(stdout) {
110
+ if (typeof stdout !== "string") return [];
111
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
112
+ const out = [];
113
+ for (const line of lines) {
114
+ // "<pid>","<cmdline maybe with commas>"
115
+ const m = line.match(/^"?(\d+)"?\s*,\s*"?(.*?)"?$/);
116
+ if (m) {
117
+ const pid = parseInt(m[1], 10);
118
+ if (Number.isFinite(pid) && pid > 0) out.push({ pid, cmdline: m[2] || "" });
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /* ── PURE FUNCTION 4: parse POSIX `ps -eo pid=,args=` ───────────────────── */
125
+ function parsePosixPs(stdout) {
126
+ if (typeof stdout !== "string") return [];
127
+ const out = [];
128
+ for (const line of stdout.split(/\r?\n/)) {
129
+ const t = line.trim();
130
+ if (!t) continue;
131
+ const m = t.match(/^(\d+)\s+(.*)$/);
132
+ if (m) {
133
+ const pid = parseInt(m[1], 10);
134
+ if (Number.isFinite(pid) && pid > 0) out.push({ pid, cmdline: m[2] });
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /* ── PURE FUNCTION 5: select daemon PIDs to kill (exclude self) ──────────── */
141
+ function selectDaemonPids(procs, selfPid) {
142
+ if (!Array.isArray(procs)) return [];
143
+ const seen = new Set();
144
+ const out = [];
145
+ for (const p of procs) {
146
+ if (!p || typeof p.pid !== "number" || p.pid <= 0) continue;
147
+ if (p.pid === selfPid) continue;
148
+ if (!matchesMnemeDaemonCmdline(p.cmdline)) continue;
149
+ if (seen.has(p.pid)) continue;
150
+ seen.add(p.pid);
151
+ out.push(p.pid);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /* ── PURE FUNCTION 6: known libvips/native DLL candidate paths ───────────── */
157
+ function libvipsDllCandidates(npmGlobalPkgDir, isWindows) {
158
+ if (!isWindows) return [];
159
+ const nm = path.join(npmGlobalPkgDir, "node_modules");
160
+ return [
161
+ path.join(nm, "@img", "sharp-libvips-win32-x64", "lib", "libvips-42.dll"),
162
+ path.join(nm, "@img", "sharp-libvips-win32-x64", "lib", "libvips-cpp-8.17.3.dll"),
163
+ path.join(nm, "sharp", "build", "Release", "sharp-win32-x64.node"),
164
+ ];
165
+ }
166
+
167
+ /* ── HANDLE-ORACLE: deterministic "is this file unlocked?" ────────────────
168
+ *
169
+ * On Windows, fs.openSync(path,'r+') fails with EBUSY/EPERM while another
170
+ * process holds the file mapped. Success = nobody holds it = safe to
171
+ * overwrite. We inject openFn for testing. Returns true if free / absent. */
172
+ function tryExclusiveOpen(filePath, openFn) {
173
+ const open = openFn || ((p) => { const fd = fs.openSync(p, "r+"); fs.closeSync(fd); });
174
+ try {
175
+ if (!fs.existsSync(filePath)) return true; // nothing to lock
176
+ } catch { /* fall through to open attempt */ }
177
+ try { open(filePath); return true; }
178
+ catch { return false; }
179
+ }
180
+
181
+ /** Loop tryExclusiveOpen until released or tries exhausted. Deterministic
182
+ * proof-of-release; returns the moment the OS frees the handle. */
183
+ function waitForHandleRelease(filePath, opts) {
184
+ const o = opts || {};
185
+ const tries = typeof o.tries === "number" ? o.tries : 40;
186
+ const intervalMs = typeof o.intervalMs === "number" ? o.intervalMs : 50;
187
+ const openFn = o.openFn;
188
+ const sleep = o.sleep || busyWait;
189
+ for (let i = 1; i <= tries; i++) {
190
+ if (tryExclusiveOpen(filePath, openFn)) return { released: true, attempts: i };
191
+ if (i < tries) sleep(intervalMs);
192
+ }
193
+ return { released: false, attempts: tries };
194
+ }
195
+
196
+ /* ── IO: read PID leases from the heartbeat registry ─────────────────────── */
197
+ function readHeartbeatLeases(dir) {
198
+ const d = dir || heartbeatDir();
199
+ const out = [];
200
+ let entries;
201
+ try { entries = fs.readdirSync(d); } catch { return out; }
202
+ for (const f of entries) {
203
+ if (!f.endsWith(".beat")) continue;
204
+ try {
205
+ const beat = JSON.parse(fs.readFileSync(path.join(d, f), "utf8"));
206
+ if (typeof beat.pid === "number" && beat.pid > 0) {
207
+ out.push({ pid: beat.pid, holdsPaths: Array.isArray(beat.holdsPaths) ? beat.holdsPaths : [], beatFile: path.join(d, f) });
208
+ }
209
+ } catch { /* corrupt — skip */ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /* ── IO: query the OS process table (best-effort) ────────────────────────── */
215
+ function queryProcessTable() {
216
+ try {
217
+ if (IS_WIN) {
218
+ // Prefer wmic (fast, present on most Windows); fall back to PowerShell
219
+ // CIM (wmic is removed on some Win11 builds).
220
+ const w = spawnSync("wmic", ["process", "get", "Name,ProcessId,CommandLine", "/format:csv"],
221
+ { shell: true, windowsHide: true, timeout: 6000, encoding: "utf8" });
222
+ if (w.status === 0 && typeof w.stdout === "string" && /ProcessId/i.test(w.stdout)) {
223
+ return parseWmicCsv(w.stdout);
224
+ }
225
+ const ps = spawnSync("powershell",
226
+ ["-NoProfile", "-NonInteractive", "-Command",
227
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"],
228
+ { shell: false, windowsHide: true, timeout: 8000, encoding: "utf8" });
229
+ if (typeof ps.stdout === "string") return parsePowershellCsv(ps.stdout);
230
+ return [];
231
+ }
232
+ const r = spawnSync("ps", ["-eo", "pid=,args="], { timeout: 6000, encoding: "utf8" });
233
+ if (typeof r.stdout === "string") return parsePosixPs(r.stdout);
234
+ return [];
235
+ } catch { return []; }
236
+ }
237
+
238
+ /* ── IO: kill a list of PIDs (Windows-correct taskkill /F /PID) ──────────── */
239
+ function killPids(pids, isWindows) {
240
+ const win = typeof isWindows === "boolean" ? isWindows : IS_WIN;
241
+ const out = [];
242
+ for (const pid of pids || []) {
243
+ if (typeof pid !== "number" || pid <= 0 || pid === process.pid) { out.push({ pid, killed: false, reason: "skip" }); continue; }
244
+ try {
245
+ if (win) {
246
+ const r = spawnSync("taskkill", ["/F", "/PID", String(pid), "/T"], { shell: true, windowsHide: true, timeout: 4000, stdio: "ignore" });
247
+ out.push({ pid, killed: r.status === 0 });
248
+ } else {
249
+ try { process.kill(pid, "SIGTERM"); } catch { /* */ }
250
+ busyWait(120);
251
+ try { process.kill(pid, "SIGKILL"); } catch { /* */ }
252
+ out.push({ pid, killed: true });
253
+ }
254
+ } catch { out.push({ pid, killed: false, reason: "throw" }); }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /* ── HMAC-chained trail (tamper-evident install breadcrumb) ──────────────── */
260
+ function makeTrail(organ, version) {
261
+ const trailPath = path.join(organ, "preinstall-trail.jsonl");
262
+ const secret = process.env["MNEME_PREINSTALL_TRAIL_SECRET"] || "mneme-preinstall-trail-v1";
263
+ const lastSig = () => {
264
+ try {
265
+ if (!fs.existsSync(trailPath)) return "genesis";
266
+ const lines = fs.readFileSync(trailPath, "utf8").trim().split("\n").filter(Boolean);
267
+ if (lines.length === 0) return "genesis";
268
+ const last = JSON.parse(lines[lines.length - 1]);
269
+ return typeof last.sig === "string" ? last.sig : "genesis";
270
+ } catch { return "genesis"; }
271
+ };
272
+ return (step, ok, details) => {
273
+ try {
274
+ const prevSig = lastSig();
275
+ const body = { v: 2, ts: new Date().toISOString(), version, step, ok, ...(details ? { details } : {}), pid: process.pid, prevSig };
276
+ const sig = crypto.createHmac("sha256", secret).update(prevSig + "::" + JSON.stringify(body)).digest("hex");
277
+ fs.appendFileSync(trailPath, JSON.stringify({ ...body, sig }) + "\n", "utf8");
278
+ } catch { /* trail is best-effort */ }
279
+ };
280
+ }
281
+
282
+ /* ── ORCHESTRATOR ───────────────────────────────────────────────────────── */
283
+ function runPreinstall(opts) {
284
+ const o = opts || {};
285
+ const home = os.homedir();
286
+ const organ = o.organDir || organDir();
287
+ const beatDir = o.heartbeatDir || heartbeatDir();
288
+ // Injection points (default to real IO; tests override for hermeticity).
289
+ const queryProcs = o.queryProcs || queryProcessTable;
290
+ const killFn = o.killFn || killPids;
291
+ const version = process.env["npm_package_version"] || "unknown";
292
+ const result = { ok: true, version, killedPids: [], handleOracle: [], renamed: 0, swept: 0, leasePids: [], cmdlinePids: [] };
293
+ try { if (!fs.existsSync(organ)) fs.mkdirSync(organ, { recursive: true, mode: 0o700 }); } catch { /* */ }
294
+ const trail = makeTrail(organ, version);
295
+ trail("preinstall-start", true, { v: "2.75.0", reaper: true });
296
+
297
+ // 1. announce incoming install (other Mneme processes back off on seeing this).
298
+ try {
299
+ fs.writeFileSync(path.join(organ, "install-incoming.flag"),
300
+ JSON.stringify({ v: 2, announcedAt: new Date().toISOString(), announcerPid: process.pid, reason: "preinstall-hook" }),
301
+ { encoding: "utf8", mode: 0o600 });
302
+ trail("flag-written", true);
303
+ } catch { trail("flag-written", false); }
304
+
305
+ // 2. image-name kill (legacy; catches a real mneme.exe shim if one exists).
306
+ if (IS_WIN && o.imageKill !== false) {
307
+ try { spawnSync("taskkill", ["/F", "/IM", "mneme.exe", "/T"], { shell: true, windowsHide: true, timeout: 5000, stdio: "ignore" }); } catch { /* */ }
308
+ }
309
+
310
+ // 3. PID-LEASE: kill every daemon that leased a heartbeat (authoritative).
311
+ const leases = readHeartbeatLeases(beatDir);
312
+ const leasePids = leases.map((l) => l.pid).filter((p) => p !== process.pid);
313
+ result.leasePids = leasePids.slice();
314
+ for (const l of leases) { if (l.beatFile) { try { fs.unlinkSync(l.beatFile); } catch { /* */ } } }
315
+ const killedLease = killFn(leasePids);
316
+
317
+ // 4. CMDLINE-MATCH: the real node.exe fix — kill daemons NOT in the
318
+ // registry (old version / crashed / deleted beat) by matching cmdline.
319
+ const procs = queryProcs();
320
+ const cmdlinePids = selectDaemonPids(procs, process.pid).filter((p) => !leasePids.includes(p));
321
+ result.cmdlinePids = cmdlinePids.slice();
322
+ const killedCmd = killFn(cmdlinePids);
323
+ result.killedPids = killedLease.concat(killedCmd).filter((k) => k.killed).map((k) => k.pid);
324
+ trail("daemons-reaped", true, { lease: leasePids.length, cmdline: cmdlinePids.length, killed: result.killedPids.length });
325
+
326
+ // 5. HANDLE-ORACLE: deterministically wait for the native DLLs to free,
327
+ // then rename-sideways only if the lock genuinely persists.
328
+ let renamed = 0;
329
+ const dllPrefixes = o.npmGlobalDirs || defaultNpmGlobalDirs(home);
330
+ for (const pkgDir of dllPrefixes) {
331
+ for (const dll of libvipsDllCandidates(pkgDir, IS_WIN)) {
332
+ let present = false;
333
+ try { present = fs.existsSync(dll); } catch { /* */ }
334
+ if (!present) continue;
335
+ const gate = waitForHandleRelease(dll, { tries: 40, intervalMs: 50 });
336
+ result.handleOracle.push({ dll, released: gate.released, attempts: gate.attempts });
337
+ if (!gate.released) {
338
+ // Lock never freed — fall back to the v2.19.61 rename-sideways trick
339
+ // so npm can lay down the new DLL beside the locked (orphaned) one.
340
+ try { fs.renameSync(dll, dll + ".locked-" + Date.now() + "-" + process.pid); renamed++; } catch { /* */ }
341
+ }
342
+ }
343
+ }
344
+ result.renamed = renamed;
345
+ trail("handle-oracle", true, { checked: result.handleOracle.length, renamed });
346
+
347
+ // 6. sweep abandoned npm staging dirs (.mneme-ai-* leftovers).
348
+ let swept = 0;
349
+ try {
350
+ for (const npmParent of (o.sweepDirs || defaultNpmNodeModules(home))) {
351
+ let entries;
352
+ try { entries = fs.readdirSync(npmParent); } catch { continue; }
353
+ for (const entry of entries) {
354
+ if (entry.startsWith(".mneme-ai-")) {
355
+ try { fs.rmSync(path.join(npmParent, entry), { recursive: true, force: true }); swept++; } catch { /* */ }
356
+ }
357
+ }
358
+ }
359
+ } catch { /* */ }
360
+ result.swept = swept;
361
+ trail("staging-swept", true, { swept });
362
+ trail("preinstall-end", true);
363
+ return result;
364
+ }
365
+
366
+ /* ── npm global location heuristics (Windows + POSIX, multi-version-mgr) ──── */
367
+ function defaultNpmGlobalDirs(home) {
368
+ const dirs = [];
369
+ const push = (p) => { if (p) dirs.push(path.join(p, "mneme-ai")); };
370
+ if (IS_WIN) {
371
+ push(path.join(home, "AppData", "Roaming", "npm", "node_modules"));
372
+ push(path.join(path.dirname(process.execPath), "node_modules"));
373
+ } else {
374
+ push("/usr/local/lib/node_modules");
375
+ push("/usr/lib/node_modules");
376
+ push(path.join(home, ".npm-global", "node_modules"));
377
+ }
378
+ return dirs;
379
+ }
380
+ function defaultNpmNodeModules(home) {
381
+ if (IS_WIN) {
382
+ return [path.join(home, "AppData", "Roaming", "npm", "node_modules"), path.join(path.dirname(process.execPath), "node_modules")];
383
+ }
384
+ return ["/usr/local/lib/node_modules", path.join(home, ".npm-global", "node_modules")];
385
+ }
386
+
387
+ /* ── exports (for the test suite) ───────────────────────────────────────── */
388
+ module.exports = {
389
+ matchesMnemeDaemonCmdline,
390
+ parseWmicCsv,
391
+ parsePowershellCsv,
392
+ parsePosixPs,
393
+ selectDaemonPids,
394
+ libvipsDllCandidates,
395
+ tryExclusiveOpen,
396
+ waitForHandleRelease,
397
+ readHeartbeatLeases,
398
+ queryProcessTable,
399
+ killPids,
400
+ runPreinstall,
401
+ };
402
+
403
+ /* ── run on direct invoke; ALWAYS exit 0 so install never aborts ─────────── */
404
+ if (require.main === module) {
405
+ try { runPreinstall(); } catch { /* never block install */ }
406
+ process.exit(0);
407
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAuIA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA8rMvD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAuIA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAixMvD"}
package/dist/index.js CHANGED
@@ -4682,6 +4682,97 @@ export async function run(argv) {
4682
4682
  process.exitCode = 1;
4683
4683
  }
4684
4684
  });
4685
+ // v2.74.0 — CHRONOS: temporal self-consistency honesty signal.
4686
+ const chronosParent = program
4687
+ .command("chronos")
4688
+ .description("v2.74 — temporal self-consistency (ground-truth-free honesty). Default = list agents + scores.")
4689
+ .action(async () => {
4690
+ try {
4691
+ const core = await import("@mneme-ai/core");
4692
+ const agents = core.chronos.listAgents(process.cwd());
4693
+ const scores = agents.map((a) => { const s = core.chronos.scoreAgent(a, process.cwd()); return { agent: a, score: s.score, band: s.band, silentDrift: s.tally.silentDrift }; });
4694
+ const led = core.chronos.verifyLedgerChain(process.cwd());
4695
+ process.stdout.write(JSON.stringify({ ok: led.ok, ledger: led, agents: scores }, null, 2) + "\n");
4696
+ }
4697
+ catch (e) {
4698
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
4699
+ process.exitCode = 1;
4700
+ }
4701
+ });
4702
+ chronosParent.command("record")
4703
+ .description("Record an AI answer (topic + stance + answer) to the temporal ledger; classifies drift vs prior answers.")
4704
+ .requiredOption("--agent <id>", "Agent id (consistency is per-agent)")
4705
+ .requiredOption("--topic <text>", "The question / subject")
4706
+ .requiredOption("--stance <text>", "The position taken (the answer's core assertion)")
4707
+ .option("--answer <text>", "Full answer text (evidence extracted from it); defaults to --stance")
4708
+ .option("--self-reported", "AI is explicitly flagging it is revising a prior answer", false)
4709
+ .action(async (opts) => {
4710
+ try {
4711
+ const core = await import("@mneme-ai/core");
4712
+ const r = core.chronos.record({ agent: opts.agent, topic: opts.topic, stance: opts.stance, answerText: opts.answer, selfReportedDrift: opts.selfReported, cwd: process.cwd() });
4713
+ process.stdout.write(JSON.stringify({ ok: r.ok, verdict: r.drift.verdict, reason: r.drift.reason, entryId: r.entry.id, matchedId: r.drift.matched?.id, topicCosine: r.drift.topicCosine }, null, 2) + "\n");
4714
+ if (r.drift.verdict === "SILENT_DRIFT")
4715
+ process.exitCode = 1;
4716
+ }
4717
+ catch (e) {
4718
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
4719
+ process.exitCode = 1;
4720
+ }
4721
+ });
4722
+ chronosParent.command("check")
4723
+ .description("Classify a candidate answer vs the ledger WITHOUT recording it (dry-run drift check).")
4724
+ .requiredOption("--agent <id>", "Agent id")
4725
+ .requiredOption("--topic <text>", "The question / subject")
4726
+ .requiredOption("--stance <text>", "The position to check")
4727
+ .option("--answer <text>", "Full answer text; defaults to --stance")
4728
+ .option("--self-reported", "Treat as self-reported drift", false)
4729
+ .action(async (opts) => {
4730
+ try {
4731
+ const core = await import("@mneme-ai/core");
4732
+ const r = core.chronos.check({ agent: opts.agent, topic: opts.topic, stance: opts.stance, answerText: opts.answer, selfReportedDrift: opts.selfReported }, { cwd: process.cwd() });
4733
+ process.stdout.write(JSON.stringify(r, null, 2) + "\n");
4734
+ }
4735
+ catch (e) {
4736
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
4737
+ process.exitCode = 1;
4738
+ }
4739
+ });
4740
+ chronosParent.command("score")
4741
+ .description("Show the temporal-honesty score for an agent (0-100 + band + silent-drift list).")
4742
+ .requiredOption("--agent <id>", "Agent id")
4743
+ .option("--banner", "Render ASCII banner instead of JSON")
4744
+ .action(async (opts) => {
4745
+ try {
4746
+ const core = await import("@mneme-ai/core");
4747
+ const s = core.chronos.scoreAgent(opts.agent, process.cwd());
4748
+ if (opts.banner)
4749
+ process.stdout.write(core.chronos.renderScoreBanner(s) + "\n");
4750
+ else
4751
+ process.stdout.write(JSON.stringify(s, null, 2) + "\n");
4752
+ }
4753
+ catch (e) {
4754
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
4755
+ process.exitCode = 1;
4756
+ }
4757
+ });
4758
+ chronosParent.command("audit")
4759
+ .description("Verify the HMAC-chained CHRONOS ledger + show last N entries.")
4760
+ .option("--limit <n>", "Max rows", (v) => Number(v), 20)
4761
+ .action(async (opts) => {
4762
+ try {
4763
+ const core = await import("@mneme-ai/core");
4764
+ const led = core.chronos.verifyLedgerChain(process.cwd());
4765
+ const rows = core.chronos.readLedger(process.cwd());
4766
+ const recent = rows.slice(-(opts.limit ?? 20)).map((e) => ({ id: e.id, at: e.at, agent: e.agent, topic: e.topic, stance: e.stance, verdict: e.driftVerdict, matchedId: e.matchedId }));
4767
+ process.stdout.write(JSON.stringify({ ok: led.ok, totalRows: led.rows, brokenAt: led.brokenAt, recent }, null, 2) + "\n");
4768
+ if (!led.ok)
4769
+ process.exitCode = 1;
4770
+ }
4771
+ catch (e) {
4772
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
4773
+ process.exitCode = 1;
4774
+ }
4775
+ });
4685
4776
  // v2.66.0 — REFLOG: cross-session time-machine.
4686
4777
  const reflogParent = program
4687
4778
  .command("reflog")