@gamaze/hicortex 0.20.4 → 0.20.6

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,1349 @@
1
+ "use strict";
2
+ /**
3
+ * Reconsolidation (#384, #392) — the store resolves its own corrections, and
4
+ * THE unified resolution stage.
5
+ *
6
+ * Nightly consolidation stage (runs as Stage 3.8, after supersession, before
7
+ * decay/prune) that detects memories which correct, retract, supersede, or
8
+ * DUPLICATE older ones; REWRITES corrected facts in place (absorbing
9
+ * transition-only trigger memories), MERGES confirmed duplicates via the
10
+ * dedup core (absorbing the loser), and marks everything else. Companions to
11
+ * the stage:
12
+ * - explicit write-time marking (`corrects`/`supersedes` at /ingest +
13
+ * `hicortex_ingest`) — deterministic link + status, zero LLM;
14
+ * - `memory_history` audit + the `hicortex history` CLI (listing + rollback).
15
+ *
16
+ * #392 — one zone system, ONE verdict per pair: below `correctionMinSimilarity`
17
+ * (floor, 0.75) pairs are not candidates; in [floor, `dedupAutoMergeThreshold`)
18
+ * (ceiling, 0.92) each unlinked pair gets ONE verdict call whose action is
19
+ * `merge` | `corrects` | `supersedes` | `none`; at/above the ceiling the
20
+ * deterministic merge zone (dedup.ts runDeterministicMergeZone — LLM-free,
21
+ * budget-free) owns the pair. The merge disposition reuses the dedup core's
22
+ * execution (canonical pick, link re-point, dedup_log, metadata rails); a
23
+ * merge verdict below `correctionRewriteMinConfidence` keeps both memories.
24
+ *
25
+ * Status vocabulary (code-defined, extensible — deliberately NOT config):
26
+ * NULL/'active' default | 'superseded' + 'retracted' demote in ranking |
27
+ * 'corrected' = rewritten, never demotes (demoting it would bury the
28
+ * correction — the exact failure this stage fixes) | 'absorbed' = invisible
29
+ * to recall (no vector row, no FTS row; plain row + link kept as evidence,
30
+ * session lineage, rollback reference). Merge losers share 'absorbed'
31
+ * (storage.absorbMemory is the one primitive) — the only difference is the
32
+ * audit trail: rewrites roll back via memory_history; merges recover via
33
+ * dedup_log + the retained loser row (NOT history-rollback-able).
34
+ *
35
+ * This module deliberately does NOT import consolidate.ts (which imports this
36
+ * module to wire the stage) — the budget is consumed through the structural
37
+ * StageBudget interface below, which BudgetTracker satisfies. dedup.ts is
38
+ * imported (never the reverse) for the merge core.
39
+ */
40
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
41
+ if (k2 === undefined) k2 = k;
42
+ var desc = Object.getOwnPropertyDescriptor(m, k);
43
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
44
+ desc = { enumerable: true, get: function() { return m[k]; } };
45
+ }
46
+ Object.defineProperty(o, k2, desc);
47
+ }) : (function(o, m, k, k2) {
48
+ if (k2 === undefined) k2 = k;
49
+ o[k2] = m[k];
50
+ }));
51
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
52
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
53
+ }) : function(o, v) {
54
+ o["default"] = v;
55
+ });
56
+ var __importStar = (this && this.__importStar) || (function () {
57
+ var ownKeys = function(o) {
58
+ ownKeys = Object.getOwnPropertyNames || function (o) {
59
+ var ar = [];
60
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
61
+ return ar;
62
+ };
63
+ return ownKeys(o);
64
+ };
65
+ return function (mod) {
66
+ if (mod && mod.__esModule) return mod;
67
+ var result = {};
68
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
69
+ __setModuleDefault(result, mod);
70
+ return result;
71
+ };
72
+ })();
73
+ Object.defineProperty(exports, "__esModule", { value: true });
74
+ exports.absorbTrigger = exports.DEMOTED_STATUSES = exports.FOOTER_HEAD_MAX_CHARS = exports.DEFAULT_RECONSOLIDATION_MAX_CALLS = exports.DEFAULT_RECONSOLIDATION_MAX_MINUTES = exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE = exports.DEFAULT_CORRECTION_MIN_SIMILARITY = exports.RECONSOLIDATION_STAGE_LABEL = void 0;
75
+ exports.isFactShapedTarget = isFactShapedTarget;
76
+ exports.buildCorrectionVerdictPrompt = buildCorrectionVerdictPrompt;
77
+ exports.parseCorrectionVerdict = parseCorrectionVerdict;
78
+ exports.buildRewritePrompt = buildRewritePrompt;
79
+ exports.parseRewriteReply = parseRewriteReply;
80
+ exports.buildCorrectionFooter = buildCorrectionFooter;
81
+ exports.checkExplicitMarkTarget = checkExplicitMarkTarget;
82
+ exports.applyExplicitMark = applyExplicitMark;
83
+ exports.replaceMemoryVector = replaceMemoryVector;
84
+ exports.unabsorbTrigger = unabsorbTrigger;
85
+ exports.getMemoryHistory = getMemoryHistory;
86
+ exports.getHistoryRow = getHistoryRow;
87
+ exports.buildResolutionBands = buildResolutionBands;
88
+ exports.bandForCosine = bandForCosine;
89
+ exports.stageReconsolidation = stageReconsolidation;
90
+ exports.rollbackHistoryRow = rollbackHistoryRow;
91
+ exports.runHistoryCommand = runHistoryCommand;
92
+ const retrieval_js_1 = require("./retrieval.js");
93
+ const storage = __importStar(require("./storage.js"));
94
+ const state_js_1 = require("./state.js");
95
+ const db_js_1 = require("./db.js");
96
+ const capture_js_1 = require("./capture.js");
97
+ const paths_js_1 = require("./paths.js");
98
+ const dedup_js_1 = require("./dedup.js");
99
+ // ---------------------------------------------------------------------------
100
+ // Constants + status vocabulary
101
+ // ---------------------------------------------------------------------------
102
+ /** Stage label used for every budget.use()/recordUsage() call (#384). */
103
+ exports.RECONSOLIDATION_STAGE_LABEL = "reconsolidation";
104
+ /**
105
+ * Default minimum COSINE similarity for a correction candidate pair. Lower
106
+ * than the supersession stage's 0.80 on purpose: a retraction often rides
107
+ * inside an otherwise unrelated memory (the field failure that opened this
108
+ * issue), so the neighborhood gate must be a touch wider while the LLM
109
+ * verdict + confidence gate carry the precision load.
110
+ */
111
+ exports.DEFAULT_CORRECTION_MIN_SIMILARITY = 0.75;
112
+ /**
113
+ * Default minimum verdict confidence for the REWRITE fork. Below this a
114
+ * `corrects` verdict degrades to mark-only — a weak mark is recoverable, a
115
+ * weak rewrite is corruption.
116
+ */
117
+ exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE = 0.8;
118
+ /**
119
+ * Default wall-clock bound for the stage, in minutes (#401). Checked at the
120
+ * top of the candidate scan loop (and before each rewrite contract call); on
121
+ * expiry the scan breaks cleanly at the last fully-considered candidate and
122
+ * the next run resumes from the persisted cursor. 120 sits safely under any
123
+ * sane process-level nightly timeout. 0 disables the bound. Invalid →
124
+ * default.
125
+ */
126
+ exports.DEFAULT_RECONSOLIDATION_MAX_MINUTES = 120;
127
+ /**
128
+ * Default per-run classify-call ceiling for the stage (#401) — the
129
+ * supersessionMaxCalls pattern with a NON-ZERO default ON PURPOSE: that
130
+ * knob's 0=unlimited default is what let the first full-corpus pass grow
131
+ * unbounded. Counts EVERY classify-tier call the stage makes (mark
132
+ * verifications, pair verdicts, rewrite contracts). 0 disables the cap.
133
+ * Invalid → default.
134
+ */
135
+ exports.DEFAULT_RECONSOLIDATION_MAX_CALLS = 600;
136
+ /**
137
+ * Candidates between mid-scan cursor persists (#401). The cursor also
138
+ * persists at EVERY scan-loop exit path (deadline, call/budget cap,
139
+ * discovery failure), so a killed run loses at most K-1 candidates of scan
140
+ * progress instead of the whole night.
141
+ */
142
+ const RECONSOLIDATION_CURSOR_PERSIST_EVERY = 50;
143
+ /** Neighbor pool size before older/similarity filtering narrows to top 5 (supersession mirror). */
144
+ const CORRECTION_NEIGHBOR_POOL = 15;
145
+ /** Older-neighbor pairs kept per candidate after filtering (supersession mirror). */
146
+ const CORRECTION_NEIGHBOR_TOP_K = 5;
147
+ /** Content truncation for prompts (classify-tier cost profile; supersession precedent). */
148
+ const PROMPT_TRUNCATE_CHARS = 1500;
149
+ /** Head of the old content quoted in the provenance footer. */
150
+ exports.FOOTER_HEAD_MAX_CHARS = 160;
151
+ /** Base slack allowed on a rewrite beyond the old content length (AC4). */
152
+ const REWRITE_BASE_SLACK_CHARS = 2000;
153
+ /** Additional slack per trigger beyond the first (AC4). */
154
+ const REWRITE_PER_TRIGGER_SLACK_CHARS = 500;
155
+ /**
156
+ * Statuses that demote a memory's ranking score (retrieval.ts findDemotedIds).
157
+ * 'corrected' is deliberately absent — see module doc.
158
+ */
159
+ exports.DEMOTED_STATUSES = ["superseded", "retracted"];
160
+ // ---------------------------------------------------------------------------
161
+ // Shape + link helpers
162
+ // ---------------------------------------------------------------------------
163
+ /**
164
+ * True when a memory is REWRITE-ELIGIBLE — a fact-shaped target. The fork is
165
+ * keyed on the existing taxonomy the code already trusts (facts are
166
+ * rewritten; decisions/plans/experiences are history, marked only).
167
+ */
168
+ function isFactShapedTarget(mem) {
169
+ return mem.memory_type === "knowledge" || mem.content.includes("[Facts Learned]");
170
+ }
171
+ /** True when a superseded_by OR corrected_by link already exists between the pair, either direction. */
172
+ function alreadyResolutionLinked(db, oldId, newId) {
173
+ const row = db
174
+ .prepare(`SELECT 1 FROM memory_links WHERE relationship IN ('superseded_by', 'corrected_by')
175
+ AND ((source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?))`)
176
+ .get(oldId, newId, newId, oldId);
177
+ return !!row;
178
+ }
179
+ /** True when a link with exactly this relationship exists on the ordered pair. */
180
+ function hasLink(db, sourceId, targetId, relationship) {
181
+ return !!db
182
+ .prepare("SELECT 1 FROM memory_links WHERE source_id = ? AND target_id = ? AND relationship = ?")
183
+ .get(sourceId, targetId, relationship);
184
+ }
185
+ function nowIso() {
186
+ return new Date().toISOString();
187
+ }
188
+ /** Build the constrained pair-verdict prompt (1500-char truncation, supersession precedent). */
189
+ function buildCorrectionVerdictPrompt(oldContent, newContent) {
190
+ const trunc = (s) => (s.length > PROMPT_TRUNCATE_CHARS ? `${s.slice(0, PROMPT_TRUNCATE_CHARS)}…` : s);
191
+ return (`You are checking how a NEWER memory relates to an OLDER one in an AI agent's long-term memory.\n\n` +
192
+ `OLDER MEMORY:\n${trunc(oldContent)}\n\n` +
193
+ `NEWER MEMORY:\n${trunc(newContent)}\n\n` +
194
+ `How does the NEWER memory relate to the OLDER one?\n` +
195
+ `- "merge": the two memories carry the SAME underlying fact, verdict, or decision, differing only in ` +
196
+ `wording, detail, or qualifiers — neither invalidates the other; they are two statements of one claim.\n` +
197
+ `- "corrects": the newer memory fixes a factual error or retraction in the older one — the older claim is ` +
198
+ `wrong, no longer true, or was retracted, and the newer memory carries the corrected fact.\n` +
199
+ `- "supersedes": the newer memory replaces a decision, plan, or state that was valid at the time but is ` +
200
+ `now outdated — a replacement, not a factual correction.\n` +
201
+ `- "none": unrelated, merely similar, or both can still be true (an addition or elaboration).\n\n` +
202
+ `Reply with ONLY a JSON object, no prose: ` +
203
+ `{"action": "merge" | "corrects" | "supersedes" | "none", "confidence": <number between 0 and 1>}`);
204
+ }
205
+ /**
206
+ * Parse the pair verdict. Null on anything unparseable, unknown action, or an
207
+ * out-of-range/missing confidence — the caller counts skipped_infra and moves
208
+ * on (same discipline as parseSupersessionReply: never mis-judge on ambiguity).
209
+ */
210
+ function parseCorrectionVerdict(reply) {
211
+ if (!reply)
212
+ return null;
213
+ const start = reply.indexOf("{");
214
+ const end = reply.lastIndexOf("}");
215
+ if (start === -1 || end === -1 || end <= start)
216
+ return null;
217
+ let obj;
218
+ try {
219
+ obj = JSON.parse(reply.slice(start, end + 1));
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ const action = obj.action;
225
+ if (action !== "merge" && action !== "corrects" && action !== "supersedes" && action !== "none")
226
+ return null;
227
+ const confidence = Number(obj.confidence);
228
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
229
+ return null;
230
+ return { action, confidence };
231
+ }
232
+ /** Build the constrained rewrite prompt: old content + N trigger contents, nothing else. */
233
+ function buildRewritePrompt(oldContent, triggers) {
234
+ const trunc = (s) => (s.length > PROMPT_TRUNCATE_CHARS ? `${s.slice(0, PROMPT_TRUNCATE_CHARS)}…` : s);
235
+ const triggerBlocks = triggers
236
+ .map((t) => `[${t.id}] ${trunc(t.content)}`)
237
+ .join("\n\n");
238
+ return (`You are rewriting a memory in an AI agent's long-term memory so it carries the corrected story.\n\n` +
239
+ `OLDER MEMORY (currently stored; contains the outdated or incorrect claim):\n${trunc(oldContent)}\n\n` +
240
+ `CORRECTING MEMORIES (newer; together they supply the correction):\n${triggerBlocks}\n\n` +
241
+ `Compose the corrected memory using ONLY the older memory and the correcting memories — no outside ` +
242
+ `knowledge, no speculation. Keep the older memory's subject and scope; replace the wrong claim with the ` +
243
+ `corrected fact. Write plain prose for long-term recall (no meta commentary, no JSON inside the text).\n` +
244
+ `Then judge each correcting memory: "absorb" if it mostly restates what the corrected memory now says ` +
245
+ `(transition-only — safe to hide from recall); "keep" if it carries standalone substance beyond the ` +
246
+ `correction.\n\n` +
247
+ `Reply with ONLY a JSON object, no prose:\n` +
248
+ `{"rewritten": "<the corrected memory text>", "triggers": [{"id": "<trigger id verbatim>", ` +
249
+ `"disposition": "absorb" | "keep"}, ...]} — one triggers entry per correcting memory above, ids verbatim.`);
250
+ }
251
+ /**
252
+ * Parse + validate the rewrite contract (AC4). Null on ANY failure:
253
+ * - unparseable JSON / wrong shape;
254
+ * - rewritten empty, identical to the old content, or longer than
255
+ * old + 2000 + 500 per additional trigger;
256
+ * - the triggers array not covering every input trigger id exactly once
257
+ * (missing, unknown, or duplicated) or carrying an invalid disposition.
258
+ * A null return degrades the WHOLE group to mark-only (never a partial apply).
259
+ */
260
+ function parseRewriteReply(reply, expectedTriggerIds, oldContent) {
261
+ if (!reply)
262
+ return null;
263
+ const start = reply.indexOf("{");
264
+ const end = reply.lastIndexOf("}");
265
+ if (start === -1 || end === -1 || end <= start)
266
+ return null;
267
+ let obj;
268
+ try {
269
+ obj = JSON.parse(reply.slice(start, end + 1));
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ const rewritten = obj.rewritten;
275
+ if (typeof rewritten !== "string" || rewritten.trim().length === 0)
276
+ return null;
277
+ if (rewritten.trim() === oldContent.trim())
278
+ return null;
279
+ const maxLen = oldContent.length + REWRITE_BASE_SLACK_CHARS + REWRITE_PER_TRIGGER_SLACK_CHARS * Math.max(0, expectedTriggerIds.length - 1);
280
+ if (rewritten.length > maxLen)
281
+ return null;
282
+ const triggersRaw = obj.triggers;
283
+ if (!Array.isArray(triggersRaw) || triggersRaw.length !== expectedTriggerIds.length)
284
+ return null;
285
+ const seen = new Set();
286
+ const triggers = [];
287
+ for (const t of triggersRaw) {
288
+ if (!t || typeof t !== "object")
289
+ return null;
290
+ const rec = t;
291
+ if (typeof rec.id !== "string" || !expectedTriggerIds.includes(rec.id))
292
+ return null;
293
+ if (seen.has(rec.id))
294
+ return null;
295
+ if (rec.disposition !== "absorb" && rec.disposition !== "keep")
296
+ return null;
297
+ seen.add(rec.id);
298
+ triggers.push({ id: rec.id, disposition: rec.disposition });
299
+ }
300
+ return { rewritten: rewritten.trim(), triggers };
301
+ }
302
+ // ---------------------------------------------------------------------------
303
+ // Provenance footer (template-controlled — uniform audit)
304
+ // ---------------------------------------------------------------------------
305
+ /**
306
+ * The provenance footer appended to every rewritten memory:
307
+ * `previously believed "<≤160-char head of old content>" until <date>`.
308
+ * date = the ISO DATE (YYYY-MM-DD) derived from the latest trigger's
309
+ * created_at — the same trigger recorded as the history row's evidence_id.
310
+ */
311
+ function buildCorrectionFooter(oldContent, dateISO) {
312
+ const head = oldContent.slice(0, exports.FOOTER_HEAD_MAX_CHARS);
313
+ return `previously believed "${head}" until ${dateISO}`;
314
+ }
315
+ /**
316
+ * Validate an explicit mark target BEFORE anything is written (AC1: on an
317
+ * unknown/ambiguous id the WHOLE request fails and nothing is stored).
318
+ * httpStatus is the REST code; the MCP tool reuses the message verbatim.
319
+ */
320
+ function checkExplicitMarkTarget(db, input) {
321
+ const targetId = storage.resolveMemoryId(db, input.target);
322
+ if (!targetId) {
323
+ return {
324
+ ok: false,
325
+ httpStatus: 404,
326
+ error: `${input.kind} target not found or ambiguous: ${input.target}`,
327
+ };
328
+ }
329
+ const target = storage.getMemory(db, targetId);
330
+ if (!target) {
331
+ return { ok: false, httpStatus: 404, error: `${input.kind} target not found: ${input.target}` };
332
+ }
333
+ if (target.status === "absorbed") {
334
+ return {
335
+ ok: false,
336
+ httpStatus: 409,
337
+ error: `${input.kind} target ${targetId.slice(0, 8)} is absorbed (invisible to recall) — roll back the absorbing rewrite first (hicortex history --rollback)`,
338
+ };
339
+ }
340
+ return { ok: true, targetId };
341
+ }
342
+ /**
343
+ * Apply a validated explicit mark: link old → new + status on the old memory.
344
+ * Deterministic — no LLM. Link strength 1.0: an operator-declared mark, not a
345
+ * measured cosine. `corrects` → `corrected_by` + status `retracted`;
346
+ * `supersedes` → `superseded_by` + status `superseded` (AC1).
347
+ */
348
+ function applyExplicitMark(db, newMemoryId, input) {
349
+ const check = checkExplicitMarkTarget(db, input);
350
+ if (!check.ok) {
351
+ throw new Error(`applyExplicitMark: unvalidated mark refused — ${check.error}`);
352
+ }
353
+ const relationship = input.kind === "corrects" ? "corrected_by" : "superseded_by";
354
+ storage.addLink(db, check.targetId, newMemoryId, relationship, 1.0);
355
+ storage.updateMemory(db, check.targetId, {
356
+ status: input.kind === "corrects" ? "retracted" : "superseded",
357
+ });
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // Absorb / un-absorb + vector-replace primitives (shared by stage + rollback)
361
+ // ---------------------------------------------------------------------------
362
+ /**
363
+ * Drop a trigger's retrieval candidacy: status `absorbed`, vector row deleted,
364
+ * FTS row deleted (direct DELETE — the AFTER UPDATE trigger's `UPDATE … WHERE
365
+ * rowid` is a silent no-op on the missing row, so later column edits cannot
366
+ * resurrect it). Row + links are KEPT (evidence, session lineage, rollback).
367
+ * Must run inside a transaction. Tags/domain deliberately untouched (only the
368
+ * rewritten TARGET gets its tags cleared).
369
+ *
370
+ * #392: the implementation moved to storage.ts (`absorbMemory`) so the dedup
371
+ * merge core shares the ONE primitive without an import cycle; re-exported
372
+ * here under its historical name for the rewrite/rollback paths (nothing
373
+ * external imports it today, but it is the module's documented surface).
374
+ */
375
+ exports.absorbTrigger = storage.absorbMemory;
376
+ /**
377
+ * Replace a memory's vector (delete + insert) — the /update re-embed pattern.
378
+ * Must run inside a transaction (the caller pre-computes the embedding
379
+ * asynchronously, outside the sync transaction).
380
+ */
381
+ function replaceMemoryVector(db, memoryId, embedding) {
382
+ db.prepare("DELETE FROM memory_vectors WHERE id = ?").run(memoryId);
383
+ db.prepare("INSERT INTO memory_vectors (id, embedding) VALUES (?, ?)").run(memoryId, storage.embedToBlob(embedding));
384
+ }
385
+ /**
386
+ * Reverse an absorb: status back to active (NULL), vector re-embedded from the
387
+ * (untouched) content, FTS row re-inserted explicitly (migration v10's rebuild
388
+ * pattern — the AFTER UPDATE trigger cannot recreate a deleted FTS row).
389
+ * Must run inside a transaction; no-op on a memory that is not currently
390
+ * absorbed (never resurrects an already-live row, never duplicates an FTS row).
391
+ */
392
+ function unabsorbTrigger(db, triggerId, embedding) {
393
+ const mem = storage.getMemory(db, triggerId);
394
+ if (!mem || mem.status !== "absorbed")
395
+ return;
396
+ const rid = storage.memoryRowid(db, triggerId);
397
+ storage.updateMemory(db, triggerId, { status: null });
398
+ replaceMemoryVector(db, triggerId, embedding);
399
+ if (rid !== null) {
400
+ db.prepare(`INSERT INTO memories_fts (rowid, content, project, domain)
401
+ SELECT rowid, content, COALESCE(project, ''), COALESCE(domain, '') FROM memories
402
+ WHERE id = ? AND NOT EXISTS (SELECT 1 FROM memories_fts WHERE rowid = ?)`).run(triggerId, rid);
403
+ }
404
+ }
405
+ /** History rows for one memory, oldest first. */
406
+ function getMemoryHistory(db, memoryId) {
407
+ return db
408
+ .prepare("SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id ASC")
409
+ .all(memoryId);
410
+ }
411
+ function getHistoryRow(db, historyRowId) {
412
+ return db
413
+ .prepare("SELECT * FROM memory_history WHERE id = ?")
414
+ .get(historyRowId) ?? null;
415
+ }
416
+ // ---------------------------------------------------------------------------
417
+ // Cosine-band statistics (#392) — self-calibration evidence
418
+ // ---------------------------------------------------------------------------
419
+ /** Fixed intermediate band edges — only the floor and ceiling are config. */
420
+ const BAND_INTERMEDIATE_EDGES = [0.8, 0.85, 0.9];
421
+ /**
422
+ * Build the verdict-statistic bands from the LIVE floor/ceiling (#392): edges
423
+ * = sorted unique [floor, 0.80, 0.85, 0.90, ceiling]; bands are [e0,e1) …
424
+ * [e(n-1),en) plus the deterministic ">=en" band. Intermediate edges outside
425
+ * (floor, ceiling) are dropped — a band below the floor can never receive a
426
+ * verdict (candidates are >= floor), so a raised floor collapses the lower
427
+ * bands away instead of seeding dead labels. Labels use the numbers as
428
+ * configured ("0.75-0.8" … "0.9-0.92", ">=0.92").
429
+ */
430
+ function buildResolutionBands(floor, ceiling) {
431
+ const edges = [
432
+ floor,
433
+ ...BAND_INTERMEDIATE_EDGES.filter((e) => e > floor && e < ceiling),
434
+ ceiling,
435
+ ]
436
+ .filter((e) => Number.isFinite(e))
437
+ .filter((e, i, arr) => arr.indexOf(e) === i)
438
+ .sort((a, b) => a - b);
439
+ const bands = [];
440
+ for (let i = 0; i < edges.length - 1; i++) {
441
+ bands.push({ label: `${edges[i]}-${edges[i + 1]}`, lo: edges[i], hi: edges[i + 1] });
442
+ }
443
+ if (edges.length > 0) {
444
+ bands.push({ label: `>=${edges[edges.length - 1]}`, lo: edges[edges.length - 1], hi: Infinity });
445
+ }
446
+ return bands;
447
+ }
448
+ /**
449
+ * The band a pair's cosine falls into: the [lo, hi) band that contains it,
450
+ * falling through to the final >= band for cosines at/above the last edge.
451
+ * Null only for a cosine below the floor (never a candidate).
452
+ */
453
+ function bandForCosine(bands, cosine) {
454
+ for (const b of bands) {
455
+ if (cosine >= b.lo && cosine < b.hi)
456
+ return b;
457
+ }
458
+ return null;
459
+ }
460
+ /** An empty band-stat record (fresh accumulation starts from zeroes). */
461
+ function emptyBandStat() {
462
+ return { pairs: 0, merge: 0, corrects: 0, supersedes: 0, none: 0, merge_below_gate: 0, conf_sum: 0 };
463
+ }
464
+ /** Add a run's per-band counts into a cumulative record (in place). */
465
+ function accumulateBandStat(cumulative, run) {
466
+ cumulative.pairs += run.pairs;
467
+ cumulative.merge += run.merge;
468
+ cumulative.corrects += run.corrects;
469
+ cumulative.supersedes += run.supersedes;
470
+ cumulative.none += run.none;
471
+ cumulative.merge_below_gate += run.merge_below_gate;
472
+ cumulative.conf_sum += run.conf_sum;
473
+ if (run.metadata_skipped !== undefined) {
474
+ cumulative.metadata_skipped = (cumulative.metadata_skipped ?? 0) + run.metadata_skipped;
475
+ }
476
+ }
477
+ /**
478
+ * Find up to CORRECTION_NEIGHBOR_TOP_K OLDER neighbors for a candidate at/above
479
+ * minSimilarity, highest cosine first. NO shape filter on either side — a
480
+ * retraction riding inside an unrelated memory is exactly the pair this stage
481
+ * exists to catch. Absorbed neighbors are structurally absent (no vector row).
482
+ */
483
+ async function findOlderCorrectionNeighbors(db, candidate, embedFn, minSimilarity) {
484
+ const embedding = storage.getStoredEmbedding(db, candidate.id) ?? (await embedFn(candidate.content));
485
+ return storage
486
+ .vectorSearch(db, embedding, CORRECTION_NEIGHBOR_POOL, [candidate.id])
487
+ .filter((n) => n.created_at < candidate.created_at && (0, retrieval_js_1.l2ToCosine)(n.distance) >= minSimilarity)
488
+ .sort((a, b) => (0, retrieval_js_1.l2ToCosine)(b.distance) - (0, retrieval_js_1.l2ToCosine)(a.distance))
489
+ .slice(0, CORRECTION_NEIGHBOR_TOP_K);
490
+ }
491
+ async function classifyPair(llm, oldContent, newContent) {
492
+ try {
493
+ const r = await llm.completeClassify(buildCorrectionVerdictPrompt(oldContent, newContent));
494
+ return { verdict: parseCorrectionVerdict(r.text), usage: r.usage };
495
+ }
496
+ catch {
497
+ return { verdict: null, usage: undefined };
498
+ }
499
+ }
500
+ /**
501
+ * Nightly reconsolidation stage (#384, #392 — THE unified resolution stage).
502
+ *
503
+ * Phase 0 (#392): the deterministic merge zone (pairs >= the ceiling) runs
504
+ * first — LLM-free, budget-free, own lock/backup/cap.
505
+ *
506
+ * Scan: every memory with rowid > reconsolidationCursor (no shape filter;
507
+ * absorbed candidates are skipped — invisible memories are not re-judged).
508
+ * Each candidate's pairs: incoming explicit marks (verified once, AC7) then
509
+ * up-to-5 older KNN neighbors in [floor, ceiling) (verdict call per unlinked
510
+ * pair, AC2 — pairs at/above the ceiling are counted, never judged). Confirmed
511
+ * `corrects` pairs above the confidence gate on fact-shaped targets group by
512
+ * target into ONE rewrite call each (AC3); confirmed `merge` pairs queue for
513
+ * the merge phase; everything else is mark-only.
514
+ *
515
+ * Merge phase (#392): queued pairs merge through the dedup core under one
516
+ * lock/backup window, capped with the zone by dedupNightlyMaxMerges. A pair
517
+ * that cannot apply keeps both memories and holds the cursor.
518
+ *
519
+ * Cursor discipline mirrors stageSupersession: the cursor advances past a
520
+ * candidate once its neighbor set has been considered, regardless of infra
521
+ * skips — EXCEPT when rewrite groups or confirmed merges could not be applied
522
+ * (budget exhausted / rewrite-call infra error / merge cap or lock): the
523
+ * cursor then holds BELOW the earliest candidate contributing to the
524
+ * un-applied work, so those pairs are re-detected next run (dup-over-loss —
525
+ * an un-marked, un-rewritten, un-merged confirmed resolution must never be
526
+ * silently dropped by the cursor passing it).
527
+ *
528
+ * Dry-run: the zone's discovery + the free idempotency check only — zero LLM
529
+ * calls, zero writes, no cursor or band-stats persistence. Gate discovery is
530
+ * reported on every run (pairs_discovered / pairs_discovered_unlinked, #394) —
531
+ * on a dry-run they are the sizing numbers (pairs_evaluated stays 0: no calls
532
+ * are ever made).
533
+ */
534
+ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
535
+ // Config values pass through `unknown`-typed JSON — validate, never trust.
536
+ const validNumber = (v, fallback, ok) => {
537
+ const n = Number(v);
538
+ return Number.isFinite(n) && ok(n) ? n : fallback;
539
+ };
540
+ const minSimilarity = validNumber(options.minSimilarity, exports.DEFAULT_CORRECTION_MIN_SIMILARITY, (n) => n > 0 && n <= 1);
541
+ const rewriteMinConfidence = validNumber(options.rewriteMinConfidence, exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE, (n) => n > 0 && n <= 1);
542
+ const autoMergeThreshold = validNumber(options.autoMergeThreshold, dedup_js_1.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
543
+ const maxMerges = validNumber(options.maxMerges, dedup_js_1.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
544
+ const maxMinutes = validNumber(options.maxMinutes, exports.DEFAULT_RECONSOLIDATION_MAX_MINUTES, (n) => n >= 0);
545
+ const maxCalls = validNumber(options.maxCalls, exports.DEFAULT_RECONSOLIDATION_MAX_CALLS, (n) => n >= 0);
546
+ // ---- #401 runtime bounds. The wall-clock deadline is measured from stage
547
+ // start (the deterministic zone's runtime counts against it — the binding
548
+ // constraint must be THIS knob, never the process-level backstop).
549
+ const deadlineAt = maxMinutes > 0 ? Date.now() + Math.round(maxMinutes * 60_000) : Infinity;
550
+ const deadlineHit = () => Date.now() >= deadlineAt;
551
+ // ---- #392 phase 0: the deterministic merge zone (pairs >= the ceiling),
552
+ // LLM-free and budget-free — an LLM-less night still drains duplicates. Its
553
+ // own short lock window, pre-merge backup, and pacing cap; fail-soft, never
554
+ // a throw. Runs FIRST so the scan below never sees the pairs it owns.
555
+ const merges = await (0, dedup_js_1.runDeterministicMergeZone)(db, {
556
+ stateDir: stateDir ?? (0, paths_js_1.hicortexHome)(),
557
+ threshold: autoMergeThreshold,
558
+ maxMerges,
559
+ dryRun,
560
+ acquireLock: options.acquireLock,
561
+ });
562
+ // Per-run verdict statistics by cosine band (#392) — report snapshot here,
563
+ // cumulative series in state.json at stage end (never on dry-run).
564
+ const bands = buildResolutionBands(minSimilarity, autoMergeThreshold);
565
+ const runBands = new Map();
566
+ const recordBand = (cosine, action, confidence) => {
567
+ const band = bandForCosine(bands, cosine);
568
+ if (!band)
569
+ return; // below the floor — never a candidate (defensive)
570
+ const stat = runBands.get(band.label) ?? emptyBandStat();
571
+ stat.pairs++;
572
+ stat[action]++;
573
+ stat.conf_sum += confidence;
574
+ runBands.set(band.label, stat);
575
+ };
576
+ const startCursor = (0, state_js_1.loadState)(stateDir).reconsolidationCursor ?? 0;
577
+ // NO shape filter (AC2) — unlike stageSupersession. Absorbed rows are
578
+ // excluded: they are invisible to recall and must not re-enter judgment.
579
+ const rows = db
580
+ .prepare(`SELECT rowid AS __rowid, * FROM memories
581
+ WHERE rowid > ? AND COALESCE(status, '') != 'absorbed'
582
+ ORDER BY rowid ASC`)
583
+ .all(startCursor);
584
+ let scanned = 0;
585
+ let pairsEvaluated = 0;
586
+ let pairsDiscovered = 0; // #394: gate discovery — counted before any skip/judgment
587
+ let pairsDiscoveredUnlinked = 0;
588
+ let rewritten = 0;
589
+ let absorbed = 0;
590
+ let keptLinked = 0;
591
+ let markedSuperseded = 0;
592
+ let markedRetracted = 0;
593
+ let belowGate = 0;
594
+ let contractFailed = 0;
595
+ let skippedInfra = 0;
596
+ let skippedIdempotent = 0;
597
+ let explicitVerified = 0;
598
+ let explicitDivergent = 0;
599
+ let mergeBelowGate = 0;
600
+ let skippedAboveCeiling = 0;
601
+ let skippedMetadataMismatch = 0;
602
+ let mergePairsApplied = 0;
603
+ let cursor = startCursor;
604
+ const queuedMerges = [];
605
+ // #392 cursor-hold anchor, shared by the merge phase and the rewrite phase:
606
+ // un-applied work holds the cursor BELOW the earliest contributing
607
+ // candidate so the pairs are re-detected next run (dup-over-loss).
608
+ let pendingMinRowid = null;
609
+ // Links created by THIS stage in THIS run — lets the explicit-mark pass
610
+ // distinguish operator marks (pre-existing) from stage output.
611
+ const linksCreatedThisRun = new Set();
612
+ const markLink = (oldId, newId, relationship, strength) => {
613
+ storage.addLink(db, oldId, newId, relationship, strength);
614
+ linksCreatedThisRun.add(`${oldId}|${newId}`);
615
+ };
616
+ // ---- #401: mid-scan cursor persistence. Called at EVERY scan-loop exit
617
+ // path (deadline, call/budget cap, mark-verify budget stop, discovery
618
+ // failure) plus a every-K batch tick, so a killed run loses at most K-1
619
+ // candidates of scan progress instead of the whole night. The end-of-stage
620
+ // updateState below stays the authoritative final write (it also applies
621
+ // the pendingMinRowid hold — pendingMinRowid is only ever set AFTER the
622
+ // scan loop, so the raw cursor is the effective cursor at every call site
623
+ // here). updateState is load→mutate→temp-rename atomic.
624
+ let scanBatch = 0;
625
+ let callsUsed = 0;
626
+ let deadlineStopped = false;
627
+ let callCapStopped = false;
628
+ const persistCursor = () => {
629
+ if (dryRun)
630
+ return;
631
+ (0, state_js_1.updateState)((s) => {
632
+ s.reconsolidationCursor = cursor;
633
+ }, stateDir);
634
+ };
635
+ const groups = new Map();
636
+ const addTrigger = (target, trigger, confidence, cosine, explicit) => {
637
+ let group = groups.get(target.id);
638
+ if (!group) {
639
+ group = { targetId: target.id, target, triggers: [] };
640
+ groups.set(target.id, group);
641
+ }
642
+ if (!group.triggers.some((t) => t.id === trigger.id)) {
643
+ group.triggers.push({
644
+ id: trigger.id,
645
+ memory: trigger,
646
+ confidence,
647
+ cosine,
648
+ candidateRowid: trigger.__rowid,
649
+ explicit,
650
+ });
651
+ }
652
+ };
653
+ for (const candidate of rows) {
654
+ // #401: runtime bounds first — exit cleanly at the last fully-considered
655
+ // candidate boundary (cursor = the previous candidate's rowid here).
656
+ if (!dryRun && deadlineHit()) {
657
+ deadlineStopped = true;
658
+ persistCursor();
659
+ break;
660
+ }
661
+ if (!dryRun && budget.exhausted) {
662
+ persistCursor();
663
+ break;
664
+ }
665
+ // #401: the per-stage call cap stops the scan at the candidate boundary
666
+ // (the in-loop check below is the mid-candidate backstop — supersession
667
+ // mirrors both).
668
+ if (!dryRun && maxCalls > 0 && callsUsed >= maxCalls) {
669
+ callCapStopped = true;
670
+ persistCursor();
671
+ break;
672
+ }
673
+ scanned++;
674
+ // ---- AC7: verify incoming explicit marks (corrected_by/superseded_by
675
+ // links targeting this candidate) before they can join a rewrite group.
676
+ // #401: only OPERATOR marks are verified — applyExplicitMark writes
677
+ // strength 1.0, while every stage-created link carries a measured cosine
678
+ // strength < 1 (markLink sites + supersession). Without the filter, every
679
+ // prior night's stage output re-entered verification: a self-sustaining
680
+ // backlog that re-litigated settled verdicts forever.
681
+ if (!dryRun) {
682
+ const incoming = db
683
+ .prepare(`SELECT source_id, relationship FROM memory_links
684
+ WHERE target_id = ? AND source_id != ?
685
+ AND relationship IN ('corrected_by', 'superseded_by')
686
+ AND strength >= 1.0`)
687
+ .all(candidate.id, candidate.id);
688
+ let markBudgetStop = false;
689
+ for (const mark of incoming) {
690
+ if (linksCreatedThisRun.has(`${mark.source_id}|${candidate.id}`))
691
+ continue; // stage output, not a mark
692
+ if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL)) {
693
+ markBudgetStop = true;
694
+ break;
695
+ }
696
+ const target = storage.getMemory(db, mark.source_id);
697
+ if (!target || target.status === "absorbed") {
698
+ explicitDivergent++; // mark's target is gone/invisible — retain link, nothing to upgrade
699
+ continue;
700
+ }
701
+ const { verdict, usage } = await classifyPair(llm, target.content, candidate.content);
702
+ budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, usage);
703
+ callsUsed++; // #401
704
+ pairsEvaluated++;
705
+ if (!verdict) {
706
+ skippedInfra++; // mark retained; the neighborhood is revisited via newer candidacies
707
+ continue;
708
+ }
709
+ if (verdict.action === "corrects" && verdict.confidence >= rewriteMinConfidence && isFactShapedTarget(target)) {
710
+ addTrigger(target, candidate, verdict.confidence, null, true);
711
+ explicitVerified++;
712
+ }
713
+ else {
714
+ // Divergent: the nightly verdict did not confirm a rewrite. The mark
715
+ // is RETAINED untouched — explicit input is deliberate (owner
716
+ // decision 1), and marks are cheap to reverse via CLI.
717
+ explicitDivergent++;
718
+ console.log(`[hicortex] Reconsolidation: explicit mark on ${candidate.id.slice(0, 8)} diverged ` +
719
+ `(verdict ${verdict.action}, confidence ${verdict.confidence.toFixed(2)}) — mark retained`);
720
+ }
721
+ }
722
+ if (markBudgetStop) {
723
+ persistCursor(); // #401: this candidate's remaining marks re-verify next run
724
+ break;
725
+ }
726
+ }
727
+ // ---- AC2: detection pairs against older KNN neighbors.
728
+ let neighbors;
729
+ try {
730
+ neighbors = await findOlderCorrectionNeighbors(db, candidate, embedFn, minSimilarity);
731
+ }
732
+ catch (err) {
733
+ console.warn(`[hicortex] reconsolidation: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
734
+ cursor = candidate.__rowid;
735
+ persistCursor(); // #401: every exit path persists
736
+ continue;
737
+ }
738
+ for (const neighbor of neighbors) {
739
+ pairsDiscovered++; // every neighbor passed the floor gate
740
+ if (alreadyResolutionLinked(db, neighbor.id, candidate.id)) {
741
+ skippedIdempotent++;
742
+ continue;
743
+ }
744
+ pairsDiscoveredUnlinked++; // still unlinked — the actionable candidate
745
+ // #392: pairs at/above the ceiling belong to the deterministic zone —
746
+ // counted here, never LLM-judged (the zone merges them or holds them
747
+ // for its cap; re-detection is structural, not cursor-based).
748
+ const pairCosine = (0, retrieval_js_1.l2ToCosine)(neighbor.distance);
749
+ if (pairCosine >= autoMergeThreshold) {
750
+ skippedAboveCeiling++;
751
+ continue;
752
+ }
753
+ if (dryRun)
754
+ continue; // preview only — no LLM call, no write
755
+ // #401: the per-stage call cap rides the same boundary as the budget —
756
+ // supersession-stage pattern (consolidate.ts stageSupersession).
757
+ if ((maxCalls > 0 && callsUsed >= maxCalls) || !budget.use(exports.RECONSOLIDATION_STAGE_LABEL)) {
758
+ callCapStopped = maxCalls > 0 && callsUsed >= maxCalls;
759
+ persistCursor(); // cursor still points at the last fully-considered candidate
760
+ break;
761
+ }
762
+ const { verdict, usage } = await classifyPair(llm, neighbor.content, candidate.content);
763
+ budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, usage);
764
+ callsUsed++; // #401
765
+ pairsEvaluated++;
766
+ if (!verdict) {
767
+ skippedInfra++;
768
+ continue;
769
+ }
770
+ recordBand(pairCosine, verdict.action, verdict.confidence);
771
+ // #392: a merge verdict is queued for the merge phase (below) — no
772
+ // link, no write here. Below the confidence gate BOTH memories stay
773
+ // live: a weak mark is recoverable, and there is nothing to mark for a
774
+ // duplicate — keeping both is the recoverable outcome.
775
+ if (verdict.action === "merge") {
776
+ if (verdict.confidence < rewriteMinConfidence) {
777
+ mergeBelowGate++;
778
+ const band = bandForCosine(bands, pairCosine);
779
+ if (band) {
780
+ const stat = runBands.get(band.label) ?? emptyBandStat();
781
+ stat.merge_below_gate++;
782
+ runBands.set(band.label, stat);
783
+ }
784
+ }
785
+ else {
786
+ queuedMerges.push({ oldId: neighbor.id, newId: candidate.id, candidateRowid: candidate.__rowid });
787
+ }
788
+ continue;
789
+ }
790
+ if (verdict.action === "supersedes") {
791
+ markLink(neighbor.id, candidate.id, "superseded_by", (0, retrieval_js_1.l2ToCosine)(neighbor.distance));
792
+ storage.updateMemory(db, neighbor.id, { status: "superseded" });
793
+ markedSuperseded++;
794
+ console.log(`[hicortex] Reconsolidation: ${neighbor.id.slice(0, 8)} superseded_by ${candidate.id.slice(0, 8)} (mark-only)`);
795
+ continue;
796
+ }
797
+ if (verdict.action === "corrects") {
798
+ const cosine = pairCosine;
799
+ if (verdict.confidence < rewriteMinConfidence) {
800
+ // Below the gate: mark-only, never rewrite. The
801
+ // trigger stays live — it is the only carrier of the correction.
802
+ belowGate++;
803
+ markLink(neighbor.id, candidate.id, "corrected_by", cosine);
804
+ storage.updateMemory(db, neighbor.id, { status: "retracted" });
805
+ markedRetracted++;
806
+ continue;
807
+ }
808
+ if (!isFactShapedTarget(neighbor)) {
809
+ // Decisions/plans/experiences are history, not error — mark only.
810
+ markLink(neighbor.id, candidate.id, "corrected_by", cosine);
811
+ storage.updateMemory(db, neighbor.id, { status: "retracted" });
812
+ markedRetracted++;
813
+ continue;
814
+ }
815
+ addTrigger(neighbor, candidate, verdict.confidence, cosine, false);
816
+ }
817
+ // verdict "none" → nothing to do
818
+ }
819
+ cursor = candidate.__rowid;
820
+ // #401: batch-tick persistence every K candidates — a kill between exit
821
+ // paths loses at most K-1 candidates of scan progress.
822
+ if (!dryRun && ++scanBatch >= RECONSOLIDATION_CURSOR_PERSIST_EVERY) {
823
+ persistCursor();
824
+ scanBatch = 0;
825
+ }
826
+ }
827
+ if (deadlineStopped) {
828
+ console.log(`[hicortex] Reconsolidation: wall-clock deadline reached (reconsolidationMaxMinutes) — ` +
829
+ `scan stopped at cursor ${cursor}; the next run resumes from there`);
830
+ }
831
+ else if (callCapStopped) {
832
+ console.log(`[hicortex] Reconsolidation: per-run call cap reached (reconsolidationMaxCalls) — ` +
833
+ `scan stopped at cursor ${cursor}; the next run resumes from there`);
834
+ }
835
+ // ---- #392 judged-merge phase: apply the queued pair merges through the
836
+ // dedup core (mergeMemoryIds — same canonical pick, link re-points,
837
+ // dedup_log, absorb). One short lock/backup window for the whole batch, one
838
+ // transaction per pair. Zone merge operations count against the SAME
839
+ // dedupNightlyMaxMerges cap. A pair that cannot apply (cap exhausted, busy
840
+ // lock, failed backup) keeps BOTH memories live and holds the cursor below
841
+ // its candidate — a confirmed merge is never silently dropped by the cursor
842
+ // passing it (dup-over-loss). A metadata-rail refusal is different: the
843
+ // verdict WAS rendered, both memories stay live, the cursor advances.
844
+ const zoneOpsUsed = merges.merged_clusters + merges.failed;
845
+ let mergeOpsRemaining = maxMerges > 0 ? Math.max(0, maxMerges - zoneOpsUsed) : 0;
846
+ let mergePairsDeferred = 0;
847
+ if (!dryRun && queuedMerges.length > 0) {
848
+ const holdQueued = (from) => {
849
+ for (let i = from; i < queuedMerges.length; i++) {
850
+ pendingMinRowid =
851
+ pendingMinRowid === null
852
+ ? queuedMerges[i].candidateRowid
853
+ : Math.min(pendingMinRowid, queuedMerges[i].candidateRowid);
854
+ }
855
+ };
856
+ if (maxMerges === 0) {
857
+ // Machinery disabled by config: keep both (counted in band_stats as
858
+ // merge verdicts) and ADVANCE — holding the cursor would re-judge the
859
+ // same pairs into the same disabled state forever.
860
+ console.log(`[hicortex] Reconsolidation: ${queuedMerges.length} confirmed merge(s) kept — ` +
861
+ `dedupNightlyMaxMerges is 0 (merge machinery disabled)`);
862
+ }
863
+ else if (mergeOpsRemaining <= 0) {
864
+ mergePairsDeferred = queuedMerges.length;
865
+ holdQueued(0); // zone consumed the whole cap — retry next run
866
+ console.log(`[hicortex] Reconsolidation: ${mergePairsDeferred} confirmed merge(s) deferred — ` +
867
+ `dedupNightlyMaxMerges exhausted by the deterministic zone`);
868
+ }
869
+ else {
870
+ const acquire = options.acquireLock ?? capture_js_1.acquireCaptureLock;
871
+ const release = await acquire(stateDir ?? (0, paths_js_1.hicortexHome)(), 0);
872
+ if (!release) {
873
+ mergePairsDeferred = queuedMerges.length;
874
+ holdQueued(0); // a busy capture run defers the batch — fail-soft
875
+ console.warn(`[hicortex] Reconsolidation: capture lock busy — ${mergePairsDeferred} confirmed merge(s) deferred to next run`);
876
+ }
877
+ else {
878
+ try {
879
+ let backupOk = true;
880
+ try {
881
+ await (0, dedup_js_1.takePreDedupBackup)(db, stateDir ?? (0, paths_js_1.hicortexHome)());
882
+ }
883
+ catch (err) {
884
+ backupOk = false;
885
+ console.error(`[hicortex] Reconsolidation: pre-merge backup failed ` +
886
+ `(${err instanceof Error ? err.message : String(err)}) — ${queuedMerges.length} merge(s) deferred`);
887
+ }
888
+ if (backupOk) {
889
+ for (let i = 0; i < queuedMerges.length; i++) {
890
+ const pair = queuedMerges[i];
891
+ if (mergeOpsRemaining <= 0) {
892
+ mergePairsDeferred = queuedMerges.length - i;
893
+ holdQueued(i); // cap exhausted mid-batch — the rest retry next run
894
+ console.log(`[hicortex] Reconsolidation: ${mergePairsDeferred} confirmed merge(s) deferred — dedupNightlyMaxMerges exhausted`);
895
+ break;
896
+ }
897
+ const result = (0, dedup_js_1.mergeMemoryIds)(db, [pair.oldId, pair.newId]);
898
+ if (result.ok) {
899
+ mergePairsApplied++;
900
+ mergeOpsRemaining--;
901
+ console.log(`[hicortex] Reconsolidation: merged ${pair.oldId.slice(0, 8)} + ${pair.newId.slice(0, 8)} ` +
902
+ `into canonical ${result.canonicalId.slice(0, 8)} (${result.linksRepointed} link(s) re-pointed)`);
903
+ }
904
+ else if (result.reason === "metadata_mismatch") {
905
+ skippedMetadataMismatch++;
906
+ console.log(`[hicortex] Reconsolidation: merge of ${pair.oldId.slice(0, 8)} + ${pair.newId.slice(0, 8)} ` +
907
+ `skipped (metadata mismatch) — both kept`);
908
+ }
909
+ // "no_members": a member vanished/was absorbed since the
910
+ // verdict — nothing to merge, nothing to hold; the cursor
911
+ // advances past it.
912
+ }
913
+ }
914
+ else {
915
+ mergePairsDeferred = queuedMerges.length;
916
+ holdQueued(0);
917
+ }
918
+ }
919
+ finally {
920
+ release();
921
+ }
922
+ }
923
+ }
924
+ }
925
+ // ---- Rewrite phase (AC3/AC4/AC5). Three sub-phases so the multi-target
926
+ // keep rule can be honored: (R1) collect contracts, (R2) resolve every
927
+ // trigger's FINAL disposition across all groups, (R3) apply one transaction
928
+ // per group. A group whose rewrite call was never made (budget/infra) is
929
+ // left untouched and holds the cursor — never partially applied.
930
+ const contracts = new Map(); // null = contract failed
931
+ // pendingMinRowid (min candidate rowid among un-applied work) is declared
932
+ // above — shared with the merge phase's holdQueued.
933
+ const deferFrom = (fromTargetId) => {
934
+ let seen = false;
935
+ for (const group of groups.values()) {
936
+ if (!seen && group.targetId !== fromTargetId)
937
+ continue;
938
+ seen = true;
939
+ for (const t of group.triggers) {
940
+ pendingMinRowid = pendingMinRowid === null ? t.candidateRowid : Math.min(pendingMinRowid, t.candidateRowid);
941
+ }
942
+ }
943
+ };
944
+ if (!dryRun && groups.size > 0) {
945
+ for (const group of groups.values()) {
946
+ // #401: the bounds stop the rewrite phase too — a group whose rewrite
947
+ // call was never made is left untouched and holds the cursor (never
948
+ // partially applied), exactly like the budget-exhausted path below.
949
+ if (deadlineHit()) {
950
+ deadlineStopped = true;
951
+ deferFrom(group.targetId);
952
+ break;
953
+ }
954
+ if (maxCalls > 0 && callsUsed >= maxCalls) {
955
+ callCapStopped = true;
956
+ deferFrom(group.targetId);
957
+ break;
958
+ }
959
+ if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL)) {
960
+ deferFrom(group.targetId);
961
+ break;
962
+ }
963
+ const triggersArg = group.triggers.map((t) => ({ id: t.id, content: t.memory.content }));
964
+ let contract = null;
965
+ let infraError = false;
966
+ try {
967
+ const r = await llm.completeClassify(buildRewritePrompt(group.target.content, triggersArg));
968
+ contract = parseRewriteReply(r.text, group.triggers.map((t) => t.id), group.target.content);
969
+ budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, r.usage);
970
+ callsUsed++; // #401: rewrite contracts count toward the stage call cap
971
+ }
972
+ catch {
973
+ infraError = true;
974
+ }
975
+ if (infraError) {
976
+ skippedInfra++;
977
+ deferFrom(group.targetId); // group NOT marked, NOT rewritten — retried next run
978
+ break;
979
+ }
980
+ contracts.set(group.targetId, contract);
981
+ if (!contract)
982
+ contractFailed++;
983
+ }
984
+ // R2: final per-trigger disposition — a trigger in multiple groups is
985
+ // absorbed only if EVERY disposition says absorb (any keep keeps it).
986
+ const finalOutcome = new Map();
987
+ for (const contract of contracts.values()) {
988
+ if (!contract)
989
+ continue;
990
+ for (const t of contract.triggers) {
991
+ if (t.disposition === "keep" || finalOutcome.get(t.id) === "keep")
992
+ finalOutcome.set(t.id, "keep");
993
+ else
994
+ finalOutcome.set(t.id, "absorb");
995
+ }
996
+ }
997
+ // R3: apply (one transaction per group). An apply that fails mid-flight
998
+ // (embed error, DB error) writes NOTHING (the transaction never ran) —
999
+ // the group is deferred like a pending one so it retries next run.
1000
+ const appliedOutcome = new Map();
1001
+ const deferGroup = (group) => {
1002
+ for (const t of group.triggers) {
1003
+ pendingMinRowid = pendingMinRowid === null ? t.candidateRowid : Math.min(pendingMinRowid, t.candidateRowid);
1004
+ }
1005
+ };
1006
+ for (const group of groups.values()) {
1007
+ const contract = contracts.get(group.targetId);
1008
+ if (contract === undefined)
1009
+ continue; // pending group — untouched this run
1010
+ if (contract === null) {
1011
+ // Failed rewrite contract → whole group mark-only, never a partial
1012
+ // apply. Content untouched, NO trigger absorbed.
1013
+ try {
1014
+ applyMarkOnlyGroup(db, group);
1015
+ }
1016
+ catch (err) {
1017
+ console.warn(`[hicortex] reconsolidation: mark-only fallback failed for ${group.targetId.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
1018
+ skippedInfra++;
1019
+ deferGroup(group);
1020
+ continue;
1021
+ }
1022
+ markedRetracted++;
1023
+ console.log(`[hicortex] Reconsolidation: rewrite contract failed for ${group.targetId.slice(0, 8)} — group degraded to mark-only`);
1024
+ continue;
1025
+ }
1026
+ let applied = false;
1027
+ try {
1028
+ applied = await applyRewriteGroup(db, group, contract, finalOutcome, embedFn);
1029
+ }
1030
+ catch (err) {
1031
+ console.warn(`[hicortex] reconsolidation: rewrite apply failed for ${group.targetId.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
1032
+ }
1033
+ if (!applied) {
1034
+ skippedInfra++; // defensive absorbed-target guard, or an apply error — retry next run
1035
+ deferGroup(group);
1036
+ continue;
1037
+ }
1038
+ rewritten++;
1039
+ // Counted from APPLIED groups only (a deferred group's dispositions
1040
+ // never took effect); a trigger in several applied groups counts once.
1041
+ for (const t of contract.triggers) {
1042
+ const outcome = finalOutcome.get(t.id) ?? "keep";
1043
+ if (outcome === "keep" || appliedOutcome.get(t.id) === "keep")
1044
+ appliedOutcome.set(t.id, "keep");
1045
+ else
1046
+ appliedOutcome.set(t.id, "absorb");
1047
+ }
1048
+ }
1049
+ for (const outcome of appliedOutcome.values()) {
1050
+ if (outcome === "absorb")
1051
+ absorbed++;
1052
+ else
1053
+ keptLinked++;
1054
+ }
1055
+ }
1056
+ // Cursor hold: un-applied work (rewrite groups, confirmed merges) holds the
1057
+ // cursor BELOW its earliest contributing candidate so the pairs are
1058
+ // re-detected next run.
1059
+ if (pendingMinRowid !== null) {
1060
+ cursor = Math.min(cursor, pendingMinRowid - 1);
1061
+ }
1062
+ // Report snapshot: the deterministic band (from the zone's own numbers —
1063
+ // losers are merge verdicts at confidence 1.0; the zone persists the
1064
+ // cumulative copy itself) plus this run's judged bands.
1065
+ const bandStats = {};
1066
+ if (merges.max_merges > 0) {
1067
+ const det = emptyBandStat();
1068
+ det.pairs = merges.losers_merged;
1069
+ det.merge = merges.losers_merged;
1070
+ det.conf_sum = merges.losers_merged;
1071
+ if (merges.skipped_metadata_mismatch > 0) {
1072
+ det.metadata_skipped = merges.skipped_metadata_mismatch;
1073
+ }
1074
+ bandStats[`>=${autoMergeThreshold}`] = det;
1075
+ }
1076
+ for (const [label, stat] of runBands)
1077
+ bandStats[label] = stat;
1078
+ if (!dryRun) {
1079
+ // #401: the authoritative FINAL cursor write — the mid-scan persists
1080
+ // above are checkpoints; this one also applies the pendingMinRowid hold.
1081
+ (0, state_js_1.updateState)((s) => {
1082
+ s.reconsolidationCursor = cursor;
1083
+ // Cumulative judged-band accumulation (#392) — the zone already
1084
+ // persisted the deterministic band under its own label.
1085
+ if (runBands.size > 0) {
1086
+ const cumulative = s.resolutionBandStats ?? {};
1087
+ for (const [label, run] of runBands) {
1088
+ const b = cumulative[label] ?? emptyBandStat();
1089
+ accumulateBandStat(b, run);
1090
+ cumulative[label] = b;
1091
+ }
1092
+ s.resolutionBandStats = cumulative;
1093
+ }
1094
+ }, stateDir);
1095
+ }
1096
+ if (rows.length > 0 || groups.size > 0 || mergePairsApplied > 0 || mergeBelowGate > 0) {
1097
+ console.log(`[hicortex] Reconsolidation: ${scanned} scanned, ${pairsEvaluated} pairs evaluated, ` +
1098
+ `${rewritten} rewritten (${absorbed} triggers absorbed, ${keptLinked} kept), ` +
1099
+ `${mergePairsApplied} pair(s) merged, ${markedSuperseded} superseded, ` +
1100
+ `${markedRetracted} retracted (${belowGate} below gate, ${mergeBelowGate} merge below gate, ` +
1101
+ `${contractFailed} contract failed), ${skippedInfra} infra-skipped, ${skippedIdempotent} ` +
1102
+ `already-linked, ${skippedAboveCeiling} above ceiling, ${explicitVerified} explicit verified, ` +
1103
+ `${explicitDivergent} explicit divergent (cursor ${cursor})`);
1104
+ }
1105
+ return {
1106
+ scanned,
1107
+ pairs_evaluated: pairsEvaluated,
1108
+ pairs_discovered: pairsDiscovered,
1109
+ pairs_discovered_unlinked: pairsDiscoveredUnlinked,
1110
+ rewritten,
1111
+ absorbed,
1112
+ kept_linked: keptLinked,
1113
+ marked_superseded: markedSuperseded,
1114
+ marked_retracted: markedRetracted,
1115
+ below_gate: belowGate,
1116
+ contract_failed: contractFailed,
1117
+ skipped_infra: skippedInfra,
1118
+ skipped_idempotent: skippedIdempotent,
1119
+ explicit_verified: explicitVerified,
1120
+ explicit_divergent: explicitDivergent,
1121
+ cursor,
1122
+ merges,
1123
+ merge_pairs_applied: mergePairsApplied,
1124
+ merge_below_gate: mergeBelowGate,
1125
+ skipped_above_ceiling: skippedAboveCeiling,
1126
+ skipped_metadata_mismatch: skippedMetadataMismatch,
1127
+ band_stats: bandStats,
1128
+ };
1129
+ }
1130
+ /** Mark-only fallback for a group: links + retracted status, content + triggers untouched. */
1131
+ function applyMarkOnlyGroup(db, group) {
1132
+ const tx = db.transaction(() => {
1133
+ for (const t of group.triggers) {
1134
+ if (!hasLink(db, group.targetId, t.id, "corrected_by")) {
1135
+ storage.addLink(db, group.targetId, t.id, "corrected_by", t.cosine ?? 1.0);
1136
+ }
1137
+ }
1138
+ storage.updateMemory(db, group.targetId, { status: "retracted" });
1139
+ });
1140
+ tx();
1141
+ }
1142
+ /**
1143
+ * Apply one rewrite group in ONE transaction (AC3): memory_history row, target
1144
+ * content + status `corrected`, vector replaced, tags cleared + domain NULL
1145
+ * (re-classified next nightly), corrected_by link per trigger, per-trigger
1146
+ * disposition (absorb → status + vector + FTS dropped; keep → untouched).
1147
+ */
1148
+ async function applyRewriteGroup(db, group, contract, finalOutcome, embedFn) {
1149
+ // Defensive (#384): never rewrite an absorbed row (it has no vector/FTS
1150
+ // row — a rewrite would resurrect dead evidence). Unreachable via the
1151
+ // normal paths (absorbed targets are neither neighbors — no vector — nor
1152
+ // explicit-mark targets — rejected at ingest); counted skipped_infra by
1153
+ // the caller when hit.
1154
+ const currentTarget = storage.getMemory(db, group.targetId);
1155
+ if (!currentTarget || currentTarget.status === "absorbed")
1156
+ return false;
1157
+ const target = group.target; // content snapshot the verdicts judged
1158
+ // evidence_id = the latest-created trigger of the group — the same trigger
1159
+ // the footer date derives from (documented semantics, #384).
1160
+ const latest = [...group.triggers].sort((a, b) => a.memory.created_at === b.memory.created_at
1161
+ ? a.id.localeCompare(b.id)
1162
+ : a.memory.created_at.localeCompare(b.memory.created_at))[group.triggers.length - 1];
1163
+ const dateISO = latest.memory.created_at.slice(0, 10);
1164
+ const footer = buildCorrectionFooter(target.content, dateISO);
1165
+ const newContent = `${contract.rewritten}\n\n${footer}`;
1166
+ // Embeddings are async — computed BEFORE the sync transaction.
1167
+ const newEmbedding = await embedFn(newContent);
1168
+ const triggersJson = JSON.stringify(group.triggers.map((t) => ({
1169
+ id: t.id,
1170
+ disposition: finalOutcome.get(t.id) ?? "keep",
1171
+ confidence: t.confidence,
1172
+ })));
1173
+ // History confidence: the group's MINIMUM trigger confidence — the weakest
1174
+ // verdict that gated this rewrite (conservative audit).
1175
+ const minConfidence = Math.min(...group.triggers.map((t) => t.confidence));
1176
+ const tx = db.transaction(() => {
1177
+ db.prepare(`INSERT INTO memory_history
1178
+ (memory_id, old_content, new_content, prev_status, new_status, triggers_json,
1179
+ evidence_id, confidence, cause, created_at)
1180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(target.id, target.content, newContent,
1181
+ // prev_status from the LIVE row, not the detection snapshot — a
1182
+ // below-gate mark earlier in this same run may have retracted it.
1183
+ currentTarget.status ?? null, "corrected", triggersJson, latest.id, minConfidence, "reconsolidation", nowIso());
1184
+ // Content + status + domain NULL in one UPDATE (fires the FTS update
1185
+ // trigger — the target still HAS an FTS row).
1186
+ storage.updateMemory(db, target.id, { content: newContent, status: "corrected", domain: null });
1187
+ db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(target.id);
1188
+ replaceMemoryVector(db, target.id, newEmbedding);
1189
+ for (const t of group.triggers) {
1190
+ if (!hasLink(db, target.id, t.id, "corrected_by")) {
1191
+ storage.addLink(db, target.id, t.id, "corrected_by", t.cosine ?? 1.0);
1192
+ }
1193
+ }
1194
+ for (const t of group.triggers) {
1195
+ if (finalOutcome.get(t.id) === "absorb")
1196
+ (0, exports.absorbTrigger)(db, t.id);
1197
+ }
1198
+ });
1199
+ tx();
1200
+ console.log(`[hicortex] Reconsolidation: rewrote ${target.id.slice(0, 8)} (corrected; ` +
1201
+ `${group.triggers.length} trigger(s), evidence ${latest.id.slice(0, 8)})`);
1202
+ return true;
1203
+ }
1204
+ /**
1205
+ * Roll back ONE rewrite history row: restore the recorded prior content and
1206
+ * prior status via the same mechanics as the rewrite (re-embed, clear tags +
1207
+ * domain NULL), reverse every absorb recorded in triggers_json (status
1208
+ * restored, vector re-embedded, FTS row re-inserted), and write the rollback's
1209
+ * own history row (cause `rollback`).
1210
+ *
1211
+ * Newest-first discipline: the row must be the NEWEST history entry for its
1212
+ * memory (rolling back an older entry under a newer one would clobber — undo
1213
+ * the newest first). The rollback row itself can be rolled back (undo the
1214
+ * undo), which is what makes the chain navigable.
1215
+ */
1216
+ async function rollbackHistoryRow(db, historyRowId, embedFn) {
1217
+ const row = getHistoryRow(db, historyRowId);
1218
+ if (!row) {
1219
+ throw new Error(`history row not found: ${historyRowId}`);
1220
+ }
1221
+ const newer = db
1222
+ .prepare("SELECT COUNT(*) AS n FROM memory_history WHERE memory_id = ? AND id > ?")
1223
+ .get(row.memory_id, historyRowId).n;
1224
+ if (newer > 0) {
1225
+ throw new Error(`history row ${historyRowId} is not the newest entry for memory ${row.memory_id.slice(0, 8)} — ` +
1226
+ `roll back the newest entry first (history rows are undone newest-first)`);
1227
+ }
1228
+ const mem = storage.getMemory(db, row.memory_id);
1229
+ if (!mem) {
1230
+ throw new Error(`memory ${row.memory_id} no longer exists — nothing to roll back`);
1231
+ }
1232
+ let triggers = [];
1233
+ if (row.triggers_json) {
1234
+ try {
1235
+ triggers = JSON.parse(row.triggers_json);
1236
+ }
1237
+ catch {
1238
+ triggers = []; // unreadable trigger record — un-absorb impossible, content restore still proceeds
1239
+ }
1240
+ }
1241
+ // Async embedding work BEFORE the sync transaction: the restored target
1242
+ // content + every currently-absorbed trigger's (untouched) content.
1243
+ const restoredEmbedding = await embedFn(row.old_content);
1244
+ const unabsorbEmbeddings = new Map();
1245
+ for (const t of triggers) {
1246
+ // Disposition values are "absorb"/"keep" (the rewrite contract's
1247
+ // vocabulary) — distinct from the STATUS "absorbed" written on the row.
1248
+ if (t.disposition !== "absorb")
1249
+ continue;
1250
+ const triggerMem = storage.getMemory(db, t.id);
1251
+ if (triggerMem && triggerMem.status === "absorbed") {
1252
+ unabsorbEmbeddings.set(t.id, await embedFn(triggerMem.content));
1253
+ }
1254
+ }
1255
+ const tx = db.transaction(() => {
1256
+ const result = db
1257
+ .prepare(`INSERT INTO memory_history
1258
+ (memory_id, old_content, new_content, prev_status, new_status, triggers_json,
1259
+ evidence_id, confidence, cause, created_at)
1260
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1261
+ .run(row.memory_id, mem.content, row.old_content, mem.status ?? null, row.prev_status, row.triggers_json, row.evidence_id, row.confidence, "rollback", nowIso());
1262
+ storage.updateMemory(db, row.memory_id, {
1263
+ content: row.old_content,
1264
+ status: row.prev_status ?? null,
1265
+ domain: null,
1266
+ });
1267
+ db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(row.memory_id);
1268
+ replaceMemoryVector(db, row.memory_id, restoredEmbedding);
1269
+ for (const [triggerId, embedding] of unabsorbEmbeddings) {
1270
+ unabsorbTrigger(db, triggerId, embedding);
1271
+ }
1272
+ return result.lastInsertRowid;
1273
+ });
1274
+ const newHistoryRowId = tx();
1275
+ console.log(`[hicortex] history: rolled back row ${historyRowId} — memory ${row.memory_id.slice(0, 8)} restored ` +
1276
+ `to its prior content/status, ${unabsorbEmbeddings.size} trigger(s) un-absorbed ` +
1277
+ `(new history row ${newHistoryRowId})`);
1278
+ return {
1279
+ historyRowId,
1280
+ memoryId: row.memory_id,
1281
+ restoredStatus: row.prev_status ?? null,
1282
+ unabsorbed: [...unabsorbEmbeddings.keys()],
1283
+ newHistoryRowId,
1284
+ };
1285
+ }
1286
+ function head(text, max = 100) {
1287
+ const single = text.replace(/\s+/g, " ").trim();
1288
+ return single.length > max ? `${single.slice(0, max)}…` : single;
1289
+ }
1290
+ /** List one memory's rewrite events (dates, before/after heads, dispositions, confidence). */
1291
+ function printHistory(rows, memoryId) {
1292
+ if (rows.length === 0) {
1293
+ console.log(`[hicortex] No history recorded for memory ${memoryId.slice(0, 8)}.`);
1294
+ return;
1295
+ }
1296
+ console.log(`[hicortex] History for memory ${memoryId.slice(0, 8)} (${rows.length} event(s), oldest first):`);
1297
+ for (const r of rows) {
1298
+ console.log(` #${r.id} ${r.created_at} cause=${r.cause} ${r.prev_status ?? "active"} → ${r.new_status ?? "active"}`);
1299
+ console.log(` before: ${head(r.old_content)}`);
1300
+ console.log(` after: ${head(r.new_content)}`);
1301
+ if (r.confidence !== null && r.confidence !== undefined) {
1302
+ console.log(` confidence: ${r.confidence}`);
1303
+ }
1304
+ if (r.triggers_json) {
1305
+ try {
1306
+ const triggers = JSON.parse(r.triggers_json);
1307
+ for (const t of triggers) {
1308
+ console.log(` trigger ${t.id.slice(0, 8)}: ${t.disposition}`);
1309
+ }
1310
+ }
1311
+ catch {
1312
+ console.log(` triggers: (unreadable record)`);
1313
+ }
1314
+ }
1315
+ if (r.evidence_id)
1316
+ console.log(` evidence: ${r.evidence_id.slice(0, 8)}`);
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Runner for `hicortex history <id>` / `hicortex history --rollback <n>`.
1321
+ * Returns a process exit code (0 success, 1 failure); throws only on
1322
+ * unexpected infra errors (cli.ts prints those).
1323
+ */
1324
+ async function runHistoryCommand(options) {
1325
+ if (options.rollbackId === undefined && !options.memoryId) {
1326
+ console.error("[hicortex] history: pass a memory id to list its history, or --rollback <history-row-id>.");
1327
+ return 1;
1328
+ }
1329
+ const db = (0, db_js_1.initDb)((0, db_js_1.resolveDbPath)(options.dbPath));
1330
+ try {
1331
+ if (options.rollbackId !== undefined) {
1332
+ // Lazy-load the ONNX embedder ONLY when a rollback actually needs to
1333
+ // re-embed (listing never loads it).
1334
+ const embedFn = options.embedFn ?? (await import("./embedder.js")).embed;
1335
+ await rollbackHistoryRow(db, options.rollbackId, embedFn);
1336
+ return 0;
1337
+ }
1338
+ const resolved = storage.resolveMemoryId(db, options.memoryId);
1339
+ if (!resolved) {
1340
+ console.error(`[hicortex] history: memory not found or ambiguous: ${options.memoryId}`);
1341
+ return 1;
1342
+ }
1343
+ printHistory(getMemoryHistory(db, resolved), resolved);
1344
+ return 0;
1345
+ }
1346
+ finally {
1347
+ db.close();
1348
+ }
1349
+ }