@gamaze/hicortex 0.20.7 → 0.20.10

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 (86) hide show
  1. package/README.md +18 -41
  2. package/assets/dashboard.html +3989 -836
  3. package/dist/calibration.d.ts +293 -0
  4. package/dist/calibration.js +379 -0
  5. package/dist/capture-health.d.ts +87 -0
  6. package/dist/capture-health.js +106 -0
  7. package/dist/capture-pause.d.ts +86 -0
  8. package/dist/capture-pause.js +127 -0
  9. package/dist/capture.d.ts +24 -3
  10. package/dist/capture.js +11 -1
  11. package/dist/classify-domains.d.ts +6 -0
  12. package/dist/classify-domains.js +7 -1
  13. package/dist/cli.js +38 -3
  14. package/dist/config-read.d.ts +1 -1
  15. package/dist/config-read.js +96 -9
  16. package/dist/consolidate.d.ts +114 -68
  17. package/dist/consolidate.js +302 -182
  18. package/dist/dashboard.d.ts +326 -6
  19. package/dist/dashboard.js +592 -7
  20. package/dist/db.js +105 -0
  21. package/dist/dedup.d.ts +34 -26
  22. package/dist/dedup.js +91 -57
  23. package/dist/distiller.js +1 -1
  24. package/dist/domain-classify.d.ts +7 -6
  25. package/dist/domain-classify.js +12 -10
  26. package/dist/eval/decay-eval.d.ts +3 -3
  27. package/dist/eval/decay-eval.js +4 -4
  28. package/dist/eval/importance-eval.d.ts +85 -0
  29. package/dist/eval/importance-eval.js +286 -0
  30. package/dist/eval/planted-eval.d.ts +26 -0
  31. package/dist/eval/planted-eval.js +97 -0
  32. package/dist/eval/planted-fixtures.d.ts +107 -0
  33. package/dist/eval/planted-fixtures.js +283 -0
  34. package/dist/eval/planted-harness.d.ts +176 -0
  35. package/dist/eval/planted-harness.js +649 -0
  36. package/dist/eval/ranking-battery.d.ts +78 -0
  37. package/dist/eval/ranking-battery.js +181 -0
  38. package/dist/eval/ranking-eval.d.ts +41 -0
  39. package/dist/eval/ranking-eval.js +391 -0
  40. package/dist/eval/ranking-fixtures.d.ts +77 -0
  41. package/dist/eval/ranking-fixtures.js +226 -0
  42. package/dist/identity-store.d.ts +21 -0
  43. package/dist/identity-store.js +49 -0
  44. package/dist/index.js +4 -3
  45. package/dist/init.d.ts +23 -3
  46. package/dist/init.js +84 -9
  47. package/dist/llm.d.ts +43 -58
  48. package/dist/llm.js +87 -101
  49. package/dist/mcp-server.d.ts +12 -0
  50. package/dist/mcp-server.js +213 -32
  51. package/dist/nightly.d.ts +9 -1
  52. package/dist/nightly.js +164 -110
  53. package/dist/nofit.d.ts +4 -11
  54. package/dist/nofit.js +6 -23
  55. package/dist/prompts.d.ts +10 -0
  56. package/dist/prompts.js +28 -5
  57. package/dist/recall-index.d.ts +30 -28
  58. package/dist/recall-index.js +21 -18
  59. package/dist/recall-registry.d.ts +2 -1
  60. package/dist/recall-registry.js +35 -1
  61. package/dist/reconsolidation.d.ts +168 -87
  62. package/dist/reconsolidation.js +818 -377
  63. package/dist/relink.js +3 -4
  64. package/dist/rescore-importance.d.ts +80 -0
  65. package/dist/rescore-importance.js +236 -0
  66. package/dist/retrieval.d.ts +80 -35
  67. package/dist/retrieval.js +322 -105
  68. package/dist/run-deadline.d.ts +62 -0
  69. package/dist/run-deadline.js +73 -0
  70. package/dist/schema-prototypes.d.ts +3 -3
  71. package/dist/schema-prototypes.js +3 -3
  72. package/dist/stages.d.ts +37 -0
  73. package/dist/stages.js +51 -0
  74. package/dist/state.d.ts +34 -9
  75. package/dist/storage.d.ts +50 -18
  76. package/dist/storage.js +125 -30
  77. package/dist/telemetry.d.ts +8 -7
  78. package/dist/token-budget.js +3 -4
  79. package/dist/type-classify.js +4 -4
  80. package/dist/types.d.ts +143 -155
  81. package/domains.example.json +4 -5
  82. package/hermes-plugin/hicortex/README.md +2 -2
  83. package/openclaw.plugin.json +1 -1
  84. package/package.json +4 -1
  85. package/pi-extension/hicortex/README.md +1 -1
  86. package/server.json +3 -3
@@ -38,10 +38,14 @@ 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.warnUnmeteredTokensRun = warnUnmeteredTokensRun;
45
+ exports.isStaleTokenPeriod = isStaleTokenPeriod;
43
46
  exports.shouldThrottleTokens = shouldThrottleTokens;
44
47
  exports.parseJsonLenient = parseJsonLenient;
48
+ exports.scoreMemoriesImportance = scoreMemoriesImportance;
45
49
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
46
50
  exports.discoverLinkCandidates = discoverLinkCandidates;
47
51
  exports.classifyLinkCandidates = classifyLinkCandidates;
@@ -53,8 +57,6 @@ exports.stageDecayPrune = stageDecayPrune;
53
57
  exports.resolveMemorySoftCap = resolveMemorySoftCap;
54
58
  exports.stageMemoryCapEviction = stageMemoryCapEviction;
55
59
  exports.runConsolidation = runConsolidation;
56
- exports.msUntilHour = msUntilHour;
57
- exports.scheduleConsolidation = scheduleConsolidation;
58
60
  const retrieval_js_1 = require("./retrieval.js");
59
61
  const storage = __importStar(require("./storage.js"));
60
62
  const config_read_js_1 = require("./config-read.js");
@@ -68,19 +70,41 @@ const schema_prototypes_js_1 = require("./schema-prototypes.js");
68
70
  const nofit_js_1 = require("./nofit.js");
69
71
  const reconsolidation_js_1 = require("./reconsolidation.js");
70
72
  const dedup_js_1 = require("./dedup.js");
73
+ const CALIBRATION = __importStar(require("./calibration.js"));
71
74
  // Default config constants (matching Python config.py)
72
75
  /**
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).
76
+ * Default ceiling on LLM calls across the WHOLE nightly pipeline (#405; the
77
+ * #241 consolidateMaxLlmCalls mechanism, renamed and widened). A runaway
78
+ * BACKSTOP that bounds money/load INDEPENDENT OF LATENCY — a fast metered or
79
+ * capacity-limited endpoint permits thousands of calls inside the wall-clock
80
+ * budget, so time alone cannot protect it (owner ruling 2026-09-12). Consumed
81
+ * in run order: a stage that exhausts it defers its remainder via its cursor.
82
+ * 5000 clears a one-time classification backlog (a ~2000-memory batch drains
83
+ * in ~1-2 runs) with margin. Config: `nightlyLlmCallBudget` (#405); the old
84
+ * `consolidateMaxLlmCalls` key is a deprecated alias honored one release.
82
85
  */
83
- exports.CONSOLIDATE_MAX_LLM_CALLS = 5000;
86
+ exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET = 5000;
87
+ /**
88
+ * Resolve the per-run LLM call budget from config (#405):
89
+ * - `nightlyLlmCallBudget` present (positive finite) → it wins;
90
+ * - else `consolidateMaxLlmCalls` present → used as a DEPRECATED ALIAS with
91
+ * a warn naming the replacement (honored one release);
92
+ * - absent/invalid → the 5000 default.
93
+ */
94
+ function resolveNightlyLlmCallBudget(config) {
95
+ const c = config ?? {};
96
+ if (c.nightlyLlmCallBudget !== undefined) {
97
+ return (0, config_read_js_1.readPositiveConfig)(c, "nightlyLlmCallBudget", exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET);
98
+ }
99
+ if (c.consolidateMaxLlmCalls !== undefined) {
100
+ const legacy = (0, config_read_js_1.readPositiveConfig)(c, "consolidateMaxLlmCalls", exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET);
101
+ console.warn(`[hicortex] config key "consolidateMaxLlmCalls" is deprecated — renamed ` +
102
+ `"nightlyLlmCallBudget" (same meaning, now the ONE per-run LLM call ceiling). ` +
103
+ `The old key is honored for one release; rename it to clear this warning.`);
104
+ return legacy;
105
+ }
106
+ return exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET;
107
+ }
84
108
  const CONSOLIDATE_PRUNE_MIN_AGE_DAYS = 90;
85
109
  /**
86
110
  * Minimum COSINE similarity for a link candidate.
@@ -133,14 +157,12 @@ class BudgetTracker {
133
157
  callsByStage = {};
134
158
  /**
135
159
  * 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.
160
+ * exhausted (#255). Keys are the same stage labels passed to `use()`; each
161
+ * refused call adds 1 (the dead batch `count` param is gone — #405 —
162
+ * production always passed 1 anyway). Stages break on the first refusal,
163
+ * so a stage's value is the count of requests that crossed the boundary.
164
+ * For item-level skip counts (how many memories or pairs were left
165
+ * unprocessed), see the per-stage reports — e.g.
144
166
  * `stages.importance.skipped_budget` — which count MEMORIES, not call
145
167
  * requests. Surfaced in summary() and ConsolidationReport as
146
168
  * `deferred_by_stage`.
@@ -168,21 +190,21 @@ class BudgetTracker {
168
190
  get remaining() {
169
191
  return Math.max(0, this.maxCalls - this.callsUsed);
170
192
  }
171
- use(stage, count = 1) {
172
- if (this.callsUsed + count > this.maxCalls) {
193
+ use(stage) {
194
+ if (this.callsUsed >= this.maxCalls) {
173
195
  // #255: emit as a STRUCTURED event (not a bare prose warn) so a monitor
174
196
  // can grep/parse `event=budget_exhausted` from journald. The line stays
175
197
  // human-readable (key=value tokens after the [hicortex] prefix). Deferred
176
198
  // counts are accrued BEFORE the log so the line reflects the up-to-date
177
199
  // per-stage toll — the refused count is added to this stage's slot.
178
- this.deferredByStage[stage] = (this.deferredByStage[stage] ?? 0) + count;
200
+ this.deferredByStage[stage] = (this.deferredByStage[stage] ?? 0) + 1;
179
201
  console.warn(`[hicortex] event=budget_exhausted stage=${stage} ` +
180
202
  `calls_used=${this.callsUsed} max_calls=${this.maxCalls} ` +
181
203
  `deferred_by_stage=${JSON.stringify(this.deferredByStage)}`);
182
204
  return false;
183
205
  }
184
- this.callsUsed += count;
185
- this.callsByStage[stage] = (this.callsByStage[stage] ?? 0) + count;
206
+ this.callsUsed += 1;
207
+ this.callsByStage[stage] = (this.callsByStage[stage] ?? 0) + 1;
186
208
  return true;
187
209
  }
188
210
  /**
@@ -220,9 +242,44 @@ class BudgetTracker {
220
242
  }
221
243
  }
222
244
  exports.BudgetTracker = BudgetTracker;
245
+ /**
246
+ * #427 observability: warn when a consolidation run made LLM CALLS but
247
+ * metered ZERO tokens — the endpoint returned no usage objects on its
248
+ * completions (recordUsage skips undefined by design, never fabricates a
249
+ * zero). Such a run still spends budget calls but its snapshot carries token
250
+ * nulls, which read as a mystery on the dashboard. The warn is a structured
251
+ * event in the same journald-greppable style as `event=budget_exhausted`
252
+ * (grep `event=tokens_unmetered`), so the blind spot is visible instead of
253
+ * silent. Returns true when it warned (for tests); no fabrication either
254
+ * way — the numbers stay exactly what the endpoint reported.
255
+ */
256
+ function warnUnmeteredTokensRun(budget) {
257
+ const calls = budget.calls_used ?? 0;
258
+ const tokens = budget.tokens_total?.total ?? 0;
259
+ if (calls <= 0 || tokens > 0)
260
+ return false;
261
+ console.warn(`[hicortex] event=tokens_unmetered calls_used=${calls} — the LLM endpoint ` +
262
+ `returned no usage objects on its completions; this run's snapshot carries ` +
263
+ `no token metering (budget calls were still counted).`);
264
+ return true;
265
+ }
223
266
  // ---------------------------------------------------------------------------
224
267
  // Token fair-use throttle decision (#246)
225
268
  // ---------------------------------------------------------------------------
269
+ /**
270
+ * True when a token-period start stamp is ABSENT or sits in a previous UTC
271
+ * calendar month than `now` — the monthly-reset staleness check. #405: ONE
272
+ * shared helper — the check was triplicated (the nightly's throttle branch,
273
+ * the nightly's accrual write, token-budget.ts recordDistillUsage) and each
274
+ * copy re-derived the year+month comparison by hand.
275
+ */
276
+ function isStaleTokenPeriod(periodStart, now = new Date()) {
277
+ if (!periodStart)
278
+ return true;
279
+ const start = new Date(periodStart);
280
+ return start.getUTCFullYear() !== now.getUTCFullYear() ||
281
+ start.getUTCMonth() !== now.getUTCMonth();
282
+ }
226
283
  /**
227
284
  * Decide whether consolidation should be throttled this run based on the
228
285
  * `llmTokensPerMonth` fair-use cap. Pure (no I/O) so it can be unit-tested
@@ -235,22 +292,14 @@ exports.BudgetTracker = BudgetTracker;
235
292
  *
236
293
  * `cap = 0` (the self-hosted default) → never throttle (unlimited).
237
294
  * `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).
295
+ * (isStaleTokenPeriod — the same helper every monthly-reset site uses, so
296
+ * the sides agree because they read the same state + clock).
240
297
  */
241
298
  function shouldThrottleTokens(cap, period, lastRunTokens, now = new Date()) {
242
299
  if (cap <= 0)
243
300
  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
- }
301
+ // Stale period → reset accrual to 0 before the check.
302
+ const periodTotal = isStaleTokenPeriod(period?.periodStart, now) ? 0 : (period?.total ?? 0);
254
303
  if (periodTotal + lastRunTokens > cap) {
255
304
  return { throttle: true, used: periodTotal, cap };
256
305
  }
@@ -325,29 +374,50 @@ function stagePrecheck(db, stateDir) {
325
374
  // ---------------------------------------------------------------------------
326
375
  // Stage 2: Importance Scoring
327
376
  // ---------------------------------------------------------------------------
328
- async function stageImportance(db, memories, llm, budget, dryRun) {
377
+ /**
378
+ * The shared importance-scoring loop (#425 extraction): one LLM call per
379
+ * 10-memory batch through the production `importanceScoring` prompt, each
380
+ * written score clamped at IMPORTANCE_CEILING and stamped with the
381
+ * importance_scored_at watermark. Used by the nightly's stageImportance AND
382
+ * `hicortex rescore-importance` — there is exactly one scoring code path
383
+ * (no forked backfill logic; cap + watermark write identically everywhere).
384
+ *
385
+ * Failure semantics: a batch whose LLM call THROWS writes nothing (counted
386
+ * in `failed` — retried naturally later); a batch whose reply parses to a
387
+ * non-array falls back to 0.5 per memory (written + watermarked — the
388
+ * endpoint answered, the answer was unusable).
389
+ */
390
+ async function scoreMemoriesImportance(db, memories, llm, opts = {}) {
329
391
  const batchSize = 10;
392
+ const budget = opts.budget;
393
+ const deadline = opts.deadline;
394
+ const dryRun = opts.dryRun ?? false;
330
395
  let scored = 0;
331
396
  let failed = 0;
332
397
  let skippedBudget = 0;
333
398
  for (let i = 0; i < memories.length; i += batchSize) {
334
- if (budget.exhausted) {
399
+ if (budget?.exhausted) {
335
400
  skippedBudget += memories.length - i;
336
401
  break;
337
402
  }
403
+ // #405: the run deadline bounds the batch loop too — a large unscored
404
+ // backlog must not blow the whole run's wall-clock inside one stage.
405
+ // Same stage label as the boundary check, so the defer log fires once.
406
+ if (deadline?.hit("importance"))
407
+ break;
338
408
  const batch = memories.slice(i, i + batchSize);
339
409
  const lines = batch.map((mem, idx) => `[${idx}] ${mem.content.slice(0, 500)}`);
340
410
  const memoriesBlock = lines.join("\n\n");
341
411
  const prompt = (0, prompts_js_1.importanceScoring)(memoriesBlock);
342
412
  if (dryRun)
343
413
  continue;
344
- if (!budget.use("importance")) {
414
+ if (budget && !budget.use("importance")) {
345
415
  skippedBudget += memories.length - i;
346
416
  break;
347
417
  }
348
418
  try {
349
- const r = await llm.completeFast(prompt, 256);
350
- budget.recordUsage("importance", r.usage);
419
+ const r = await llm.complete(prompt);
420
+ budget?.recordUsage("importance", r.usage);
351
421
  let scores = parseJsonLenient(r.text, null);
352
422
  if (!Array.isArray(scores)) {
353
423
  scores = new Array(batch.length).fill(0.5);
@@ -355,6 +425,8 @@ async function stageImportance(db, memories, llm, budget, dryRun) {
355
425
  while (scores.length < batch.length)
356
426
  scores.push(0.5);
357
427
  scores = scores.slice(0, batch.length);
428
+ let batchWritten = 0;
429
+ let batchFailed = 0;
358
430
  for (let j = 0; j < batch.length; j++) {
359
431
  let scoreVal = 0.5;
360
432
  try {
@@ -365,21 +437,37 @@ async function stageImportance(db, memories, llm, budget, dryRun) {
365
437
  catch {
366
438
  scoreVal = 0.5;
367
439
  }
440
+ // #425: the write cap — importance exactly 1.0 has decay rate exactly
441
+ // 1.0 and never decays, so no row is ever born immortal. The scored-at
442
+ // watermark lands in the SAME update, taking the row out of the
443
+ // nightly's unscored pool however it scored (the pre-#425 0.5-sentinel
444
+ // re-rolled genuinely-0.5 rows every night).
445
+ scoreVal = Math.min(scoreVal, CALIBRATION.IMPORTANCE_CEILING);
368
446
  try {
369
- storage.updateMemory(db, batch[j].id, { base_strength: scoreVal });
447
+ storage.updateMemory(db, batch[j].id, {
448
+ base_strength: scoreVal,
449
+ importance_scored_at: new Date().toISOString(),
450
+ });
370
451
  scored++;
452
+ batchWritten++;
371
453
  }
372
454
  catch {
373
455
  failed++;
456
+ batchFailed++;
374
457
  }
375
458
  }
459
+ opts.onBatch?.(batchWritten, batchFailed);
376
460
  }
377
461
  catch {
378
462
  failed += batch.length;
463
+ opts.onBatch?.(0, batch.length);
379
464
  }
380
465
  }
381
466
  return { scored, failed, skipped_budget: skippedBudget };
382
467
  }
468
+ async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
469
+ return scoreMemoriesImportance(db, memories, llm, { budget, deadline, dryRun });
470
+ }
383
471
  // ---------------------------------------------------------------------------
384
472
  // Stage 2.5: Reflection
385
473
  // ---------------------------------------------------------------------------
@@ -408,7 +496,7 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
408
496
  return { lessons_generated: 0, skipped: true, reason: "budget_exhausted" };
409
497
  }
410
498
  try {
411
- const r = await llm.completeReflect(prompt, 2048);
499
+ const r = await llm.complete(prompt);
412
500
  budget.recordUsage("reflection", r.usage);
413
501
  const lessons = parseJsonLenient(r.text, []);
414
502
  if (!Array.isArray(lessons)) {
@@ -458,9 +546,9 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
458
546
  const existingText = similarLessons[0].content.slice(0, 300);
459
547
  const newText = content.slice(0, 300);
460
548
  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` +
549
+ 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
550
  `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);
551
+ `Answer ONLY "yes" or "no". If the new lesson updates/refines the existing one (not contradicts), answer "no".`);
464
552
  // Stage label "contradiction_check" matches the budget.use() call
465
553
  // above (separate counter from the reflection call proper). Token
466
554
  // accounting follows the same stage partition as the call counter.
@@ -543,7 +631,7 @@ function rebuildContentModuleIndex(db, domains, stateDir) {
543
631
  (0, state_js_1.updateState)((s) => { s.moduleIndex = moduleIndex; }, stateDir);
544
632
  return { domains: moduleDomains.length };
545
633
  }
546
- async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, stateDir, weakPrimaryFloor = nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR) {
634
+ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, stateDir, weakPrimaryFloor = nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR, deadline) {
547
635
  // Rows needing (re)classification:
548
636
  // - domain IS NULL (never classified), OR
549
637
  // - domain NOT IN the current vocabulary (a rename/removal re-files), OR
@@ -569,6 +657,11 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
569
657
  // refreshes everything from the updated tag sets anyway).
570
658
  const { prototypes: startPrototypes } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
571
659
  for (const row of rows) {
660
+ // #405: the run deadline bounds the classification row loop (a large
661
+ // backlog must defer, not blow the wall-clock). Same stage label as the
662
+ // boundary check, so the defer log fires once.
663
+ if (deadline?.hit("domain_curation"))
664
+ break;
572
665
  if (budget.exhausted || !budget.use("content_domain")) {
573
666
  console.warn(`[hicortex] content-domain: budget exhausted after ${classified} classified`);
574
667
  break;
@@ -716,7 +809,7 @@ async function stageDomainCuration(db, llm, budget, dryRun, stateDir) {
716
809
  .map((r) => `${r.project}: ${r.cnt} / ${lessonsByProject.get(r.project) ?? 0}`)
717
810
  .join("\n");
718
811
  try {
719
- const r = await llm.completeFast((0, prompts_js_1.domainCuration)(projectLines), 1024);
812
+ const r = await llm.complete((0, prompts_js_1.domainCuration)(projectLines));
720
813
  budget.recordUsage("domain_curation", r.usage);
721
814
  const parsed = parseJsonLenient(r.text, []);
722
815
  if (!Array.isArray(parsed) || parsed.length === 0) {
@@ -847,19 +940,16 @@ function discoverLinkCandidates(db, mem, embedding) {
847
940
  * 672-link audit (see the Stage 3 header) found the LLM-classified UPPERCASE
848
941
  * types near-useless (CONTRADICTS 4% acceptable). Every candidate now takes its
849
942
  * 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.
943
+ * classifyRelationship). No LLM call is made. #405: the ignored `llm`/`budget`
944
+ * params are deleted — the signature now tells the truth.
945
+ * The return shape is unchanged; `llmClassified` is always 0 and
946
+ * `heuristicFallback` counts every candidate. Do NOT re-add an LLM path here
947
+ * without a classifier that passes the audit harness at >= 70% acceptable.
858
948
  *
859
949
  * Shared between the nightly `stageLinks` and `hicortex relink`.
860
950
  * Returns one relationship type per candidate (same order as input).
861
951
  */
862
- async function classifyLinkCandidates(candidates, _llm, _budget) {
952
+ async function classifyLinkCandidates(candidates) {
863
953
  const types = candidates.map((c) => c.heuristicType);
864
954
  return { types, llmClassified: 0, heuristicFallback: candidates.length };
865
955
  }
@@ -880,8 +970,9 @@ async function stageLinks(db, memories, embedFn, dryRun, llm, budget) {
880
970
  if (candidates.length === 0) {
881
971
  return { auto_linked: 0, llm_classified: 0, heuristic_fallback: 0, failed };
882
972
  }
883
- // Phase B: LLM batch classification (heuristic fallback inside)
884
- const { types: classifiedTypes, llmClassified, heuristicFallback } = await classifyLinkCandidates(candidates, llm, budget);
973
+ // Phase B: heuristic-only classification (LLM retired; #405 dropped the
974
+ // dead llm/budget params)
975
+ const { types: classifiedTypes, llmClassified, heuristicFallback } = await classifyLinkCandidates(candidates);
885
976
  // Phase C: Store all classified links
886
977
  for (let i = 0; i < candidates.length; i++) {
887
978
  const c = candidates[i];
@@ -930,7 +1021,9 @@ function classifyRelationship(source, target, similarity) {
930
1021
  // Stage 3.5: Hub Detection & Strength Boost
931
1022
  // ---------------------------------------------------------------------------
932
1023
  const HUB_BOOST = 0.1;
933
- const HUB_STRENGTH_CAP = 1.0;
1024
+ // #425: the release-managed importance ceiling (calibration.ts) — hub boosts
1025
+ // can no longer push a row to base 1.0 (decay rate exactly 1.0 = immortal).
1026
+ const HUB_STRENGTH_CAP = CALIBRATION.IMPORTANCE_CEILING;
934
1027
  function stageHubBoost(db, dryRun) {
935
1028
  const hubs = (0, graph_js_1.detectHubs)(db);
936
1029
  if (hubs.length === 0)
@@ -968,26 +1061,17 @@ function stageHubBoost(db, dryRun) {
968
1061
  // this is a judgment call about content, not a duplicate).
969
1062
  //
970
1063
  // 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;
1064
+ // the corpus is back-processed gradually) whose shape suggests a
1065
+ // decision/correction. For each, KNN top-5 OLDER same-shape neighbors
1066
+ // at/above the release-managed similarity floor (calibration.ts); one constrained classify-tier LLM
1067
+ // call per pair decides `superseded: true| false`. A parse/infra error skips
1068
+ // just that PAIR (retried naturally next night since the cursor still
1069
+ // advances past the memory — see the cursor note below); it never mis-links.
1070
+ // #405: no per-stage call cap — the ONE run budget (nightlyLlmCallBudget)
1071
+ // and the run deadline are the only bounds, like every other stage.
1072
+ /** Default minimum COSINE similarity for a supersession candidate pair —
1073
+ * RELEASE-MANAGED since #408 (calibration.ts SUPERSESSION_MIN_SIMILARITY). */
1074
+ exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = CALIBRATION.SUPERSESSION_MIN_SIMILARITY;
991
1075
  /** Default multiplier applied to a superseded memory's base_strength. */
992
1076
  /** Floor under which a superseded memory's base_strength never drops. */
993
1077
  /** Neighbor pool size before shape/older/similarity filtering narrows to top 5. */
@@ -1067,7 +1151,7 @@ function parseSupersessionReply(reply) {
1067
1151
  */
1068
1152
  async function classifySupersession(llm, oldContent, newContent) {
1069
1153
  try {
1070
- const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent));
1154
+ const r = await llm.complete(buildSupersessionPrompt(oldContent, newContent));
1071
1155
  return { verdict: parseSupersessionReply(r.text), usage: r.usage };
1072
1156
  }
1073
1157
  catch {
@@ -1107,6 +1191,13 @@ async function findOlderNeighbors(db, candidate, embedFn, minSimilarity) {
1107
1191
  * candidacy. It only stops SHORT of a candidate when the budget is already
1108
1192
  * exhausted before that candidate starts, so the cursor never skips a
1109
1193
  * candidate that was never looked at.
1194
+ *
1195
+ * #405: the cursor persists after EVERY fully-considered candidate (the
1196
+ * post-#404 reconsolidation pattern), not at stage end — a run killed or
1197
+ * deadline-deferred mid-stage loses at most the candidate in flight. No
1198
+ * orphan clamp is needed (unlike reconsolidation): supersession applies each
1199
+ * verdict's link immediately, so `cursor = candidate.__rowid` always sits
1200
+ * after all of that candidate's writes.
1110
1201
  */
1111
1202
  async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
1112
1203
  // Config values pass through `unknown`-typed JSON — validate rather than
@@ -1116,7 +1207,6 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1116
1207
  return Number.isFinite(n) && ok(n) ? n : fallback;
1117
1208
  };
1118
1209
  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
1210
  const startCursor = (0, state_js_1.loadState)(stateDir).supersessionCursor ?? 0;
1121
1211
  const rows = db
1122
1212
  .prepare(
@@ -1136,10 +1226,26 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1136
1226
  let superseded = 0;
1137
1227
  let skippedInfra = 0;
1138
1228
  let skippedIdempotent = 0;
1139
- let callsUsed = 0;
1140
1229
  let cursor = startCursor;
1230
+ // #405: per-candidate checkpoint — persists the cursor after every fully
1231
+ // considered candidate (updateState is an atomic temp-rename of a small
1232
+ // file; the loop cadence is seconds per candidate, so the cost is
1233
+ // negligible). The end-of-stage write below stays the authoritative final
1234
+ // write.
1235
+ const persistCursor = () => {
1236
+ if (dryRun)
1237
+ return;
1238
+ (0, state_js_1.updateState)((s) => {
1239
+ s.supersessionCursor = cursor;
1240
+ }, stateDir);
1241
+ };
1141
1242
  for (const candidate of rows) {
1142
- if (!dryRun && ((maxCalls > 0 && callsUsed >= maxCalls) || budget.exhausted))
1243
+ // #405: the ONE run budget is the only call cap; the deadline stops the
1244
+ // scan at the candidate boundary — the cursor holds at the last
1245
+ // fully-considered candidate (persisted below).
1246
+ if (!dryRun && budget.exhausted)
1247
+ break;
1248
+ if (!dryRun && options.deadline?.hit("supersession"))
1143
1249
  break;
1144
1250
  scanned++;
1145
1251
  let neighbors;
@@ -1149,6 +1255,7 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1149
1255
  catch (err) {
1150
1256
  console.warn(`[hicortex] supersession: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
1151
1257
  cursor = candidate.__rowid;
1258
+ persistCursor(); // #405: every exit path persists
1152
1259
  continue;
1153
1260
  }
1154
1261
  for (const neighbor of neighbors) {
@@ -1158,9 +1265,8 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1158
1265
  }
1159
1266
  if (dryRun)
1160
1267
  continue; // preview only — no LLM call, no write
1161
- if ((maxCalls > 0 && callsUsed >= maxCalls) || !budget.use("supersession"))
1162
- break;
1163
- callsUsed++;
1268
+ if (!budget.use("supersession"))
1269
+ break; // #405: the ONE run budget
1164
1270
  const { verdict, usage } = await classifySupersession(llm, neighbor.content, candidate.content);
1165
1271
  // Meter every round-tripped attempt (#246) — even a null verdict spent
1166
1272
  // real tokens. The stage label matches the budget.use() above.
@@ -1184,6 +1290,10 @@ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, opt
1184
1290
  }
1185
1291
  }
1186
1292
  cursor = candidate.__rowid;
1293
+ // #405: checkpoint after every fully-considered candidate (post-#404
1294
+ // reconsolidation pattern) — a killed or deadline-deferred run loses at
1295
+ // most the candidate in flight.
1296
+ persistCursor();
1187
1297
  }
1188
1298
  if (!dryRun) {
1189
1299
  (0, state_js_1.updateState)((s) => {
@@ -1311,7 +1421,15 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1311
1421
  // rejects negatives at the boundary, but this stage is callable directly).
1312
1422
  if (cap <= 0)
1313
1423
  return { cap, evicted: 0 };
1314
- const count = storage.countMemories(db);
1424
+ // #422 (#317 discipline): the cap keys off LIVE (non-absorbed) rows on BOTH
1425
+ // the count and the victim SELECT — absorbed rows are invisible evidence
1426
+ // (no vector, no FTS, recall never serves them); they must neither consume
1427
+ // cap headroom nor be picked as eviction victims. The DISPLAYED headroom
1428
+ // (dashboard.ts headline live_memories vs memory_soft_cap) reads the same
1429
+ // predicate, so the enforced and displayed caps cannot disagree.
1430
+ const count = db
1431
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE COALESCE(status, '') != 'absorbed'")
1432
+ .get().c;
1315
1433
  if (count <= cap)
1316
1434
  return { cap, evicted: 0 };
1317
1435
  const surplus = count - cap;
@@ -1319,10 +1437,12 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1319
1437
  // NOT NULL after scoring; the `?? 0.5` mirrors stageDecayPrune's defensive
1320
1438
  // default for unscored rows (inserts at 0.5). last_accessed is NULL until
1321
1439
  // first /recall-index exposure — COALESCE to created_at for the tiebreak so
1322
- // never-shown memories sort by when they entered the corpus.
1440
+ // never-shown memories sort by when they entered the corpus. Same
1441
+ // non-absorbed predicate as the count above.
1323
1442
  const rows = db
1324
1443
  .prepare(`SELECT id, base_strength, last_accessed, access_count, created_at
1325
- FROM memories`)
1444
+ FROM memories
1445
+ WHERE COALESCE(status, '') != 'absorbed'`)
1326
1446
  .all();
1327
1447
  const linkCounts = storage.getAllLinkCounts(db);
1328
1448
  const now = new Date();
@@ -1376,21 +1496,23 @@ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1376
1496
  return Number.isFinite(n) && ok(n) ? n : fallback;
1377
1497
  };
1378
1498
  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
1499
  const merges = await (0, dedup_js_1.runDeterministicMergeZone)(db, {
1381
1500
  stateDir,
1382
1501
  threshold: autoMergeThreshold,
1383
- maxMerges,
1384
1502
  dryRun,
1385
1503
  acquireLock: options.acquireLock,
1504
+ deadline: options.deadline,
1386
1505
  });
1387
1506
  const bandStats = {};
1388
- if (merges.max_merges > 0) {
1507
+ {
1508
+ // #405: recorded whenever the zone ran (the old max_merges>0 gate was a
1509
+ // 0=disabled switch — the switch is gone).
1389
1510
  bandStats[`>=${autoMergeThreshold}`] = {
1390
1511
  pairs: merges.losers_merged,
1391
1512
  merge: merges.losers_merged,
1392
1513
  corrects: 0,
1393
1514
  supersedes: 0,
1515
+ conflicts: 0,
1394
1516
  none: 0,
1395
1517
  merge_below_gate: 0,
1396
1518
  conf_sum: merges.losers_merged,
@@ -1416,29 +1538,52 @@ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1416
1538
  explicit_verified: 0,
1417
1539
  explicit_divergent: 0,
1418
1540
  cursor: (0, state_js_1.loadState)(stateDir).reconsolidationCursor ?? 0,
1541
+ // #439 fields: zeros on a quiet night (no scan ran — nothing re-judged,
1542
+ // new, skipped, or deferred; the type carries them so the report surface
1543
+ // stays uniform).
1544
+ pairs_reevaluated: 0,
1545
+ pairs_new: 0,
1546
+ skipped_absorbed: 0,
1547
+ merge_pairs_deferred: 0,
1419
1548
  merges,
1420
1549
  merge_pairs_applied: 0,
1421
1550
  merge_below_gate: 0,
1422
1551
  skipped_above_ceiling: 0,
1423
1552
  skipped_metadata_mismatch: 0,
1553
+ conflict_flagged: 0, // guard-C: no scan on a quiet night — nothing flagged
1554
+ conflict_skipped: merges.skipped_conflict, // guard-C: the zone's guard still counts
1555
+ scout_scanned: 0, // #393 B: the scan (and its shape calls) doesn't run on a quiet night
1556
+ scout_correction_shaped: 0,
1557
+ scout_candidates_found: 0,
1424
1558
  band_stats: bandStats,
1425
1559
  };
1426
1560
  }
1427
1561
  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). */
1562
+ /** The ONE per-run LLM-call ceiling (#405/#241). The caller resolves
1563
+ * `nightlyLlmCallBudget` from config (consolidateMaxLlmCalls is a
1564
+ * deprecated alias — resolveNightlyLlmCallBudget) and passes it; unset →
1565
+ * the exported DEFAULT_NIGHTLY_LLM_CALL_BUDGET (5000). */
1431
1566
  budgetMaxCalls,
1432
1567
  /** Soft cap on the corpus (#245). Nightly.ts reads `memorySoftCap` from
1433
1568
  * config and passes it; unset → `DEFAULT_MEMORY_SOFT_CAP` (10000). `0`
1434
1569
  * disables eviction (indefinite growth). */
1435
1570
  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) {
1571
+ /** Reconsolidation-stage knobs (#384) — eval/test seams since #408 (the
1572
+ * values are release-managed calibration constants; nightly.ts threads
1573
+ * NOTHING), exactly like supersessionOptions above; unset fields → the
1574
+ * stage's calibration defaults. Appended AFTER the pre-#384 params so
1575
+ * every existing positional caller (tests, hosted nightly) keeps its
1576
+ * argument meaning. */
1577
+ reconsolidationOptions,
1578
+ /**
1579
+ * The run-wide pipeline deadline (#405), created at nightly start and
1580
+ * shared by capture + every consolidation stage. Absent = no deadline
1581
+ * (tests, evict-only paths, pre-#405 callers). When it fires, every
1582
+ * not-yet-run stage defers (logs event=deadline_deferred stage=<name>) and
1583
+ * the report status becomes "deferred" — which keeps lastConsolidated
1584
+ * un-advanced so the next run re-finds the pending work.
1585
+ */
1586
+ deadline) {
1442
1587
  const start = new Date();
1443
1588
  const report = {
1444
1589
  started_at: start.toISOString(),
@@ -1483,20 +1628,31 @@ reconsolidationOptions) {
1483
1628
  // stage report (telemetry's "skipped = zero LLM work" stays true), and
1484
1629
  // the zone never runs twice: the main path runs it INSIDE the stage, this
1485
1630
  // skip path returns before that.
1486
- report.stages.reconsolidation = await skippedRunResolutionReport(db, dryRun, stateDir, reconsolidationOptions);
1631
+ report.stages.reconsolidation = await skippedRunResolutionReport(db, dryRun, stateDir, { ...reconsolidationOptions, deadline });
1487
1632
  report.status = "skipped";
1488
1633
  report.completed_at = new Date().toISOString();
1489
1634
  return report;
1490
1635
  }
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);
1636
+ // #405: the ONE per-run LLM-call ceiling (default 5000). The caller
1637
+ // resolves `nightlyLlmCallBudget` from config (consolidateMaxLlmCalls is a
1638
+ // deprecated alias — see resolveNightlyLlmCallBudget) and passes it here.
1639
+ const budget = new BudgetTracker(budgetMaxCalls ?? exports.DEFAULT_NIGHTLY_LLM_CALL_BUDGET);
1640
+ console.log(`[hicortex] Consolidation LLM call budget: ${budget.maxCalls} calls`);
1495
1641
  try {
1642
+ // #405 stage gating: each boundary checks the run deadline; a hit defers
1643
+ // that stage (absent from the report — it did not run) and logs
1644
+ // event=deadline_deferred stage=<name> once. Later boundaries check
1645
+ // independently, so a mid-run deadline reports every remaining stage as
1646
+ // deferred. Deferred stages drain next run (cursors hold below them).
1496
1647
  // Stage 2: Importance Scoring
1497
- report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun);
1648
+ if (!deadline?.hit("importance")) {
1649
+ report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun, deadline);
1650
+ }
1498
1651
  // Stage 2.5: Reflection
1499
- if (skipReflection) {
1652
+ if (deadline?.hit("reflection")) {
1653
+ // deferred — stage absent from the report
1654
+ }
1655
+ else if (skipReflection) {
1500
1656
  report.stages.reflection = {
1501
1657
  lessons_generated: 0,
1502
1658
  skipped: true,
@@ -1511,40 +1667,60 @@ reconsolidationOptions) {
1511
1667
  // domain list is configured. The single model serves all phases; if it's
1512
1668
  // down, the phase skips and retries on the next nightly run (no fallback).
1513
1669
  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
- };
1670
+ if (!deadline?.hit("domain_curation")) {
1671
+ if (cfgDomains && cfgDomains.length > 0) {
1672
+ if (domainOptions?.contentDomainsReady === false) {
1673
+ report.stages.domain_curation = {
1674
+ curated: false,
1675
+ domains: cfgDomains.length,
1676
+ reason: "reflect_endpoint_offline",
1677
+ };
1678
+ }
1679
+ else {
1680
+ report.stages.domain_curation = await stageContentDomains(db, cfgDomains, llm, budget, embedFn, dryRun, stateDir, domainOptions?.weakPrimaryFloor ?? nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR, deadline);
1681
+ }
1521
1682
  }
1522
1683
  else {
1523
- report.stages.domain_curation = await stageContentDomains(db, cfgDomains, llm, budget, embedFn, dryRun, stateDir, domainOptions?.weakPrimaryFloor ?? nofit_js_1.DEFAULT_WEAK_PRIMARY_FLOOR);
1684
+ report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
1524
1685
  }
1525
1686
  }
1526
- else {
1527
- report.stages.domain_curation = await stageDomainCuration(db, llm, budget, dryRun, stateDir);
1687
+ // Stage 3: Link Discovery (heuristic edge classification — local work,
1688
+ // but bounded by the same deadline as every other stage).
1689
+ if (!deadline?.hit("links")) {
1690
+ report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
1528
1691
  }
1529
- // Stage 3: Link Discovery (with LLM-assisted edge classification)
1530
- report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
1531
1692
  // Stage 3.5: Hub Detection — boost highly-connected memories
1532
- report.stages.hub_boost = stageHubBoost(db, dryRun);
1693
+ if (!deadline?.hit("hub_boost")) {
1694
+ report.stages.hub_boost = stageHubBoost(db, dryRun);
1695
+ }
1533
1696
  // Stage 3.7: Supersession Detection (#191 Phase B)
1534
- report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
1697
+ if (!deadline?.hit("supersession")) {
1698
+ report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, { ...supersessionOptions, deadline });
1699
+ }
1535
1700
  // Stage 3.8: Reconsolidation (#384) — resolve corrections: rewrite
1536
1701
  // fact-shaped targets in place (absorbing transition-only triggers),
1537
1702
  // mark everything else. Rides the same shared budget under its own stage
1538
1703
  // label + cursor (supersession-stage pattern).
1539
- report.stages.reconsolidation = await (0, reconsolidation_js_1.stageReconsolidation)(db, llm, budget, embedFn, dryRun, stateDir, reconsolidationOptions);
1704
+ if (!deadline?.hit("reconsolidation")) {
1705
+ report.stages.reconsolidation = await (0, reconsolidation_js_1.stageReconsolidation)(db, llm, budget, embedFn, dryRun, stateDir, { ...reconsolidationOptions, deadline });
1706
+ }
1540
1707
  // Stage 4: Decay & Prune
1541
- report.stages.decay_prune = stageDecayPrune(db, dryRun);
1708
+ if (!deadline?.hit("decay_prune")) {
1709
+ report.stages.decay_prune = stageDecayPrune(db, dryRun);
1710
+ }
1542
1711
  // (Memory cap eviction moved before the precheck skip — see above.)
1543
1712
  }
1544
1713
  catch (err) {
1545
1714
  report.status = "failed";
1546
1715
  console.error("[hicortex] Consolidation pipeline error:", err);
1547
1716
  }
1717
+ // #405: any deferred stage ⇒ the run is "deferred", not "completed" — the
1718
+ // lastConsolidated gate below then holds the watermark so the next run's
1719
+ // pending-set queries re-find the deferred work (mirrors endpoint_down).
1720
+ // A thrown error still wins ("failed" is the more specific outcome).
1721
+ if (report.status === "completed" && deadline && deadline.deferredStages().length > 0) {
1722
+ report.status = "deferred";
1723
+ }
1548
1724
  // Update last-consolidated timestamp. #357: the stages fail SOFT, so a run
1549
1725
  // against an endpoint that died mid-way still reports status "completed"
1550
1726
  // here — advancing the timestamp made state.json disagree with the
@@ -1568,59 +1744,3 @@ reconsolidationOptions) {
1568
1744
  Math.round((Date.now() - start.getTime()) / 100) / 10;
1569
1745
  return report;
1570
1746
  }
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
- }