@gamaze/hicortex 0.20.9 → 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 (48) hide show
  1. package/README.md +8 -0
  2. package/assets/dashboard.html +3989 -836
  3. package/dist/calibration.d.ts +119 -0
  4. package/dist/calibration.js +149 -1
  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 +9 -0
  10. package/dist/capture.js +2 -1
  11. package/dist/cli.js +36 -0
  12. package/dist/consolidate.d.ts +35 -0
  13. package/dist/consolidate.js +85 -9
  14. package/dist/dashboard.d.ts +322 -3
  15. package/dist/dashboard.js +592 -7
  16. package/dist/db.js +105 -0
  17. package/dist/eval/importance-eval.d.ts +85 -0
  18. package/dist/eval/importance-eval.js +286 -0
  19. package/dist/eval/planted-fixtures.d.ts +1 -1
  20. package/dist/eval/ranking-battery.d.ts +78 -0
  21. package/dist/eval/ranking-battery.js +181 -0
  22. package/dist/eval/ranking-eval.d.ts +41 -0
  23. package/dist/eval/ranking-eval.js +391 -0
  24. package/dist/eval/ranking-fixtures.d.ts +77 -0
  25. package/dist/eval/ranking-fixtures.js +226 -0
  26. package/dist/identity-store.d.ts +21 -0
  27. package/dist/identity-store.js +49 -0
  28. package/dist/init.d.ts +14 -0
  29. package/dist/init.js +32 -0
  30. package/dist/mcp-server.d.ts +12 -0
  31. package/dist/mcp-server.js +184 -3
  32. package/dist/nightly.d.ts +9 -1
  33. package/dist/nightly.js +59 -7
  34. package/dist/prompts.d.ts +10 -0
  35. package/dist/prompts.js +28 -5
  36. package/dist/reconsolidation.d.ts +59 -30
  37. package/dist/reconsolidation.js +526 -296
  38. package/dist/rescore-importance.d.ts +80 -0
  39. package/dist/rescore-importance.js +236 -0
  40. package/dist/retrieval.d.ts +12 -0
  41. package/dist/retrieval.js +30 -1
  42. package/dist/stages.d.ts +37 -0
  43. package/dist/stages.js +51 -0
  44. package/dist/state.d.ts +32 -6
  45. package/dist/storage.d.ts +34 -2
  46. package/dist/storage.js +63 -6
  47. package/dist/types.d.ts +48 -0
  48. package/package.json +3 -1
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * Operator capture pause (#423 phase 3, D3) — the server-side 200-skip.
4
+ *
5
+ * A pause is a row in `capture_pauses` (migration v18): machine × harness →
6
+ * paused_at. A row EXISTS = paused for that bundle; the /distill handler
7
+ * reads the table per POST, so a pause takes effect on the very next post —
8
+ * no restart — and answers 200 {skipped: true, paused: true}. The 200 is the
9
+ * whole point: capture.ts treats every 200 as confirmed and advances its
10
+ * cursor, so sessions that arrive while paused are deliberately NOT captured
11
+ * and are never re-sent or backfilled. Zero client changes.
12
+ *
13
+ * THE BUNDLE KEY. The pause's (machine, harness) must derive EXACTLY like
14
+ * the traffic it gates: machine via storage.sanitizeSourceMachine ('' when
15
+ * absent) and harness via harnessOfAgent below — the same normalization
16
+ * recordDistillActivity applies when it writes distill_activity, and the same
17
+ * "harness/profile" → "harness" split the console groups its bundles on. A
18
+ * key derived any other way would never match and the pause would silently
19
+ * not fire.
20
+ *
21
+ * LAST-SEEN (presence dots) derives ONLY from /distill activity — the one
22
+ * per-agent-identified traffic the server sees. Recall traffic (/search,
23
+ * /recall-index, /memory) carries no agent/machine identity on the wire, so
24
+ * attributing it would need new client fields — the heartbeats the spec
25
+ * forbids. Thresholds (green ≤36h — a nightly poster reads online through
26
+ * the following day; amber ≤7d — the distill_activity retention window; none
27
+ * beyond or with no rows) are page-side presentation; this module just
28
+ * reports the newest ts per bundle.
29
+ *
30
+ * Pure, unit-testable without express (the capture-health.ts layering):
31
+ * mcp-server.ts and dashboard.ts wire these functions to the live db.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.harnessOfAgent = harnessOfAgent;
35
+ exports.capturePauseKey = capturePauseKey;
36
+ exports.isCapturePaused = isCapturePaused;
37
+ exports.setCapturePause = setCapturePause;
38
+ exports.listCapturePauses = listCapturePauses;
39
+ exports.readFleetLastSeen = readFleetLastSeen;
40
+ const storage_js_1 = require("./storage.js");
41
+ /**
42
+ * Derive the harness from a source_agent wire value — the bundle split the
43
+ * console already uses ("claude-code/main" → "claude-code"): the part before
44
+ * the first '/' when a slash is present at index > 0, else the whole trimmed
45
+ * string, capped at 128. Non-string/blank → "unknown" (mirrors how
46
+ * recordDistillActivity stores the agent when absent).
47
+ */
48
+ function harnessOfAgent(sourceAgent) {
49
+ if (typeof sourceAgent !== "string")
50
+ return "unknown";
51
+ const t = sourceAgent.trim();
52
+ if (t.length === 0)
53
+ return "unknown";
54
+ const slash = t.indexOf("/");
55
+ return (slash > 0 ? t.slice(0, slash) : t).slice(0, 128);
56
+ }
57
+ /**
58
+ * Normalize the raw /distill wire fields into the pause key. MUST match the
59
+ * recordDistillActivity normalization (machine '' when absent, agent
60
+ * 'unknown' when absent) and the console's bundle grouping
61
+ * ((machine||'')+'|'+harness) — see the module doc.
62
+ */
63
+ function capturePauseKey(machine, sourceAgent) {
64
+ return {
65
+ machine: (0, storage_js_1.sanitizeSourceMachine)(machine) ?? "",
66
+ harness: harnessOfAgent(sourceAgent),
67
+ };
68
+ }
69
+ /** True when a pause row exists for the (machine, harness) bundle. */
70
+ function isCapturePaused(db, machine, harness) {
71
+ return (db
72
+ .prepare("SELECT 1 FROM capture_pauses WHERE machine = ? AND harness = ?")
73
+ .get(machine, harness) !== undefined);
74
+ }
75
+ /**
76
+ * Pause (upsert the row, timestamp now) or resume (delete it). Returns the
77
+ * persisted paused_at when pausing, null when resuming. No pruning, ever —
78
+ * see migration v18's provenance comment.
79
+ */
80
+ function setCapturePause(db, machine, harness, paused) {
81
+ if (!paused) {
82
+ db.prepare("DELETE FROM capture_pauses WHERE machine = ? AND harness = ?").run(machine, harness);
83
+ return null;
84
+ }
85
+ const pausedAt = new Date().toISOString();
86
+ db.prepare("INSERT OR REPLACE INTO capture_pauses (machine, harness, paused_at) VALUES (?, ?, ?)").run(machine, harness, pausedAt);
87
+ return pausedAt;
88
+ }
89
+ /** Every paused bundle (newest first) — the dashboard fleet.pauses block. */
90
+ function listCapturePauses(db) {
91
+ return db
92
+ .prepare("SELECT machine, harness, paused_at FROM capture_pauses ORDER BY paused_at DESC")
93
+ .all();
94
+ }
95
+ /**
96
+ * The newest /distill activity per bundle: for each (machine, agent) take
97
+ * MAX(ts) with that latest row's outcome, derive the harness per agent, then
98
+ * merge same-bundle agents keeping the newest ts (one dot per bundle, not
99
+ * per profile). Reads only distill_activity, which the recorder prunes to
100
+ * 7 days — older-than-window bundles simply have no rows and no dot.
101
+ */
102
+ function readFleetLastSeen(db) {
103
+ const rows = db
104
+ .prepare(`SELECT a.machine, a.agent, a.ts, a.outcome
105
+ FROM distill_activity a
106
+ JOIN (
107
+ SELECT machine, agent, MAX(ts) AS max_ts
108
+ FROM distill_activity
109
+ GROUP BY machine, agent
110
+ ) latest
111
+ ON a.machine = latest.machine AND a.agent = latest.agent AND a.ts = latest.max_ts`)
112
+ .all();
113
+ const byBundle = new Map();
114
+ for (const r of rows) {
115
+ const key = `${r.machine}|${harnessOfAgent(r.agent)}`;
116
+ const prev = byBundle.get(key);
117
+ if (!prev || r.ts > prev.last_seen) {
118
+ byBundle.set(key, {
119
+ machine: r.machine,
120
+ harness: harnessOfAgent(r.agent),
121
+ last_seen: r.ts,
122
+ last_outcome: r.outcome,
123
+ });
124
+ }
125
+ }
126
+ return [...byBundle.values()].sort((a, b) => (a.last_seen < b.last_seen ? 1 : -1));
127
+ }
package/dist/capture.d.ts CHANGED
@@ -57,6 +57,9 @@ export interface DistillBody {
57
57
  source_agent_id?: string | null;
58
58
  /** Client-declared topic/domain of the capturing agent. Provenance only. */
59
59
  source_domain?: string | null;
60
+ /** Machine this capture ran on (#421 machine × harness): config
61
+ * `machineName` ?? os.hostname(), stamped by the nightly. */
62
+ source_machine?: string | null;
60
63
  project: string;
61
64
  session_id: string;
62
65
  segment_id: string;
@@ -106,6 +109,12 @@ export interface CaptureOptions {
106
109
  * `source_domain` provenance. Null when undeclared.
107
110
  */
108
111
  sourceDomain?: string | null;
112
+ /**
113
+ * Machine stamp on every segment (#421 machine × harness): config
114
+ * `machineName` when set, else os.hostname() — resolved by the nightly
115
+ * caller. Null disables stamping.
116
+ */
117
+ sourceMachine?: string | null;
109
118
  /**
110
119
  * The run-wide pipeline deadline (#405), checked BETWEEN segment POSTs —
111
120
  * a boundary the per-session cursor discipline already guarantees is safe
package/dist/capture.js CHANGED
@@ -204,7 +204,7 @@ async function postWithRateRetry(post, body) {
204
204
  * re-paying the Retry-After ladder (#327).
205
205
  */
206
206
  async function captureBatches(batches, opts) {
207
- const { post, cursorStore, dryRun = false, segmentMaxChars = exports.SEGMENT_MAX_CHARS, sourceAgentId, sourceDomain, deadline } = opts;
207
+ const { post, cursorStore, dryRun = false, segmentMaxChars = exports.SEGMENT_MAX_CHARS, sourceAgentId, sourceDomain, sourceMachine, deadline } = opts;
208
208
  let memoriesIngested = 0;
209
209
  let sessionsSent = 0;
210
210
  let hadTransientFailure = false;
@@ -276,6 +276,7 @@ async function captureBatches(batches, opts) {
276
276
  source_agent: batch.sourceAgent ?? `claude-code/${batch.projectName}`,
277
277
  source_agent_id: sourceAgentId ?? null,
278
278
  source_domain: sourceDomain ?? null,
279
+ source_machine: sourceMachine ?? null,
279
280
  project: batch.projectName,
280
281
  session_id: batch.sessionId,
281
282
  segment_id: `${genPrefix}${seg.segStart}-${seg.segEnd}${seg.idSuffix}`,
package/dist/cli.js CHANGED
@@ -189,6 +189,36 @@ switch (command) {
189
189
  });
190
190
  break;
191
191
  }
192
+ case "rescore-importance": {
193
+ // #425 — one-shot LLM backfill: re-judge the corpus under the
194
+ // re-anchored importance rubric. classify-domains shape (resumable
195
+ // cursor, --batch, --reset) + dedup discipline (dry-run default,
196
+ // --apply, DB backup before any write).
197
+ const args = process.argv.slice(3);
198
+ const intFlag = (name) => {
199
+ const idx = args.indexOf(name);
200
+ if (idx === -1)
201
+ return undefined;
202
+ const val = parseInt(args[idx + 1], 10);
203
+ if (isNaN(val)) {
204
+ console.error(`[hicortex] rescore-importance: ${name} requires an integer value`);
205
+ process.exit(1);
206
+ }
207
+ return val;
208
+ };
209
+ const rescoreOptions = {
210
+ apply: args.includes("--apply"),
211
+ reset: args.includes("--reset"),
212
+ batchSize: intFlag("--batch"),
213
+ };
214
+ import("./rescore-importance.js").then(({ runRescoreImportance }) => {
215
+ runRescoreImportance(rescoreOptions).catch((err) => {
216
+ console.error(err instanceof Error ? err.message : `[hicortex] rescore-importance failed: ${err}`);
217
+ process.exit(1);
218
+ });
219
+ });
220
+ break;
221
+ }
192
222
  case "classify-types": {
193
223
  const args = process.argv.slice(3);
194
224
  const intFlag = (name) => {
@@ -421,6 +451,8 @@ Commands:
421
451
  backup Snapshot the DB + identity + state to a tar.gz (online, WAL-safe)
422
452
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
423
453
  classify-types Backfill episode→fact/decision type tags over the corpus (server mode)
454
+ rescore-importance Re-judge all memories' importance under the current rubric
455
+ (server mode; dry run by default — --apply executes; resumable)
424
456
  learnings-identity Fetch identity + lessons and print Markdown to stdout (CC SessionStart hook)
425
457
  (alias: lessons-context — the pre-#264 name, kept for backcompat)
426
458
  recall-hook Pushed recall index for the current prompt (CC UserPromptSubmit/SessionStart hook)
@@ -458,6 +490,10 @@ Options:
458
490
  classify-types --all Reclassify every memory (default: only episodes)
459
491
  classify-types --batch <n> Memories per batch (default: 200)
460
492
  classify-types --reset Restart from the beginning (ignore saved cursor)
493
+ rescore-importance --apply Execute the importance backfill (default: dry run, report only)
494
+ Takes a DB backup first; resumable via a state.json cursor
495
+ rescore-importance --batch <n> Rows per invocation (default: 500; LLM calls are 10 rows each)
496
+ rescore-importance --reset Restart from the beginning (ignore saved cursor)
461
497
  identity show [name] Print all identity sections, or just <name> (raw, pipeable)
462
498
  identity edit <name> Edit a section in $EDITOR; PUT only if changed
463
499
  identity … --agent <id> Target a per-agent scope instead of the global set
@@ -120,6 +120,18 @@ export declare class BudgetTracker {
120
120
  } | undefined): void;
121
121
  summary(): NonNullable<ConsolidationReport["budget"]>;
122
122
  }
123
+ /**
124
+ * #427 observability: warn when a consolidation run made LLM CALLS but
125
+ * metered ZERO tokens — the endpoint returned no usage objects on its
126
+ * completions (recordUsage skips undefined by design, never fabricates a
127
+ * zero). Such a run still spends budget calls but its snapshot carries token
128
+ * nulls, which read as a mystery on the dashboard. The warn is a structured
129
+ * event in the same journald-greppable style as `event=budget_exhausted`
130
+ * (grep `event=tokens_unmetered`), so the blind spot is visible instead of
131
+ * silent. Returns true when it warned (for tests); no fabrication either
132
+ * way — the numbers stay exactly what the endpoint reported.
133
+ */
134
+ export declare function warnUnmeteredTokensRun(budget: NonNullable<ConsolidationReport["budget"]>): boolean;
123
135
  /**
124
136
  * True when a token-period start stamp is ABSENT or sits in a previous UTC
125
137
  * calendar month than `now` — the monthly-reset staleness check. #405: ONE
@@ -155,6 +167,29 @@ export declare function shouldThrottleTokens(cap: number, period: {
155
167
  * Parse JSON from LLM output, tolerating markdown fences and indexed formats.
156
168
  */
157
169
  export declare function parseJsonLenient<T>(text: string, fallback: T): T;
170
+ /**
171
+ * The shared importance-scoring loop (#425 extraction): one LLM call per
172
+ * 10-memory batch through the production `importanceScoring` prompt, each
173
+ * written score clamped at IMPORTANCE_CEILING and stamped with the
174
+ * importance_scored_at watermark. Used by the nightly's stageImportance AND
175
+ * `hicortex rescore-importance` — there is exactly one scoring code path
176
+ * (no forked backfill logic; cap + watermark write identically everywhere).
177
+ *
178
+ * Failure semantics: a batch whose LLM call THROWS writes nothing (counted
179
+ * in `failed` — retried naturally later); a batch whose reply parses to a
180
+ * non-array falls back to 0.5 per memory (written + watermarked — the
181
+ * endpoint answered, the answer was unusable).
182
+ */
183
+ export declare function scoreMemoriesImportance(db: Database.Database, memories: Memory[], llm: LlmClient, opts?: {
184
+ budget?: BudgetTracker;
185
+ deadline?: RunDeadline;
186
+ dryRun?: boolean;
187
+ onBatch?: (written: number, failed: number) => void;
188
+ }): Promise<{
189
+ scored: number;
190
+ failed: number;
191
+ skipped_budget: number;
192
+ }>;
158
193
  /**
159
194
  * Rebuild moduleIndex from the configured domain set + live DB counts, and
160
195
  * persist it. Shared by the nightly stage and `hicortex classify-domains`.
@@ -41,9 +41,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
41
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
42
  exports.resolveNightlyLlmCallBudget = resolveNightlyLlmCallBudget;
43
43
  exports.isContradictionCandidate = isContradictionCandidate;
44
+ exports.warnUnmeteredTokensRun = warnUnmeteredTokensRun;
44
45
  exports.isStaleTokenPeriod = isStaleTokenPeriod;
45
46
  exports.shouldThrottleTokens = shouldThrottleTokens;
46
47
  exports.parseJsonLenient = parseJsonLenient;
48
+ exports.scoreMemoriesImportance = scoreMemoriesImportance;
47
49
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
48
50
  exports.discoverLinkCandidates = discoverLinkCandidates;
49
51
  exports.classifyLinkCandidates = classifyLinkCandidates;
@@ -240,6 +242,27 @@ class BudgetTracker {
240
242
  }
241
243
  }
242
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
+ }
243
266
  // ---------------------------------------------------------------------------
244
267
  // Token fair-use throttle decision (#246)
245
268
  // ---------------------------------------------------------------------------
@@ -351,13 +374,29 @@ function stagePrecheck(db, stateDir) {
351
374
  // ---------------------------------------------------------------------------
352
375
  // Stage 2: Importance Scoring
353
376
  // ---------------------------------------------------------------------------
354
- async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
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 = {}) {
355
391
  const batchSize = 10;
392
+ const budget = opts.budget;
393
+ const deadline = opts.deadline;
394
+ const dryRun = opts.dryRun ?? false;
356
395
  let scored = 0;
357
396
  let failed = 0;
358
397
  let skippedBudget = 0;
359
398
  for (let i = 0; i < memories.length; i += batchSize) {
360
- if (budget.exhausted) {
399
+ if (budget?.exhausted) {
361
400
  skippedBudget += memories.length - i;
362
401
  break;
363
402
  }
@@ -372,13 +411,13 @@ async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
372
411
  const prompt = (0, prompts_js_1.importanceScoring)(memoriesBlock);
373
412
  if (dryRun)
374
413
  continue;
375
- if (!budget.use("importance")) {
414
+ if (budget && !budget.use("importance")) {
376
415
  skippedBudget += memories.length - i;
377
416
  break;
378
417
  }
379
418
  try {
380
419
  const r = await llm.complete(prompt);
381
- budget.recordUsage("importance", r.usage);
420
+ budget?.recordUsage("importance", r.usage);
382
421
  let scores = parseJsonLenient(r.text, null);
383
422
  if (!Array.isArray(scores)) {
384
423
  scores = new Array(batch.length).fill(0.5);
@@ -386,6 +425,8 @@ async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
386
425
  while (scores.length < batch.length)
387
426
  scores.push(0.5);
388
427
  scores = scores.slice(0, batch.length);
428
+ let batchWritten = 0;
429
+ let batchFailed = 0;
389
430
  for (let j = 0; j < batch.length; j++) {
390
431
  let scoreVal = 0.5;
391
432
  try {
@@ -396,21 +437,37 @@ async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
396
437
  catch {
397
438
  scoreVal = 0.5;
398
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);
399
446
  try {
400
- 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
+ });
401
451
  scored++;
452
+ batchWritten++;
402
453
  }
403
454
  catch {
404
455
  failed++;
456
+ batchFailed++;
405
457
  }
406
458
  }
459
+ opts.onBatch?.(batchWritten, batchFailed);
407
460
  }
408
461
  catch {
409
462
  failed += batch.length;
463
+ opts.onBatch?.(0, batch.length);
410
464
  }
411
465
  }
412
466
  return { scored, failed, skipped_budget: skippedBudget };
413
467
  }
468
+ async function stageImportance(db, memories, llm, budget, dryRun, deadline) {
469
+ return scoreMemoriesImportance(db, memories, llm, { budget, deadline, dryRun });
470
+ }
414
471
  // ---------------------------------------------------------------------------
415
472
  // Stage 2.5: Reflection
416
473
  // ---------------------------------------------------------------------------
@@ -964,7 +1021,9 @@ function classifyRelationship(source, target, similarity) {
964
1021
  // Stage 3.5: Hub Detection & Strength Boost
965
1022
  // ---------------------------------------------------------------------------
966
1023
  const HUB_BOOST = 0.1;
967
- 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;
968
1027
  function stageHubBoost(db, dryRun) {
969
1028
  const hubs = (0, graph_js_1.detectHubs)(db);
970
1029
  if (hubs.length === 0)
@@ -1362,7 +1421,15 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1362
1421
  // rejects negatives at the boundary, but this stage is callable directly).
1363
1422
  if (cap <= 0)
1364
1423
  return { cap, evicted: 0 };
1365
- 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;
1366
1433
  if (count <= cap)
1367
1434
  return { cap, evicted: 0 };
1368
1435
  const surplus = count - cap;
@@ -1370,10 +1437,12 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1370
1437
  // NOT NULL after scoring; the `?? 0.5` mirrors stageDecayPrune's defensive
1371
1438
  // default for unscored rows (inserts at 0.5). last_accessed is NULL until
1372
1439
  // first /recall-index exposure — COALESCE to created_at for the tiebreak so
1373
- // 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.
1374
1442
  const rows = db
1375
1443
  .prepare(`SELECT id, base_strength, last_accessed, access_count, created_at
1376
- FROM memories`)
1444
+ FROM memories
1445
+ WHERE COALESCE(status, '') != 'absorbed'`)
1377
1446
  .all();
1378
1447
  const linkCounts = storage.getAllLinkCounts(db);
1379
1448
  const now = new Date();
@@ -1469,6 +1538,13 @@ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1469
1538
  explicit_verified: 0,
1470
1539
  explicit_divergent: 0,
1471
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,
1472
1548
  merges,
1473
1549
  merge_pairs_applied: 0,
1474
1550
  merge_below_gate: 0,