@gamaze/hicortex 0.20.5 → 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.
@@ -1402,6 +1402,8 @@ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1402
1402
  return {
1403
1403
  scanned: 0,
1404
1404
  pairs_evaluated: 0,
1405
+ pairs_discovered: 0, // #394: the scan doesn't run on a quiet night — nothing discovered
1406
+ pairs_discovered_unlinked: 0,
1405
1407
  rewritten: 0,
1406
1408
  absorbed: 0,
1407
1409
  kept_linked: 0,
package/dist/nightly.js CHANGED
@@ -748,6 +748,12 @@ async function runNightly(options = {}) {
748
748
  autoMergeThreshold: (savedConfig?.dedupAutoMergeThreshold ??
749
749
  savedConfig?.dedupMergeThreshold),
750
750
  maxMerges: savedConfig?.dedupNightlyMaxMerges,
751
+ // #401 runtime bounds: the wall-clock deadline (default 120
752
+ // min, 0 disables) and the per-run classify-call ceiling
753
+ // (default 600, 0 disables). Same posture — the stage
754
+ // validates and falls back on invalid/absent values.
755
+ maxMinutes: savedConfig?.reconsolidationMaxMinutes,
756
+ maxCalls: savedConfig?.reconsolidationMaxCalls,
751
757
  });
752
758
  console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
753
759
  (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
@@ -57,6 +57,24 @@ export declare const DEFAULT_CORRECTION_MIN_SIMILARITY = 0.75;
57
57
  * weak rewrite is corruption.
58
58
  */
59
59
  export declare const DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE = 0.8;
60
+ /**
61
+ * Default wall-clock bound for the stage, in minutes (#401). Checked at the
62
+ * top of the candidate scan loop (and before each rewrite contract call); on
63
+ * expiry the scan breaks cleanly at the last fully-considered candidate and
64
+ * the next run resumes from the persisted cursor. 120 sits safely under any
65
+ * sane process-level nightly timeout. 0 disables the bound. Invalid →
66
+ * default.
67
+ */
68
+ export declare const DEFAULT_RECONSOLIDATION_MAX_MINUTES = 120;
69
+ /**
70
+ * Default per-run classify-call ceiling for the stage (#401) — the
71
+ * supersessionMaxCalls pattern with a NON-ZERO default ON PURPOSE: that
72
+ * knob's 0=unlimited default is what let the first full-corpus pass grow
73
+ * unbounded. Counts EVERY classify-tier call the stage makes (mark
74
+ * verifications, pair verdicts, rewrite contracts). 0 disables the cap.
75
+ * Invalid → default.
76
+ */
77
+ export declare const DEFAULT_RECONSOLIDATION_MAX_CALLS = 600;
60
78
  /** Head of the old content quoted in the provenance footer. */
61
79
  export declare const FOOTER_HEAD_MAX_CHARS = 160;
62
80
  /** The code-defined status vocabulary (see module doc). Not user-configurable. */
@@ -91,6 +109,24 @@ export interface ReconsolidationOptions {
91
109
  * pair merges against ONE cap. Invalid → default.
92
110
  */
93
111
  maxMerges?: number;
112
+ /**
113
+ * reconsolidationMaxMinutes (config; default 120; 0 disables) — wall-clock
114
+ * deadline for the stage (#401), measured from stage start. Checked at the
115
+ * top of the candidate scan loop and before each rewrite contract call; on
116
+ * expiry the scan breaks cleanly — the cursor already points at the last
117
+ * fully-considered candidate, so the run ends consistent and the next
118
+ * nightly resumes from it. Invalid → default.
119
+ */
120
+ maxMinutes?: number;
121
+ /**
122
+ * reconsolidationMaxCalls (config; default 600; 0 disables) — per-run
123
+ * ceiling on classify-tier calls for the stage (#401), the
124
+ * supersessionMaxCalls pattern with a NON-ZERO default (the 0=unlimited
125
+ * default there is what removed the last per-stage bound). Exhaustion
126
+ * mid-neighbor-loop or mid-rewrite-phase stops/defers cleanly at the
127
+ * current candidate boundary. Invalid → default.
128
+ */
129
+ maxCalls?: number;
94
130
  /**
95
131
  * Capture-lock acquirer override (tests) — the deterministic zone and the
96
132
  * judged-merge phase each hold a short lock window. Defaults to the real
@@ -98,7 +134,7 @@ export interface ReconsolidationOptions {
98
134
  */
99
135
  acquireLock?: typeof acquireCaptureLock;
100
136
  }
101
- /** The 14-field stage report (typed once, in ConsolidationReport). */
137
+ /** The stage report (typed once, in ConsolidationReport — field list there). */
102
138
  export type ReconsolidationStageResult = NonNullable<ConsolidationReport["stages"]["reconsolidation"]>;
103
139
  /**
104
140
  * True when a memory is REWRITE-ELIGIBLE — a fact-shaped target. The fork is
@@ -282,7 +318,10 @@ export declare function bandForCosine(bands: ResolutionBand[], cosine: number):
282
318
  * silently dropped by the cursor passing it).
283
319
  *
284
320
  * Dry-run: the zone's discovery + the free idempotency check only — zero LLM
285
- * calls, zero writes, no cursor or band-stats persistence.
321
+ * calls, zero writes, no cursor or band-stats persistence. Gate discovery is
322
+ * reported on every run (pairs_discovered / pairs_discovered_unlinked, #394) —
323
+ * on a dry-run they are the sizing numbers (pairs_evaluated stays 0: no calls
324
+ * are ever made).
286
325
  */
287
326
  export declare function stageReconsolidation(db: Database.Database, llm: LlmClient, budget: StageBudget, embedFn: EmbedFn, dryRun: boolean, stateDir: string | undefined, options?: ReconsolidationOptions): Promise<ReconsolidationStageResult>;
288
327
  export interface RollbackResult {
@@ -71,7 +71,7 @@ var __importStar = (this && this.__importStar) || (function () {
71
71
  };
72
72
  })();
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
- exports.absorbTrigger = exports.DEMOTED_STATUSES = exports.FOOTER_HEAD_MAX_CHARS = exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE = exports.DEFAULT_CORRECTION_MIN_SIMILARITY = exports.RECONSOLIDATION_STAGE_LABEL = void 0;
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
75
  exports.isFactShapedTarget = isFactShapedTarget;
76
76
  exports.buildCorrectionVerdictPrompt = buildCorrectionVerdictPrompt;
77
77
  exports.parseCorrectionVerdict = parseCorrectionVerdict;
@@ -115,6 +115,31 @@ exports.DEFAULT_CORRECTION_MIN_SIMILARITY = 0.75;
115
115
  * weak rewrite is corruption.
116
116
  */
117
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;
118
143
  /** Neighbor pool size before older/similarity filtering narrows to top 5 (supersession mirror). */
119
144
  const CORRECTION_NEIGHBOR_POOL = 15;
120
145
  /** Older-neighbor pairs kept per candidate after filtering (supersession mirror). */
@@ -501,7 +526,10 @@ async function classifyPair(llm, oldContent, newContent) {
501
526
  * silently dropped by the cursor passing it).
502
527
  *
503
528
  * Dry-run: the zone's discovery + the free idempotency check only — zero LLM
504
- * calls, zero writes, no cursor or band-stats persistence.
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).
505
533
  */
506
534
  async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
507
535
  // Config values pass through `unknown`-typed JSON — validate, never trust.
@@ -513,6 +541,13 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
513
541
  const rewriteMinConfidence = validNumber(options.rewriteMinConfidence, exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE, (n) => n > 0 && n <= 1);
514
542
  const autoMergeThreshold = validNumber(options.autoMergeThreshold, dedup_js_1.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
515
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;
516
551
  // ---- #392 phase 0: the deterministic merge zone (pairs >= the ceiling),
517
552
  // LLM-free and budget-free — an LLM-less night still drains duplicates. Its
518
553
  // own short lock window, pre-merge backup, and pacing cap; fail-soft, never
@@ -548,6 +583,8 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
548
583
  .all(startCursor);
549
584
  let scanned = 0;
550
585
  let pairsEvaluated = 0;
586
+ let pairsDiscovered = 0; // #394: gate discovery — counted before any skip/judgment
587
+ let pairsDiscoveredUnlinked = 0;
551
588
  let rewritten = 0;
552
589
  let absorbed = 0;
553
590
  let keptLinked = 0;
@@ -576,6 +613,25 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
576
613
  storage.addLink(db, oldId, newId, relationship, strength);
577
614
  linksCreatedThisRun.add(`${oldId}|${newId}`);
578
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
+ };
579
635
  const groups = new Map();
580
636
  const addTrigger = (target, trigger, confidence, cosine, explicit) => {
581
637
  let group = groups.get(target.id);
@@ -595,16 +651,39 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
595
651
  }
596
652
  };
597
653
  for (const candidate of rows) {
598
- if (!dryRun && budget.exhausted)
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();
599
671
  break;
672
+ }
600
673
  scanned++;
601
674
  // ---- AC7: verify incoming explicit marks (corrected_by/superseded_by
602
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.
603
681
  if (!dryRun) {
604
682
  const incoming = db
605
683
  .prepare(`SELECT source_id, relationship FROM memory_links
606
684
  WHERE target_id = ? AND source_id != ?
607
- AND relationship IN ('corrected_by', 'superseded_by')`)
685
+ AND relationship IN ('corrected_by', 'superseded_by')
686
+ AND strength >= 1.0`)
608
687
  .all(candidate.id, candidate.id);
609
688
  let markBudgetStop = false;
610
689
  for (const mark of incoming) {
@@ -621,6 +700,7 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
621
700
  }
622
701
  const { verdict, usage } = await classifyPair(llm, target.content, candidate.content);
623
702
  budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, usage);
703
+ callsUsed++; // #401
624
704
  pairsEvaluated++;
625
705
  if (!verdict) {
626
706
  skippedInfra++; // mark retained; the neighborhood is revisited via newer candidacies
@@ -639,8 +719,10 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
639
719
  `(verdict ${verdict.action}, confidence ${verdict.confidence.toFixed(2)}) — mark retained`);
640
720
  }
641
721
  }
642
- if (markBudgetStop)
722
+ if (markBudgetStop) {
723
+ persistCursor(); // #401: this candidate's remaining marks re-verify next run
643
724
  break;
725
+ }
644
726
  }
645
727
  // ---- AC2: detection pairs against older KNN neighbors.
646
728
  let neighbors;
@@ -650,13 +732,16 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
650
732
  catch (err) {
651
733
  console.warn(`[hicortex] reconsolidation: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
652
734
  cursor = candidate.__rowid;
735
+ persistCursor(); // #401: every exit path persists
653
736
  continue;
654
737
  }
655
738
  for (const neighbor of neighbors) {
739
+ pairsDiscovered++; // every neighbor passed the floor gate
656
740
  if (alreadyResolutionLinked(db, neighbor.id, candidate.id)) {
657
741
  skippedIdempotent++;
658
742
  continue;
659
743
  }
744
+ pairsDiscoveredUnlinked++; // still unlinked — the actionable candidate
660
745
  // #392: pairs at/above the ceiling belong to the deterministic zone —
661
746
  // counted here, never LLM-judged (the zone merges them or holds them
662
747
  // for its cap; re-detection is structural, not cursor-based).
@@ -667,10 +752,16 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
667
752
  }
668
753
  if (dryRun)
669
754
  continue; // preview only — no LLM call, no write
670
- if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL))
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
671
760
  break;
761
+ }
672
762
  const { verdict, usage } = await classifyPair(llm, neighbor.content, candidate.content);
673
763
  budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, usage);
764
+ callsUsed++; // #401
674
765
  pairsEvaluated++;
675
766
  if (!verdict) {
676
767
  skippedInfra++;
@@ -726,6 +817,20 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
726
817
  // verdict "none" → nothing to do
727
818
  }
728
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`);
729
834
  }
730
835
  // ---- #392 judged-merge phase: apply the queued pair merges through the
731
836
  // dedup core (mergeMemoryIds — same canonical pick, link re-points,
@@ -838,6 +943,19 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
838
943
  };
839
944
  if (!dryRun && groups.size > 0) {
840
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
+ }
841
959
  if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL)) {
842
960
  deferFrom(group.targetId);
843
961
  break;
@@ -849,6 +967,7 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
849
967
  const r = await llm.completeClassify(buildRewritePrompt(group.target.content, triggersArg));
850
968
  contract = parseRewriteReply(r.text, group.triggers.map((t) => t.id), group.target.content);
851
969
  budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, r.usage);
970
+ callsUsed++; // #401: rewrite contracts count toward the stage call cap
852
971
  }
853
972
  catch {
854
973
  infraError = true;
@@ -957,6 +1076,8 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
957
1076
  for (const [label, stat] of runBands)
958
1077
  bandStats[label] = stat;
959
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.
960
1081
  (0, state_js_1.updateState)((s) => {
961
1082
  s.reconsolidationCursor = cursor;
962
1083
  // Cumulative judged-band accumulation (#392) — the zone already
@@ -984,6 +1105,8 @@ async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir,
984
1105
  return {
985
1106
  scanned,
986
1107
  pairs_evaluated: pairsEvaluated,
1108
+ pairs_discovered: pairsDiscovered,
1109
+ pairs_discovered_unlinked: pairsDiscoveredUnlinked,
987
1110
  rewritten,
988
1111
  absorbed,
989
1112
  kept_linked: keptLinked,
package/dist/types.d.ts CHANGED
@@ -224,6 +224,16 @@ export interface ConsolidationReport {
224
224
  scanned: number;
225
225
  /** Pairs actually sent to the verdict LLM (detection + explicit-mark verification). */
226
226
  pairs_evaluated: number;
227
+ /**
228
+ * #394: pairs the similarity floor discovered this run (KNN neighbors
229
+ * at/above correctionMinSimilarity), counted before any skip or
230
+ * judgment — the only sizing number a dry-run can show, where
231
+ * pairs_evaluated is always 0.
232
+ */
233
+ pairs_discovered: number;
234
+ /** #394: discovered pairs with no resolution link yet — the actionable
235
+ * candidates (deterministic-zone work + would-be verdict calls). */
236
+ pairs_discovered_unlinked: number;
227
237
  /** Targets rewritten in place this run (one history row each). */
228
238
  rewritten: number;
229
239
  /** Triggers absorbed (invisible to recall: vector + FTS dropped). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.20.5",
3
+ "version": "0.20.6",
4
4
  "description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, Pi, and opencode.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {