@gamaze/hicortex 0.20.7 → 0.20.9

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.
Files changed (61) hide show
  1. package/README.md +10 -41
  2. package/dist/calibration.d.ts +174 -0
  3. package/dist/calibration.js +231 -0
  4. package/dist/capture.d.ts +15 -3
  5. package/dist/capture.js +10 -1
  6. package/dist/classify-domains.d.ts +6 -0
  7. package/dist/classify-domains.js +7 -1
  8. package/dist/cli.js +2 -3
  9. package/dist/config-read.d.ts +1 -1
  10. package/dist/config-read.js +96 -9
  11. package/dist/consolidate.d.ts +79 -68
  12. package/dist/consolidate.js +218 -174
  13. package/dist/dashboard.d.ts +4 -3
  14. package/dist/dedup.d.ts +34 -26
  15. package/dist/dedup.js +91 -57
  16. package/dist/distiller.js +1 -1
  17. package/dist/domain-classify.d.ts +7 -6
  18. package/dist/domain-classify.js +12 -10
  19. package/dist/eval/decay-eval.d.ts +3 -3
  20. package/dist/eval/decay-eval.js +4 -4
  21. package/dist/eval/planted-eval.d.ts +26 -0
  22. package/dist/eval/planted-eval.js +97 -0
  23. package/dist/eval/planted-fixtures.d.ts +107 -0
  24. package/dist/eval/planted-fixtures.js +283 -0
  25. package/dist/eval/planted-harness.d.ts +176 -0
  26. package/dist/eval/planted-harness.js +649 -0
  27. package/dist/index.js +4 -3
  28. package/dist/init.d.ts +9 -3
  29. package/dist/init.js +52 -9
  30. package/dist/llm.d.ts +43 -58
  31. package/dist/llm.js +87 -101
  32. package/dist/mcp-server.js +29 -29
  33. package/dist/nightly.js +105 -103
  34. package/dist/nofit.d.ts +4 -11
  35. package/dist/nofit.js +6 -23
  36. package/dist/recall-index.d.ts +30 -28
  37. package/dist/recall-index.js +21 -18
  38. package/dist/recall-registry.d.ts +2 -1
  39. package/dist/recall-registry.js +35 -1
  40. package/dist/reconsolidation.d.ts +124 -72
  41. package/dist/reconsolidation.js +359 -148
  42. package/dist/relink.js +3 -4
  43. package/dist/retrieval.d.ts +68 -35
  44. package/dist/retrieval.js +292 -104
  45. package/dist/run-deadline.d.ts +62 -0
  46. package/dist/run-deadline.js +73 -0
  47. package/dist/schema-prototypes.d.ts +3 -3
  48. package/dist/schema-prototypes.js +3 -3
  49. package/dist/state.d.ts +2 -3
  50. package/dist/storage.d.ts +16 -16
  51. package/dist/storage.js +62 -24
  52. package/dist/telemetry.d.ts +8 -7
  53. package/dist/token-budget.js +3 -4
  54. package/dist/type-classify.js +4 -4
  55. package/dist/types.d.ts +95 -155
  56. package/domains.example.json +4 -5
  57. package/hermes-plugin/hicortex/README.md +2 -2
  58. package/openclaw.plugin.json +1 -1
  59. package/package.json +2 -1
  60. package/pi-extension/hicortex/README.md +1 -1
  61. package/server.json +3 -3
@@ -38,8 +38,10 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.DEFAULT_MEMORY_SOFT_CAP = exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = exports.CONSOLIDATE_MAX_LLM_CALLS = void 0;
41
+ exports.DEFAULT_MEMORY_SOFT_CAP = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET = void 0;
42
+ exports.resolveNightlyLlmCallBudget = resolveNightlyLlmCallBudget;
42
43
  exports.isContradictionCandidate = isContradictionCandidate;
44
+ exports.isStaleTokenPeriod = isStaleTokenPeriod;
43
45
  exports.shouldThrottleTokens = shouldThrottleTokens;
44
46
  exports.parseJsonLenient = parseJsonLenient;
45
47
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
@@ -53,8 +55,6 @@ exports.stageDecayPrune = stageDecayPrune;
53
55
  exports.resolveMemorySoftCap = resolveMemorySoftCap;
54
56
  exports.stageMemoryCapEviction = stageMemoryCapEviction;
55
57
  exports.runConsolidation = runConsolidation;
56
- exports.msUntilHour = msUntilHour;
57
- exports.scheduleConsolidation = scheduleConsolidation;
58
58
  const retrieval_js_1 = require("./retrieval.js");
59
59
  const storage = __importStar(require("./storage.js"));
60
60
  const config_read_js_1 = require("./config-read.js");
@@ -68,19 +68,41 @@ const schema_prototypes_js_1 = require("./schema-prototypes.js");
68
68
  const nofit_js_1 = require("./nofit.js");
69
69
  const reconsolidation_js_1 = require("./reconsolidation.js");
70
70
  const dedup_js_1 = require("./dedup.js");
71
+ const CALIBRATION = __importStar(require("./calibration.js"));
71
72
  // Default config constants (matching Python config.py)
72
73
  /**
73
- * Default ceiling on total LLM calls across all classify-tier consolidation
74
- * stages (content-domain, link discovery, supersession) per run. This is a
75
- * runaway BACKSTOP, not a throughput throttle — on a free local model there is
76
- * no per-call cost to defend against; the binding constraint is the nightly
77
- * unit's wall-clock timeout (TimeoutStartSec), not call count. 5000 clears a
78
- * one-time classification backlog (a ~2000-memory batch drains in ~1-2 runs
79
- * instead of ~11 nights at the old 200) with margin for link/supersession, and
80
- * ~5000 calls x ~1-3s/call ≈ 1.4-4.2h fits the 6h consolidation backstop.
81
- * Config-overridable as `consolidateMaxLlmCalls` (#241).
74
+ * Default ceiling on LLM calls across the WHOLE nightly pipeline (#405; the
75
+ * #241 consolidateMaxLlmCalls mechanism, renamed and widened). A runaway
76
+ * BACKSTOP that bounds money/load INDEPENDENT OF LATENCY — a fast metered or
77
+ * capacity-limited endpoint permits thousands of calls inside the wall-clock
78
+ * budget, so time alone cannot protect it (owner ruling 2026-09-12). Consumed
79
+ * in run order: a stage that exhausts it defers its remainder via its cursor.
80
+ * 5000 clears a one-time classification backlog (a ~2000-memory batch drains
81
+ * in ~1-2 runs) with margin. Config: `nightlyLlmCallBudget` (#405); the old
82
+ * `consolidateMaxLlmCalls` key is a deprecated alias honored one release.
82
83
  */
83
- exports.CONSOLIDATE_MAX_LLM_CALLS = 5000;
84
+ exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET = 5000;
85
+ /**
86
+ * Resolve the per-run LLM call budget from config (#405):
87
+ * - `nightlyLlmCallBudget` present (positive finite) → it wins;
88
+ * - else `consolidateMaxLlmCalls` present → used as a DEPRECATED ALIAS with
89
+ * a warn naming the replacement (honored one release);
90
+ * - absent/invalid → the 5000 default.
91
+ */
92
+ function resolveNightlyLlmCallBudget(config) {
93
+ const c = config ?? {};
94
+ if (c.nightlyLlmCallBudget !== undefined) {
95
+ return (0, config_read_js_1.readPositiveConfig)(c, "nightlyLlmCallBudget", exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET);
96
+ }
97
+ if (c.consolidateMaxLlmCalls !== undefined) {
98
+ const legacy = (0, config_read_js_1.readPositiveConfig)(c, "consolidateMaxLlmCalls", exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET);
99
+ console.warn(`[hicortex] config key "consolidateMaxLlmCalls" is deprecated — renamed ` +
100
+ `"nightlyLlmCallBudget" (same meaning, now the ONE per-run LLM call ceiling). ` +
101
+ `The old key is honored for one release; rename it to clear this warning.`);
102
+ return legacy;
103
+ }
104
+ return exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET;
105
+ }
84
106
  const CONSOLIDATE_PRUNE_MIN_AGE_DAYS = 90;
85
107
  /**
86
108
  * Minimum COSINE similarity for a link candidate.
@@ -133,14 +155,12 @@ class BudgetTracker {
133
155
  callsByStage = {};
134
156
  /**
135
157
  * Per-stage count of LLM-call REQUESTS refused because the budget was
136
- * exhausted (#255). Keys are the same stage labels passed to `use()`. The
137
- * value is the SUM of the `count` args passed to each refused `use()` call
138
- * in that stage (in production every `use()` call passes count=1, so each
139
- * refused call adds 1 — but the API accepts a batch count, so a single
140
- * refused batch request accrues its full count). Stages break on the first
141
- * refusal, so a stage's value is the count of the one request that crossed
142
- * the boundary. For item-level skip counts (how many memories or pairs were
143
- * left unprocessed), see the per-stage reports — e.g.
158
+ * exhausted (#255). Keys are the same stage labels passed to `use()`; each
159
+ * refused call adds 1 (the dead batch `count` param is gone — #405 —
160
+ * production always passed 1 anyway). Stages break on the first refusal,
161
+ * so a stage's value is the count of requests that crossed the boundary.
162
+ * For item-level skip counts (how many memories or pairs were left
163
+ * unprocessed), see the per-stage reports — e.g.
144
164
  * `stages.importance.skipped_budget` — which count MEMORIES, not call
145
165
  * requests. Surfaced in summary() and ConsolidationReport as
146
166
  * `deferred_by_stage`.
@@ -168,21 +188,21 @@ class BudgetTracker {
168
188
  get remaining() {
169
189
  return Math.max(0, this.maxCalls - this.callsUsed);
170
190
  }
171
- use(stage, count = 1) {
172
- if (this.callsUsed + count > this.maxCalls) {
191
+ use(stage) {
192
+ if (this.callsUsed >= this.maxCalls) {
173
193
  // #255: emit as a STRUCTURED event (not a bare prose warn) so a monitor
174
194
  // can grep/parse `event=budget_exhausted` from journald. The line stays
175
195
  // human-readable (key=value tokens after the [hicortex] prefix). Deferred
176
196
  // counts are accrued BEFORE the log so the line reflects the up-to-date
177
197
  // per-stage toll — the refused count is added to this stage's slot.
178
- this.deferredByStage[stage] = (this.deferredByStage[stage] ?? 0) + count;
198
+ this.deferredByStage[stage] = (this.deferredByStage[stage] ?? 0) + 1;
179
199
  console.warn(`[hicortex] event=budget_exhausted stage=${stage} ` +
180
200
  `calls_used=${this.callsUsed} max_calls=${this.maxCalls} ` +
181
201
  `deferred_by_stage=${JSON.stringify(this.deferredByStage)}`);
182
202
  return false;
183
203
  }
184
- this.callsUsed += count;
185
- this.callsByStage[stage] = (this.callsByStage[stage] ?? 0) + count;
204
+ this.callsUsed += 1;
205
+ this.callsByStage[stage] = (this.callsByStage[stage] ?? 0) + 1;
186
206
  return true;
187
207
  }
188
208
  /**
@@ -223,6 +243,20 @@ exports.BudgetTracker = BudgetTracker;
223
243
  // ---------------------------------------------------------------------------
224
244
  // Token fair-use throttle decision (#246)
225
245
  // ---------------------------------------------------------------------------
246
+ /**
247
+ * True when a token-period start stamp is ABSENT or sits in a previous UTC
248
+ * calendar month than `now` — the monthly-reset staleness check. #405: ONE
249
+ * shared helper — the check was triplicated (the nightly's throttle branch,
250
+ * the nightly's accrual write, token-budget.ts recordDistillUsage) and each
251
+ * copy re-derived the year+month comparison by hand.
252
+ */
253
+ function isStaleTokenPeriod(periodStart, now = new Date()) {
254
+ if (!periodStart)
255
+ return true;
256
+ const start = new Date(periodStart);
257
+ return start.getUTCFullYear() !== now.getUTCFullYear() ||
258
+ start.getUTCMonth() !== now.getUTCMonth();
259
+ }
226
260
  /**
227
261
  * Decide whether consolidation should be throttled this run based on the
228
262
  * `llmTokensPerMonth` fair-use cap. Pure (no I/O) so it can be unit-tested
@@ -235,22 +269,14 @@ exports.BudgetTracker = BudgetTracker;
235
269
  *
236
270
  * `cap = 0` (the self-hosted default) → never throttle (unlimited).
237
271
  * `periodStart` in a previous calendar month → period resets to 0 first
238
- * (mirrors the reset logic in nightly.ts; both sides agree because both read
239
- * the same state + clock).
272
+ * (isStaleTokenPeriod — the same helper every monthly-reset site uses, so
273
+ * the sides agree because they read the same state + clock).
240
274
  */
241
275
  function shouldThrottleTokens(cap, period, lastRunTokens, now = new Date()) {
242
276
  if (cap <= 0)
243
277
  return { throttle: false };
244
- let periodTotal = period?.total ?? 0;
245
- const periodStart = period?.periodStart;
246
- if (periodStart) {
247
- const start = new Date(periodStart);
248
- if (start.getUTCFullYear() !== now.getUTCFullYear() ||
249
- start.getUTCMonth() !== now.getUTCMonth()) {
250
- // Stale period → reset accrual to 0 before the check.
251
- periodTotal = 0;
252
- }
253
- }
278
+ // Stale period → reset accrual to 0 before the check.
279
+ const periodTotal = isStaleTokenPeriod(period?.periodStart, now) ? 0 : (period?.total ?? 0);
254
280
  if (periodTotal + lastRunTokens > cap) {
255
281
  return { throttle: true, used: periodTotal, cap };
256
282
  }
@@ -325,7 +351,7 @@ function stagePrecheck(db, stateDir) {
325
351
  // ---------------------------------------------------------------------------
326
352
  // Stage 2: Importance Scoring
327
353
  // ---------------------------------------------------------------------------
328
- async function stageImportance(db, memories, llm, budget, dryRun) {
354
+ async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
329
355
  const batchSize = 10;
330
356
  let scored = 0;
331
357
  let failed = 0;
@@ -335,6 +361,11 @@ async function stageImportance(db, memories, llm, budget, dryRun) {
335
361
  skippedBudget += memories.length - i;
336
362
  break;
337
363
  }
364
+ // #405: the run deadline bounds the batch loop too — a large unscored
365
+ // backlog must not blow the whole run's wall-clock inside one stage.
366
+ // Same stage label as the boundary check, so the defer log fires once.
367
+ if (deadline?.hit("importance"))
368
+ break;
338
369
  const batch = memories.slice(i, i + batchSize);
339
370
  const lines = batch.map((mem, idx) => `[${idx}] ${mem.content.slice(0, 500)}`);
340
371
  const memoriesBlock = lines.join("\n\n");
@@ -346,7 +377,7 @@ async function stageImportance(db, memories, llm, budget, dryRun) {
346
377
  break;
347
378
  }
348
379
  try {
349
- const r = await llm.completeFast(prompt, 256);
380
+ const r = await llm.complete(prompt);
350
381
  budget.recordUsage("importance", r.usage);
351
382
  let scores = parseJsonLenient(r.text, null);
352
383
  if (!Array.isArray(scores)) {
@@ -408,7 +439,7 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
408
439
  return { lessons_generated: 0, skipped: true, reason: "budget_exhausted" };
409
440
  }
410
441
  try {
411
- const r = await llm.completeReflect(prompt, 2048);
442
+ const r = await llm.complete(prompt);
412
443
  budget.recordUsage("reflection", r.usage);
413
444
  const lessons = parseJsonLenient(r.text, []);
414
445
  if (!Array.isArray(lessons)) {
@@ -458,9 +489,9 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
458
489
  const existingText = similarLessons[0].content.slice(0, 300);
459
490
  const newText = content.slice(0, 300);
460
491
  try {
461
- const verdictR = await llm.completeFast(`Two lessons from an AI memory system. Do they CONTRADICT each other (opposite advice on the same topic)?\n\n` +
492
+ const verdictR = await llm.complete(`Two lessons from an AI memory system. Do they CONTRADICT each other (opposite advice on the same topic)?\n\n` +
462
493
  `EXISTING: ${existingText}\n\nNEW: ${newText}\n\n` +
463
- `Answer ONLY "yes" or "no". If the new lesson updates/refines the existing one (not contradicts), answer "no".`, 16);
494
+ `Answer ONLY "yes" or "no". If the new lesson updates/refines the existing one (not contradicts), answer "no".`);
464
495
  // Stage label "contradiction_check" matches the budget.use() call
465
496
  // above (separate counter from the reflection call proper). Token
466
497
  // accounting follows the same stage partition as the call counter.
@@ -543,7 +574,7 @@ function rebuildContentModuleIndex(db, domains, stateDir) {
543
574
  (0, state_js_1.updateState)((s) => { s.moduleIndex = moduleIndex; }, stateDir);
544
575
  return { domains: moduleDomains.length };
545
576
  }
546
- async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, stateDir, weakPrimaryFloor = nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR) {
577
+ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, stateDir, weakPrimaryFloor = nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR, deadline) {
547
578
  // Rows needing (re)classification:
548
579
  // - domain IS NULL (never classified), OR
549
580
  // - domain NOT IN the current vocabulary (a rename/removal re-files), OR
@@ -569,6 +600,11 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
569
600
  // refreshes everything from the updated tag sets anyway).
570
601
  const { prototypes: startPrototypes } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
571
602
  for (const row of rows) {
603
+ // #405: the run deadline bounds the classification row loop (a large
604
+ // backlog must defer, not blow the wall-clock). Same stage label as the
605
+ // boundary check, so the defer log fires once.
606
+ if (deadline?.hit("domain_curation"))
607
+ break;
572
608
  if (budget.exhausted || !budget.use("content_domain")) {
573
609
  console.warn(`[hicortex] content-domain: budget exhausted after ${classified} classified`);
574
610
  break;
@@ -716,7 +752,7 @@ async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
716
752
  .map((r) => `${r.project}: ${r.cnt} / ${lessonsByProject.get(r.project) ?? 0}`)
717
753
  .join("\n");
718
754
  try {
719
- const r = await llm.completeFast((0, prompts_js_1.domainCuration)(projectLines), 1024);
755
+ const r = await llm.complete((0, prompts_js_1.domainCuration)(projectLines));
720
756
  budget.recordUsage("domain_curation", r.usage);
721
757
  const parsed = parseJsonLenient(r.text, []);
722
758
  if (!Array.isArray(parsed) || parsed.length === 0) {
@@ -847,19 +883,16 @@ function discoverLinkCandidates(db, mem, embedding) {
847
883
  * 672-link audit (see the Stage 3 header) found the LLM-classified UPPERCASE
848
884
  * types near-useless (CONTRADICTS 4% acceptable). Every candidate now takes its
849
885
  * pre-computed `heuristicType` (only `extends` or `relates_to` — see
850
- * classifyRelationship). No LLM call is made.
851
- *
852
- * Signature stability: `llm` and `budget` are RETAINED but intentionally
853
- * ignored so the callers (nightly `stageLinks`, `hicortex relink`) and the
854
- * tests that import this need no change to their call sites. The return shape
855
- * is unchanged; `llmClassified` is always 0 now and `heuristicFallback` counts
856
- * every candidate. Do NOT re-add an LLM path here without a classifier that
857
- * passes the audit harness at >= 70% acceptable.
886
+ * classifyRelationship). No LLM call is made. #405: the ignored `llm`/`budget`
887
+ * params are deleted — the signature now tells the truth.
888
+ * The return shape is unchanged; `llmClassified` is always 0 and
889
+ * `heuristicFallback` counts every candidate. Do NOT re-add an LLM path here
890
+ * without a classifier that passes the audit harness at >= 70% acceptable.
858
891
  *
859
892
  * Shared between the nightly `stageLinks` and `hicortex relink`.
860
893
  * Returns one relationship type per candidate (same order as input).
861
894
  */
862
- async function classifyLinkCandidates(candidates, _llm, _budget) {
895
+ async function classifyLinkCandidates(candidates) {
863
896
  const types = candidates.map((c) => c.heuristicType);
864
897
  return { types, llmClassified: 0, heuristicFallback: candidates.length };
865
898
  }
@@ -880,8 +913,9 @@ async function stageLinks(db, memories, embedFn, dryRun, llm, budget) {
880
913
  if (candidates.length === 0) {
881
914
  return { auto_linked: 0, llm_classified: 0, heuristic_fallback: 0, failed };
882
915
  }
883
- // Phase B: LLM batch classification (heuristic fallback inside)
884
- const { types: classifiedTypes, llmClassified, heuristicFallback } = await classifyLinkCandidates(candidates, llm, budget);
916
+ // Phase B: heuristic-only classification (LLM retired; #405 dropped the
917
+ // dead llm/budget params)
918
+ const { types: classifiedTypes, llmClassified, heuristicFallback } = await classifyLinkCandidates(candidates);
885
919
  // Phase C: Store all classified links
886
920
  for (let i = 0; i < candidates.length; i++) {
887
921
  const c = candidates[i];
@@ -968,26 +1002,17 @@ function stageHubBoost(db, dryRun) {
968
1002
  // this is a judgment call about content, not a duplicate).
969
1003
  //
970
1004
  // Scope: memories with `rowid > supersessionCursor` (state.json; starts 0 —
971
- // the corpus is back-processed gradually, config `supersessionMaxCalls` LLM
972
- // calls per night) whose shape suggests a decision/correction. For each,
973
- // KNN top-5 OLDER same-shape neighbors at/above `supersessionMinSimilarity`;
974
- // one constrained classify-tier LLM call per pair decides `superseded: true|
975
- // false`. A parse/infra error skips just that PAIR (retried naturally next
976
- // night since the cursor still advances past the memory — see the cursor
977
- // note below); it never mis-links.
978
- /** Default minimum COSINE similarity for a supersession candidate pair. */
979
- exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
980
- /**
981
- * Default max classify-tier LLM calls (pairs evaluated) spent per nightly run.
982
- * 0 = no separate cap — supersession shares the consolidation budget
983
- * (CONSOLIDATE_MAX_LLM_CALLS, default 5000) like every other stage. The old
984
- * default of 30 was set when the corpus had 14 decisions; with the distiller
985
- * now classifying types correctly (#216), decisions are common and the cap
986
- * was throttling supersession to a crawl. On a local free model there is no
987
- * per-call cost to defend against — the binding constraint is the wall-clock
988
- * timeout (TimeoutStartSec), not call count.
989
- */
990
- exports.DEFAULT_SUPERSESSION_MAX_CALLS = 0;
1005
+ // the corpus is back-processed gradually) whose shape suggests a
1006
+ // decision/correction. For each, KNN top-5 OLDER same-shape neighbors
1007
+ // at/above the release-managed similarity floor (calibration.ts); one constrained classify-tier LLM
1008
+ // call per pair decides `superseded: true| false`. A parse/infra error skips
1009
+ // just that PAIR (retried naturally next night since the cursor still
1010
+ // advances past the memory — see the cursor note below); it never mis-links.
1011
+ // #405: no per-stage call cap — the ONE run budget (nightlyLlmCallBudget)
1012
+ // and the run deadline are the only bounds, like every other stage.
1013
+ /** Default minimum COSINE similarity for a supersession candidate pair —
1014
+ * RELEASE-MANAGED since #408 (calibration.ts SUPERSESSION_MIN_SIMILARITY). */
1015
+ exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = CALIBRATION.SUPERSESSION_MIN_SIMILARITY;
991
1016
  /** Default multiplier applied to a superseded memory's base_strength. */
992
1017
  /** Floor under which a superseded memory's base_strength never drops. */
993
1018
  /** Neighbor pool size before shape/older/similarity filtering narrows to top 5. */
@@ -1067,7 +1092,7 @@ function parseSupersessionReply(reply) {
1067
1092
  */
1068
1093
  async function classifySupersession(llm, oldContent, newContent) {
1069
1094
  try {
1070
- const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent));
1095
+ const r = await llm.complete(buildSupersessionPrompt(oldContent, newContent));
1071
1096
  return { verdict: parseSupersessionReply(r.text), usage: r.usage };
1072
1097
  }
1073
1098
  catch {
@@ -1107,6 +1132,13 @@ async function findOlderNeighbors(db, candidate, embedFn, minSimilarity) {
1107
1132
  * candidacy. It only stops SHORT of a candidate when the budget is already
1108
1133
  * exhausted before that candidate starts, so the cursor never skips a
1109
1134
  * candidate that was never looked at.
1135
+ *
1136
+ * #405: the cursor persists after EVERY fully-considered candidate (the
1137
+ * post-#404 reconsolidation pattern), not at stage end — a run killed or
1138
+ * deadline-deferred mid-stage loses at most the candidate in flight. No
1139
+ * orphan clamp is needed (unlike reconsolidation): supersession applies each
1140
+ * verdict's link immediately, so `cursor = candidate.__rowid` always sits
1141
+ * after all of that candidate's writes.
1110
1142
  */
1111
1143
  async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
1112
1144
  // Config values pass through `unknown`-typed JSON — validate rather than
@@ -1116,7 +1148,6 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1116
1148
  return Number.isFinite(n) && ok(n) ? n : fallback;
1117
1149
  };
1118
1150
  const minSimilarity = validNumber(options.minSimilarity, exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY, (n) => n > 0 && n <= 1);
1119
- const maxCalls = validNumber(options.maxCalls, exports.DEFAULT_SUPERSESSION_MAX_CALLS, (n) => n >= 0);
1120
1151
  const startCursor = (0, state_js_1.loadState)(stateDir).supersessionCursor ?? 0;
1121
1152
  const rows = db
1122
1153
  .prepare(
@@ -1136,10 +1167,26 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1136
1167
  let superseded = 0;
1137
1168
  let skippedInfra = 0;
1138
1169
  let skippedIdempotent = 0;
1139
- let callsUsed = 0;
1140
1170
  let cursor = startCursor;
1171
+ // #405: per-candidate checkpoint — persists the cursor after every fully
1172
+ // considered candidate (updateState is an atomic temp-rename of a small
1173
+ // file; the loop cadence is seconds per candidate, so the cost is
1174
+ // negligible). The end-of-stage write below stays the authoritative final
1175
+ // write.
1176
+ const persistCursor = () => {
1177
+ if (dryRun)
1178
+ return;
1179
+ (0, state_js_1.updateState)((s) => {
1180
+ s.supersessionCursor = cursor;
1181
+ }, stateDir);
1182
+ };
1141
1183
  for (const candidate of rows) {
1142
- if (!dryRun && ((maxCalls > 0 && callsUsed >= maxCalls) || budget.exhausted))
1184
+ // #405: the ONE run budget is the only call cap; the deadline stops the
1185
+ // scan at the candidate boundary — the cursor holds at the last
1186
+ // fully-considered candidate (persisted below).
1187
+ if (!dryRun && budget.exhausted)
1188
+ break;
1189
+ if (!dryRun && options.deadline?.hit("supersession"))
1143
1190
  break;
1144
1191
  scanned++;
1145
1192
  let neighbors;
@@ -1149,6 +1196,7 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1149
1196
  catch (err) {
1150
1197
  console.warn(`[hicortex] supersession: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
1151
1198
  cursor = candidate.__rowid;
1199
+ persistCursor(); // #405: every exit path persists
1152
1200
  continue;
1153
1201
  }
1154
1202
  for (const neighbor of neighbors) {
@@ -1158,9 +1206,8 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1158
1206
  }
1159
1207
  if (dryRun)
1160
1208
  continue; // preview only — no LLM call, no write
1161
- if ((maxCalls > 0 && callsUsed >= maxCalls) || !budget.use("supersession"))
1162
- break;
1163
- callsUsed++;
1209
+ if (!budget.use("supersession"))
1210
+ break; // #405: the ONE run budget
1164
1211
  const { verdict, usage } = await classifySupersession(llm, neighbor.content, candidate.content);
1165
1212
  // Meter every round-tripped attempt (#246) — even a null verdict spent
1166
1213
  // real tokens. The stage label matches the budget.use() above.
@@ -1184,6 +1231,10 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1184
1231
  }
1185
1232
  }
1186
1233
  cursor = candidate.__rowid;
1234
+ // #405: checkpoint after every fully-considered candidate (post-#404
1235
+ // reconsolidation pattern) — a killed or deadline-deferred run loses at
1236
+ // most the candidate in flight.
1237
+ persistCursor();
1187
1238
  }
1188
1239
  if (!dryRun) {
1189
1240
  (0, state_js_1.updateState)((s) => {
@@ -1376,21 +1427,23 @@ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1376
1427
  return Number.isFinite(n) && ok(n) ? n : fallback;
1377
1428
  };
1378
1429
  const autoMergeThreshold = validNumber(options.autoMergeThreshold, dedup_js_1.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
1379
- const maxMerges = validNumber(options.maxMerges, dedup_js_1.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
1380
1430
  const merges = await (0, dedup_js_1.runDeterministicMergeZone)(db, {
1381
1431
  stateDir,
1382
1432
  threshold: autoMergeThreshold,
1383
- maxMerges,
1384
1433
  dryRun,
1385
1434
  acquireLock: options.acquireLock,
1435
+ deadline: options.deadline,
1386
1436
  });
1387
1437
  const bandStats = {};
1388
- if (merges.max_merges > 0) {
1438
+ {
1439
+ // #405: recorded whenever the zone ran (the old max_merges>0 gate was a
1440
+ // 0=disabled switch — the switch is gone).
1389
1441
  bandStats[`>=${autoMergeThreshold}`] = {
1390
1442
  pairs: merges.losers_merged,
1391
1443
  merge: merges.losers_merged,
1392
1444
  corrects: 0,
1393
1445
  supersedes: 0,
1446
+ conflicts: 0,
1394
1447
  none: 0,
1395
1448
  merge_below_gate: 0,
1396
1449
  conf_sum: merges.losers_merged,
@@ -1421,24 +1474,40 @@ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1421
1474
  merge_below_gate: 0,
1422
1475
  skipped_above_ceiling: 0,
1423
1476
  skipped_metadata_mismatch: 0,
1477
+ conflict_flagged: 0, // guard-C: no scan on a quiet night — nothing flagged
1478
+ conflict_skipped: merges.skipped_conflict, // guard-C: the zone's guard still counts
1479
+ scout_scanned: 0, // #393 B: the scan (and its shape calls) doesn't run on a quiet night
1480
+ scout_correction_shaped: 0,
1481
+ scout_candidates_found: 0,
1424
1482
  band_stats: bandStats,
1425
1483
  };
1426
1484
  }
1427
1485
  async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions,
1428
- /** Total LLM-call ceiling across classify-tier stages (#241). The caller
1429
- * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
1430
- * exported `CONSOLIDATE_MAX_LLM_CALLS` default (5000). */
1486
+ /** The ONE per-run LLM-call ceiling (#405/#241). The caller resolves
1487
+ * `nightlyLlmCallBudget` from config (consolidateMaxLlmCalls is a
1488
+ * deprecated alias — resolveNightlyLlmCallBudget) and passes it; unset →
1489
+ * the exported DEFAULT_NIGHTLY_LLM_CALL_BUDGET (5000). */
1431
1490
  budgetMaxCalls,
1432
1491
  /** Soft cap on the corpus (#245). Nightly.ts reads `memorySoftCap` from
1433
1492
  * config and passes it; unset → `DEFAULT_MEMORY_SOFT_CAP` (10000). `0`
1434
1493
  * disables eviction (indefinite growth). */
1435
1494
  memorySoftCap,
1436
- /** Reconsolidation-stage knobs (#384), threaded from config by nightly.ts
1437
- * (correctionMinSimilarity / correctionRewriteMinConfidence) exactly like
1438
- * supersessionOptions above; unset fields → the stage's defaults.
1439
- * Appended AFTER the pre-#384 params so every existing positional caller
1440
- * (tests, hosted nightly) keeps its argument meaning. */
1441
- reconsolidationOptions) {
1495
+ /** Reconsolidation-stage knobs (#384) — eval/test seams since #408 (the
1496
+ * values are release-managed calibration constants; nightly.ts threads
1497
+ * NOTHING), exactly like supersessionOptions above; unset fields → the
1498
+ * stage's calibration defaults. Appended AFTER the pre-#384 params so
1499
+ * every existing positional caller (tests, hosted nightly) keeps its
1500
+ * argument meaning. */
1501
+ reconsolidationOptions,
1502
+ /**
1503
+ * The run-wide pipeline deadline (#405), created at nightly start and
1504
+ * shared by capture + every consolidation stage. Absent = no deadline
1505
+ * (tests, evict-only paths, pre-#405 callers). When it fires, every
1506
+ * not-yet-run stage defers (logs event=deadline_deferred stage=<name>) and
1507
+ * the report status becomes "deferred" — which keeps lastConsolidated
1508
+ * un-advanced so the next run re-finds the pending work.
1509
+ */
1510
+ deadline) {
1442
1511
  const start = new Date();
1443
1512
  const report = {
1444
1513
  started_at: start.toISOString(),
@@ -1483,20 +1552,31 @@ reconsolidationOptions) {
1483
1552
  // stage report (telemetry's "skipped = zero LLM work" stays true), and
1484
1553
  // the zone never runs twice: the main path runs it INSIDE the stage, this
1485
1554
  // skip path returns before that.
1486
- report.stages.reconsolidation = await skippedRunResolutionReport(db, dryRun, stateDir, reconsolidationOptions);
1555
+ report.stages.reconsolidation = await skippedRunResolutionReport(db, dryRun, stateDir, { ...reconsolidationOptions, deadline });
1487
1556
  report.status = "skipped";
1488
1557
  report.completed_at = new Date().toISOString();
1489
1558
  return report;
1490
1559
  }
1491
- // Config-overridable total LLM-call ceiling (#241). Default 5000 (was 200) —
1492
- // see CONSOLIDATE_MAX_LLM_CALLS. The caller reads `consolidateMaxLlmCalls`
1493
- // from config and passes it here.
1494
- const budget = new BudgetTracker(budgetMaxCalls ?? exports.CONSOLIDATE_MAX_LLM_CALLS);
1560
+ // #405: the ONE per-run LLM-call ceiling (default 5000). The caller
1561
+ // resolves `nightlyLlmCallBudget` from config (consolidateMaxLlmCalls is a
1562
+ // deprecated alias — see resolveNightlyLlmCallBudget) and passes it here.
1563
+ const budget = new BudgetTracker(budgetMaxCalls ?? exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET);
1564
+ console.log(`[hicortex] Consolidation LLM call budget: ${budget.maxCalls} calls`);
1495
1565
  try {
1566
+ // #405 stage gating: each boundary checks the run deadline; a hit defers
1567
+ // that stage (absent from the report — it did not run) and logs
1568
+ // event=deadline_deferred stage=<name> once. Later boundaries check
1569
+ // independently, so a mid-run deadline reports every remaining stage as
1570
+ // deferred. Deferred stages drain next run (cursors hold below them).
1496
1571
  // Stage 2: Importance Scoring
1497
- report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun);
1572
+ if (!deadline?.hit("importance")) {
1573
+ report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun, deadline);
1574
+ }
1498
1575
  // Stage 2.5: Reflection
1499
- if (skipReflection) {
1576
+ if (deadline?.hit("reflection")) {
1577
+ // deferred — stage absent from the report
1578
+ }
1579
+ else if (skipReflection) {
1500
1580
  report.stages.reflection = {
1501
1581
  lessons_generated: 0,
1502
1582
  skipped: true,
@@ -1511,40 +1591,60 @@ reconsolidationOptions) {
1511
1591
  // domain list is configured. The single model serves all phases; if it's
1512
1592
  // down, the phase skips and retries on the next nightly run (no fallback).
1513
1593
  const cfgDomains = domainOptions?.domains;
1514
- if (cfgDomains && cfgDomains.length > 0) {
1515
- if (domainOptions?.contentDomainsReady === false) {
1516
- report.stages.domain_curation = {
1517
- curated: false,
1518
- domains: cfgDomains.length,
1519
- reason: "reflect_endpoint_offline",
1520
- };
1594
+ if (!deadline?.hit("domain_curation")) {
1595
+ if (cfgDomains && cfgDomains.length > 0) {
1596
+ if (domainOptions?.contentDomainsReady === false) {
1597
+ report.stages.domain_curation = {
1598
+ curated: false,
1599
+ domains: cfgDomains.length,
1600
+ reason: "reflect_endpoint_offline",
1601
+ };
1602
+ }
1603
+ else {
1604
+ report.stages.domain_curation = await stageContentDomains(db, cfgDomains, llm, budget, embedFn, dryRun, stateDir, domainOptions?.weakPrimaryFloor ?? nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR, deadline);
1605
+ }
1521
1606
  }
1522
1607
  else {
1523
- report.stages.domain_curation = await stageContentDomains(db, cfgDomains, llm, budget, embedFn, dryRun, stateDir, domainOptions?.weakPrimaryFloor ?? nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR);
1608
+ report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
1524
1609
  }
1525
1610
  }
1526
- else {
1527
- report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
1611
+ // Stage 3: Link Discovery (heuristic edge classification — local work,
1612
+ // but bounded by the same deadline as every other stage).
1613
+ if (!deadline?.hit("links")) {
1614
+ report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
1528
1615
  }
1529
- // Stage 3: Link Discovery (with LLM-assisted edge classification)
1530
- report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
1531
1616
  // Stage 3.5: Hub Detection — boost highly-connected memories
1532
- report.stages.hub_boost = stageHubBoost(db, dryRun);
1617
+ if (!deadline?.hit("hub_boost")) {
1618
+ report.stages.hub_boost = stageHubBoost(db, dryRun);
1619
+ }
1533
1620
  // Stage 3.7: Supersession Detection (#191 Phase B)
1534
- report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
1621
+ if (!deadline?.hit("supersession")) {
1622
+ report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, { ...supersessionOptions, deadline });
1623
+ }
1535
1624
  // Stage 3.8: Reconsolidation (#384) — resolve corrections: rewrite
1536
1625
  // fact-shaped targets in place (absorbing transition-only triggers),
1537
1626
  // mark everything else. Rides the same shared budget under its own stage
1538
1627
  // label + cursor (supersession-stage pattern).
1539
- report.stages.reconsolidation = await (0, reconsolidation_js_1.stageReconsolidation)(db, llm, budget, embedFn, dryRun, stateDir, reconsolidationOptions);
1628
+ if (!deadline?.hit("reconsolidation")) {
1629
+ report.stages.reconsolidation = await (0, reconsolidation_js_1.stageReconsolidation)(db, llm, budget, embedFn, dryRun, stateDir, { ...reconsolidationOptions, deadline });
1630
+ }
1540
1631
  // Stage 4: Decay & Prune
1541
- report.stages.decay_prune = stageDecayPrune(db, dryRun);
1632
+ if (!deadline?.hit("decay_prune")) {
1633
+ report.stages.decay_prune = stageDecayPrune(db, dryRun);
1634
+ }
1542
1635
  // (Memory cap eviction moved before the precheck skip — see above.)
1543
1636
  }
1544
1637
  catch (err) {
1545
1638
  report.status = "failed";
1546
1639
  console.error("[hicortex] Consolidation pipeline error:", err);
1547
1640
  }
1641
+ // #405: any deferred stage ⇒ the run is "deferred", not "completed" — the
1642
+ // lastConsolidated gate below then holds the watermark so the next run's
1643
+ // pending-set queries re-find the deferred work (mirrors endpoint_down).
1644
+ // A thrown error still wins ("failed" is the more specific outcome).
1645
+ if (report.status === "completed" && deadline && deadline.deferredStages().length > 0) {
1646
+ report.status = "deferred";
1647
+ }
1548
1648
  // Update last-consolidated timestamp. #357: the stages fail SOFT, so a run
1549
1649
  // against an endpoint that died mid-way still reports status "completed"
1550
1650
  // here — advancing the timestamp made state.json disagree with the
@@ -1568,59 +1668,3 @@ reconsolidationOptions) {
1568
1668
  Math.round((Date.now() - start.getTime()) / 100) / 10;
1569
1669
  return report;
1570
1670
  }
1571
- // ---------------------------------------------------------------------------
1572
- // Scheduling
1573
- // ---------------------------------------------------------------------------
1574
- /**
1575
- * Calculate milliseconds until the next occurrence of a given hour (local time).
1576
- */
1577
- function msUntilHour(hour) {
1578
- const now = new Date();
1579
- const target = new Date(now);
1580
- target.setHours(hour, 30, 0, 0); // :30 past the hour
1581
- if (target.getTime() <= now.getTime()) {
1582
- // Already passed today, schedule for tomorrow
1583
- target.setDate(target.getDate() + 1);
1584
- }
1585
- return target.getTime() - now.getTime();
1586
- }
1587
- const ONE_DAY_MS = 24 * 60 * 60 * 1000;
1588
- /**
1589
- * Schedule the consolidation pipeline to run nightly.
1590
- * Returns a cleanup function to cancel the timer.
1591
- *
1592
- * NOTE: currently unused (nightly.ts drives consolidation directly). Any future
1593
- * caller MUST read config.domains and thread `domainOptions` into runConsolidation
1594
- * when content domains are configured — otherwise it silently falls back to the
1595
- * legacy project-grouping path even when a domain list is set.
1596
- */
1597
- function scheduleConsolidation(db, llm, embedFn, hour = 2) {
1598
- let timeout = null;
1599
- let interval = null;
1600
- const runAndScheduleInterval = () => {
1601
- runConsolidation(db, llm, embedFn)
1602
- .then((report) => {
1603
- console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s`);
1604
- })
1605
- .catch((err) => {
1606
- console.error("[hicortex] Consolidation failed:", err);
1607
- });
1608
- // Schedule recurring daily runs
1609
- if (!interval) {
1610
- interval = setInterval(() => {
1611
- runConsolidation(db, llm, embedFn).catch((err) => {
1612
- console.error("[hicortex] Consolidation failed:", err);
1613
- });
1614
- }, ONE_DAY_MS);
1615
- }
1616
- };
1617
- const delay = msUntilHour(hour);
1618
- console.log(`[hicortex] Consolidation scheduled in ${Math.round(delay / 60_000)} minutes`);
1619
- timeout = setTimeout(runAndScheduleInterval, delay);
1620
- return () => {
1621
- if (timeout)
1622
- clearTimeout(timeout);
1623
- if (interval)
1624
- clearInterval(interval);
1625
- };
1626
- }