@gamaze/hicortex 0.21.0 → 0.22.0

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.
@@ -17,10 +17,19 @@
17
17
  * `capture_health`: the most recent day with rows, grouped by machine ×
18
18
  * agent (posts / sessions / bytes / held / retried), bytes DESC.
19
19
  *
20
- * Retention: 7 days, pruned inside recordDistillActivity at most ONCE per
21
- * process per UTC day (in-module memo) — the insert path must not pay a
22
- * DELETE on every POST. Old rows exist to explain recent nights, nothing
23
- * else; the durable record of WHAT was captured is the memories themselves.
20
+ * - readCaptureHealthWindow(db) — the same grouping over the ROLLING
21
+ * capture window (#409 fix round 7, owner ruling 2026-09-14: the card
22
+ * shows the normal 30 days like the band's other cards). The newest
23
+ * night's rows stay on the payload too (secondary info: the card's
24
+ * "tonight" suffix); the window sums are the card's face.
25
+ *
26
+ * Retention: CAPTURE_HEALTH_WINDOW_DAYS (30), pruned inside
27
+ * recordDistillActivity at most ONCE per process per UTC day (in-module
28
+ * memo) — the insert path must not pay a DELETE on every POST. Retention and
29
+ * the window aggregate share the ONE constant, so the card can never claim a
30
+ * window the store no longer has rows for. Old rows exist to feed the card's
31
+ * rolling window, nothing else; the durable record of WHAT was captured is
32
+ * the memories themselves.
24
33
  */
25
34
  import type Database from "better-sqlite3";
26
35
  /** A /distill POST outcome. 'held' = the client will retry (cursor held):
@@ -64,6 +73,13 @@ export interface CaptureHealth {
64
73
  day: string | null;
65
74
  rows: CaptureHealthRow[];
66
75
  }
76
+ /** The rolling capture window the console's card shows, in days — ALSO the
77
+ * retention horizon for distill_activity (the recorder prunes past it).
78
+ * Owner ruling 2026-09-14: the capture card shows the normal 30 day like
79
+ * the band's other cards, so the store must retain enough nights to sum
80
+ * one. ONE constant feeds both the window aggregate and the prune: the card
81
+ * can never claim a window the store no longer has rows for. */
82
+ export declare const CAPTURE_HEALTH_WINDOW_DAYS = 30;
67
83
  /** Test-only: re-arm the once-per-day prune memo. The memo is deliberately
68
84
  * process-global (production must not DELETE per insert); the test suite
69
85
  * needs it fresh per case. Exported for testability, same precedent as
@@ -75,7 +91,8 @@ export declare function resetDistillPruneMemoForTests(): void;
75
91
  * COALESCE(segment_id, '') so legacy whole-session POSTs — no segment_id —
76
92
  * retry-match on the empty key). A NULL session_id never matches anything:
77
93
  * without a session id the POST has no identity to be a retry OF. Also prunes
78
- * rows older than 7 days, at most once per process per UTC day.
94
+ * rows outside the rolling capture window (CAPTURE_HEALTH_WINDOW_DAYS), at
95
+ * most once per process per UTC day.
79
96
  */
80
97
  export declare function recordDistillActivity(db: Database.Database, entry: DistillActivityEntry): void;
81
98
  /**
@@ -85,3 +102,23 @@ export declare function recordDistillActivity(db: Database.Database, entry: Dist
85
102
  * ids don't count (no identity). Sorted bytes DESC (the card's bar scale).
86
103
  */
87
104
  export declare function readCaptureHealth(db: Database.Database): CaptureHealth;
105
+ /** The /dashboard/data capture_health window block: the echoed window length
106
+ * + the per machine × agent rows over it. Field names are the WIRE keys —
107
+ * dashboard.ts spreads this over the newest-night shape, so the keys must
108
+ * not collide with {day, rows}. window_rows=[] when the window is empty
109
+ * (fresh install, static snapshot without activity rows — the page keeps its
110
+ * counts fallback). */
111
+ export interface CaptureHealthWindow {
112
+ window_days: number;
113
+ window_rows: CaptureHealthRow[];
114
+ }
115
+ /**
116
+ * Aggregate the ROLLING capture window (CAPTURE_HEALTH_WINDOW_DAYS calendar
117
+ * days, today inclusive) into per machine × agent bundles — the card's face
118
+ * (#409 fix round 7: the band's other cards are all 30-day; the newest night
119
+ * moves to secondary info). Same shape as readCaptureHealth's rows, but
120
+ * summed across nights: sessions stays COUNT(DISTINCT session_id), so a
121
+ * session retried on a later night counts once (identity, not occurrences).
122
+ * Sorted bytes DESC (the card's bar scale).
123
+ */
124
+ export declare function readCaptureHealthWindow(db: Database.Database): CaptureHealthWindow;
@@ -18,20 +18,43 @@
18
18
  * `capture_health`: the most recent day with rows, grouped by machine ×
19
19
  * agent (posts / sessions / bytes / held / retried), bytes DESC.
20
20
  *
21
- * Retention: 7 days, pruned inside recordDistillActivity at most ONCE per
22
- * process per UTC day (in-module memo) — the insert path must not pay a
23
- * DELETE on every POST. Old rows exist to explain recent nights, nothing
24
- * else; the durable record of WHAT was captured is the memories themselves.
21
+ * - readCaptureHealthWindow(db) — the same grouping over the ROLLING
22
+ * capture window (#409 fix round 7, owner ruling 2026-09-14: the card
23
+ * shows the normal 30 days like the band's other cards). The newest
24
+ * night's rows stay on the payload too (secondary info: the card's
25
+ * "tonight" suffix); the window sums are the card's face.
26
+ *
27
+ * Retention: CAPTURE_HEALTH_WINDOW_DAYS (30), pruned inside
28
+ * recordDistillActivity at most ONCE per process per UTC day (in-module
29
+ * memo) — the insert path must not pay a DELETE on every POST. Retention and
30
+ * the window aggregate share the ONE constant, so the card can never claim a
31
+ * window the store no longer has rows for. Old rows exist to feed the card's
32
+ * rolling window, nothing else; the durable record of WHAT was captured is
33
+ * the memories themselves.
25
34
  */
26
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CAPTURE_HEALTH_WINDOW_DAYS = void 0;
27
37
  exports.resetDistillPruneMemoForTests = resetDistillPruneMemoForTests;
28
38
  exports.recordDistillActivity = recordDistillActivity;
29
39
  exports.readCaptureHealth = readCaptureHealth;
40
+ exports.readCaptureHealthWindow = readCaptureHealthWindow;
30
41
  const storage_js_1 = require("./storage.js");
31
42
  /** Once-per-process-per-UTC-day prune memo (see module doc). The UTC day of
32
43
  * the last insert that ran the DELETE. Tests reset it because vitest runs
33
44
  * every suite in ONE process — see resetDistillPruneMemoForTests. */
34
45
  let lastPruneDay = null;
46
+ /** The rolling capture window the console's card shows, in days — ALSO the
47
+ * retention horizon for distill_activity (the recorder prunes past it).
48
+ * Owner ruling 2026-09-14: the capture card shows the normal 30 day like
49
+ * the band's other cards, so the store must retain enough nights to sum
50
+ * one. ONE constant feeds both the window aggregate and the prune: the card
51
+ * can never claim a window the store no longer has rows for. */
52
+ exports.CAPTURE_HEALTH_WINDOW_DAYS = 30;
53
+ /** Inclusive window cutoff as SQL: day >= today−(N−1) — the last N calendar
54
+ * days with today counted (the same inclusive convention as
55
+ * /dashboard/events' days param). The prune keeps exactly this predicate's
56
+ * complement (day < cutoff is deleted), so retained rows == window rows. */
57
+ const WINDOW_CUTOFF_SQL = `date('now', '-${exports.CAPTURE_HEALTH_WINDOW_DAYS - 1} days')`;
35
58
  /** Test-only: re-arm the once-per-day prune memo. The memo is deliberately
36
59
  * process-global (production must not DELETE per insert); the test suite
37
60
  * needs it fresh per case. Exported for testability, same precedent as
@@ -52,7 +75,8 @@ function optString(v) {
52
75
  * COALESCE(segment_id, '') so legacy whole-session POSTs — no segment_id —
53
76
  * retry-match on the empty key). A NULL session_id never matches anything:
54
77
  * without a session id the POST has no identity to be a retry OF. Also prunes
55
- * rows older than 7 days, at most once per process per UTC day.
78
+ * rows outside the rolling capture window (CAPTURE_HEALTH_WINDOW_DAYS), at
79
+ * most once per process per UTC day.
56
80
  */
57
81
  function recordDistillActivity(db, entry) {
58
82
  const ts = entry.ts ?? new Date().toISOString();
@@ -72,9 +96,11 @@ function recordDistillActivity(db, entry) {
72
96
  db.prepare(`INSERT INTO distill_activity (ts, day, machine, agent, session_id, segment_id, bytes, outcome, retried)
73
97
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(ts, day, (0, storage_js_1.sanitizeSourceMachine)(entry.machine) ?? "", optString(entry.agent) ?? "unknown", sessionId, segmentId, entry.bytes, entry.outcome, retried);
74
98
  // Prune, at most once per process per UTC day. Runs AFTER the insert so the
75
- // triggering row is subject to the same window as everything else.
99
+ // triggering row is subject to the same window as everything else. The
100
+ // cutoff is the capture window's own edge — retention keeps exactly the
101
+ // nights the card's rolling view sums (see CAPTURE_HEALTH_WINDOW_DAYS).
76
102
  if (lastPruneDay !== day) {
77
- db.prepare("DELETE FROM distill_activity WHERE day < date('now', '-7 days')").run();
103
+ db.prepare(`DELETE FROM distill_activity WHERE day < ${WINDOW_CUTOFF_SQL}`).run();
78
104
  lastPruneDay = day;
79
105
  }
80
106
  }
@@ -104,3 +130,27 @@ function readCaptureHealth(db) {
104
130
  .all(dayRow.day);
105
131
  return { day: dayRow.day, rows };
106
132
  }
133
+ /**
134
+ * Aggregate the ROLLING capture window (CAPTURE_HEALTH_WINDOW_DAYS calendar
135
+ * days, today inclusive) into per machine × agent bundles — the card's face
136
+ * (#409 fix round 7: the band's other cards are all 30-day; the newest night
137
+ * moves to secondary info). Same shape as readCaptureHealth's rows, but
138
+ * summed across nights: sessions stays COUNT(DISTINCT session_id), so a
139
+ * session retried on a later night counts once (identity, not occurrences).
140
+ * Sorted bytes DESC (the card's bar scale).
141
+ */
142
+ function readCaptureHealthWindow(db) {
143
+ const rows = db
144
+ .prepare(`SELECT machine, agent,
145
+ COUNT(*) AS posts,
146
+ COUNT(DISTINCT session_id) AS sessions,
147
+ COALESCE(SUM(bytes), 0) AS bytes,
148
+ COALESCE(SUM(outcome = 'held'), 0) AS held,
149
+ COALESCE(SUM(retried), 0) AS retried
150
+ FROM distill_activity
151
+ WHERE day >= ${WINDOW_CUTOFF_SQL}
152
+ GROUP BY machine, agent
153
+ ORDER BY bytes DESC`)
154
+ .all();
155
+ return { window_days: exports.CAPTURE_HEALTH_WINDOW_DAYS, window_rows: rows };
156
+ }
@@ -22,9 +22,11 @@
22
22
  * /recall-index, /memory) carries no agent/machine identity on the wire, so
23
23
  * attributing it would need new client fields — the heartbeats the spec
24
24
  * forbids. Thresholds (green ≤36h — a nightly poster reads online through
25
- * the following day; amber ≤7d — the distill_activity retention window; none
26
- * beyond or with no rows) are page-side presentation; this module just
27
- * reports the newest ts per bundle.
25
+ * the following day; amber ≤7d; none beyond or with no rows) are page-side
26
+ * presentation; this module just reports the newest ts per bundle. (Amber's
27
+ * 7d predates and outlives the old 7-day retention — activity rows are now
28
+ * kept for the capture card's 30-day window, CAPTURE_HEALTH_WINDOW_DAYS;
29
+ * the dot's thresholds are a design choice, not a retention artifact.)
28
30
  *
29
31
  * Pure, unit-testable without express (the capture-health.ts layering):
30
32
  * mcp-server.ts and dashboard.ts wire these functions to the live db.
@@ -81,6 +83,7 @@ export declare function listCapturePauses(db: Database.Database): CapturePause[]
81
83
  * MAX(ts) with that latest row's outcome, derive the harness per agent, then
82
84
  * merge same-bundle agents keeping the newest ts (one dot per bundle, not
83
85
  * per profile). Reads only distill_activity, which the recorder prunes to
84
- * 7 days — older-than-window bundles simply have no rows and no dot.
86
+ * the capture card's rolling window (CAPTURE_HEALTH_WINDOW_DAYS) —
87
+ * older-than-window bundles simply have no rows and no dot.
85
88
  */
86
89
  export declare function readFleetLastSeen(db: Database.Database): FleetLastSeen[];
@@ -23,9 +23,11 @@
23
23
  * /recall-index, /memory) carries no agent/machine identity on the wire, so
24
24
  * attributing it would need new client fields — the heartbeats the spec
25
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.
26
+ * the following day; amber ≤7d; none beyond or with no rows) are page-side
27
+ * presentation; this module just reports the newest ts per bundle. (Amber's
28
+ * 7d predates and outlives the old 7-day retention — activity rows are now
29
+ * kept for the capture card's 30-day window, CAPTURE_HEALTH_WINDOW_DAYS;
30
+ * the dot's thresholds are a design choice, not a retention artifact.)
29
31
  *
30
32
  * Pure, unit-testable without express (the capture-health.ts layering):
31
33
  * mcp-server.ts and dashboard.ts wire these functions to the live db.
@@ -97,7 +99,8 @@ function listCapturePauses(db) {
97
99
  * MAX(ts) with that latest row's outcome, derive the harness per agent, then
98
100
  * merge same-bundle agents keeping the newest ts (one dot per bundle, not
99
101
  * 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.
102
+ * the capture card's rolling window (CAPTURE_HEALTH_WINDOW_DAYS) —
103
+ * older-than-window bundles simply have no rows and no dot.
101
104
  */
102
105
  function readFleetLastSeen(db) {
103
106
  const rows = db
@@ -328,6 +328,25 @@ export declare function stageDecayPrune(db: Database.Database, dryRun: boolean):
328
328
  pruned: number;
329
329
  failed: number;
330
330
  };
331
+ /**
332
+ * ONE application of the #448 promotion formula:
333
+ * `S + PROMOTION_RATE × (1 − S / IMPORTANCE_CEILING) × S^(−0.3)`, clamped at
334
+ * the ceiling. Pure + exported for exact-value tests; the stage applies it
335
+ * iterated once per new use — iteration is what makes the ceiling asymptotic
336
+ * (a count-sized single shot would overshoot it).
337
+ *
338
+ * The formula's S input is floored at PROMOTION_STRENGTH_FLOOR (#453 review,
339
+ * owner decision 2026-09-16): S^(−0.3) has a zero-singularity, and a writable
340
+ * base_strength of 0.0 would otherwise leap to the ceiling via Infinity→clamp
341
+ * on a single access. The floor bounds the first boost at ≈+0.13; it clamps
342
+ * the INPUT only — the stored 0.0 row is not rewritten, and the promoted
343
+ * result is nonzero from then on.
344
+ */
345
+ export declare function applyStrengthPromotion(baseStrength: number): number;
346
+ export declare function stagePromotion(db: Database.Database, dryRun: boolean): {
347
+ promoted: number;
348
+ demoted_skipped: number;
349
+ };
331
350
  /**
332
351
  * Default soft cap on the memory corpus (#245). Above this the lowest-value
333
352
  * memories are evicted each nightly. 10000 balances headroom for a busy
@@ -54,6 +54,8 @@ exports.buildSupersessionPrompt = buildSupersessionPrompt;
54
54
  exports.parseSupersessionReply = parseSupersessionReply;
55
55
  exports.stageSupersession = stageSupersession;
56
56
  exports.stageDecayPrune = stageDecayPrune;
57
+ exports.applyStrengthPromotion = applyStrengthPromotion;
58
+ exports.stagePromotion = stagePromotion;
57
59
  exports.resolveMemorySoftCap = resolveMemorySoftCap;
58
60
  exports.stageMemoryCapEviction = stageMemoryCapEviction;
59
61
  exports.runConsolidation = runConsolidation;
@@ -1018,37 +1020,6 @@ function classifyRelationship(source, target, similarity) {
1018
1020
  return "relates_to";
1019
1021
  }
1020
1022
  // ---------------------------------------------------------------------------
1021
- // Stage 3.5: Hub Detection & Strength Boost
1022
- // ---------------------------------------------------------------------------
1023
- const HUB_BOOST = 0.1;
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;
1027
- function stageHubBoost(db, dryRun) {
1028
- const hubs = (0, graph_js_1.detectHubs)(db);
1029
- if (hubs.length === 0)
1030
- return { hubs_found: 0, boosted: 0 };
1031
- let boosted = 0;
1032
- if (!dryRun) {
1033
- const stmt = db.prepare("UPDATE memories SET base_strength = MIN(?, base_strength + ?) WHERE id = ? AND base_strength < ?");
1034
- const tx = db.transaction(() => {
1035
- for (const hub of hubs) {
1036
- const result = stmt.run(HUB_STRENGTH_CAP, HUB_BOOST, hub.id, HUB_STRENGTH_CAP);
1037
- if (result.changes > 0)
1038
- boosted++;
1039
- }
1040
- });
1041
- tx();
1042
- }
1043
- else {
1044
- boosted = hubs.length;
1045
- }
1046
- if (hubs.length > 0) {
1047
- console.log(`[hicortex] Hub detection: ${hubs.length} hubs found, ${boosted} boosted (+${HUB_BOOST})`);
1048
- }
1049
- return { hubs_found: hubs.length, boosted };
1050
- }
1051
- // ---------------------------------------------------------------------------
1052
1023
  // Stage 3.7: Supersession Detection (#191 Phase B)
1053
1024
  // ---------------------------------------------------------------------------
1054
1025
  //
@@ -1327,16 +1298,11 @@ function stageDecayPrune(db, dryRun) {
1327
1298
  const now = new Date();
1328
1299
  const cutoff = new Date(now.getTime() - CONSOLIDATE_PRUNE_MIN_AGE_DAYS * 24 * 60 * 60 * 1000);
1329
1300
  const oldUnaccessed = storage.getPruneCandidates(db, cutoff.toISOString());
1330
- const linkCounts = storage.getAllLinkCounts(db);
1331
1301
  let candidates = 0;
1332
1302
  let pruned = 0;
1333
1303
  let failed = 0;
1334
1304
  for (const mem of oldUnaccessed) {
1335
- const memLinkCount = linkCounts.get(mem.id) ?? 0;
1336
- const eff = (0, retrieval_js_1.effectiveStrength)(mem.base_strength ?? 0.5, mem.last_accessed, now, {
1337
- accessCount: 0,
1338
- linkCount: memLinkCount,
1339
- });
1305
+ const eff = (0, retrieval_js_1.effectiveStrength)(mem.base_strength ?? 0.5, mem.last_accessed, now);
1340
1306
  if (eff >= 0.01)
1341
1307
  continue;
1342
1308
  candidates++;
@@ -1353,6 +1319,103 @@ function stageDecayPrune(db, dryRun) {
1353
1319
  return { candidates, pruned, failed };
1354
1320
  }
1355
1321
  // ---------------------------------------------------------------------------
1322
+ // Stage: Strength promotion (#448) — the strength model's upward path
1323
+ // ---------------------------------------------------------------------------
1324
+ //
1325
+ // Real use of a memory (a surfaced /search or /recent hit, a hicortex_get)
1326
+ // bumps access_count (storage.strengthenMemory). This stage converts that use
1327
+ // signal into STRENGTH: every run, each memory whose access_count exceeds its
1328
+ // stored baseline (promotion_last_count, migration v20 — backfilled to
1329
+ // access_count at upgrade so lifetime counts are never replayed) is promoted
1330
+ // once per new use by the calibration formula, capped at the importance
1331
+ // ceiling. Decay stays untouched and uniform (#448 removed the hardening
1332
+ // terms) — promotion is the only way use raises a score.
1333
+ //
1334
+ // Deterministic (zero LLM) and idempotent: the baseline advances to
1335
+ // access_count in the same write, so a delta-less night is a no-op and a
1336
+ // crashed run replays exactly the unconsumed delta. The demotion set
1337
+ // (findDemotedIds: superseded/retracted rows + superseded_by-link sources)
1338
+ // plus status 'absorbed' rows (named explicitly — findDemotedIds excludes
1339
+ // them by design; they are filtered at candidacy elsewhere, but this stage
1340
+ // reads the memories table directly) get NO bump — hidden evidence must not
1341
+ // rise — but their baseline still advances, so a later un-mark never replays
1342
+ // a stale delta.
1343
+ /**
1344
+ * ONE application of the #448 promotion formula:
1345
+ * `S + PROMOTION_RATE × (1 − S / IMPORTANCE_CEILING) × S^(−0.3)`, clamped at
1346
+ * the ceiling. Pure + exported for exact-value tests; the stage applies it
1347
+ * iterated once per new use — iteration is what makes the ceiling asymptotic
1348
+ * (a count-sized single shot would overshoot it).
1349
+ *
1350
+ * The formula's S input is floored at PROMOTION_STRENGTH_FLOOR (#453 review,
1351
+ * owner decision 2026-09-16): S^(−0.3) has a zero-singularity, and a writable
1352
+ * base_strength of 0.0 would otherwise leap to the ceiling via Infinity→clamp
1353
+ * on a single access. The floor bounds the first boost at ≈+0.13; it clamps
1354
+ * the INPUT only — the stored 0.0 row is not rewritten, and the promoted
1355
+ * result is nonzero from then on.
1356
+ */
1357
+ function applyStrengthPromotion(baseStrength) {
1358
+ const s = Math.max(baseStrength, CALIBRATION.PROMOTION_STRENGTH_FLOOR);
1359
+ return Math.min(s +
1360
+ CALIBRATION.PROMOTION_RATE *
1361
+ (1 - s / CALIBRATION.IMPORTANCE_CEILING) *
1362
+ Math.pow(s, -0.3), CALIBRATION.IMPORTANCE_CEILING);
1363
+ }
1364
+ function stagePromotion(db, dryRun) {
1365
+ // Candidates: rows with at least one use since their stored baseline.
1366
+ // COALESCE treats a NULL baseline as 0 — dup-over-loss: an unknown baseline
1367
+ // promotes once, a held one never forgets a use.
1368
+ const rows = db
1369
+ .prepare(`SELECT id, base_strength, access_count, status,
1370
+ access_count - COALESCE(promotion_last_count, 0) AS delta
1371
+ FROM memories
1372
+ WHERE access_count > COALESCE(promotion_last_count, 0)`)
1373
+ .all();
1374
+ if (rows.length === 0)
1375
+ return { promoted: 0, demoted_skipped: 0 };
1376
+ const demoted = (0, retrieval_js_1.findDemotedIds)(db, rows.map((r) => r.id));
1377
+ let promoted = 0;
1378
+ let demotedSkipped = 0;
1379
+ const writes = [];
1380
+ for (const row of rows) {
1381
+ const accessCount = row.access_count ?? 0;
1382
+ // Absorbed rows are invisible to recall but sit in the memories table —
1383
+ // they are demotion-set members this query CAN see (findDemotedIds
1384
+ // deliberately excludes them), so name them here.
1385
+ if (demoted.has(row.id) || row.status === "absorbed") {
1386
+ demotedSkipped++;
1387
+ writes.push({ id: row.id, fields: { promotion_last_count: accessCount } });
1388
+ continue;
1389
+ }
1390
+ // base_strength is NOT NULL after scoring; the `?? 0.5` mirrors
1391
+ // stageDecayPrune's defensive default for unscored rows (inserts at 0.5).
1392
+ let strength = row.base_strength ?? 0.5;
1393
+ for (let i = 0; i < row.delta; i++)
1394
+ strength = applyStrengthPromotion(strength);
1395
+ promoted++;
1396
+ writes.push({
1397
+ id: row.id,
1398
+ fields: { base_strength: strength, promotion_last_count: accessCount },
1399
+ });
1400
+ }
1401
+ if (dryRun) {
1402
+ console.log(`[hicortex] Strength promotion (dry-run): would promote ${promoted} memories ` +
1403
+ `(${demotedSkipped} demotion-set rows advance their baseline only).`);
1404
+ return { promoted, demoted_skipped: demotedSkipped };
1405
+ }
1406
+ // One transaction for the whole stage (the stageMemoryCapEviction pattern):
1407
+ // strength + baseline move together or not at all — a baseline that
1408
+ // advanced without its bump (or vice versa) would lose or replay a use.
1409
+ const tx = db.transaction(() => {
1410
+ for (const w of writes)
1411
+ storage.updateMemory(db, w.id, w.fields);
1412
+ });
1413
+ tx();
1414
+ console.log(`[hicortex] Strength promotion: promoted ${promoted} memories ` +
1415
+ `(${demotedSkipped} demotion-set rows advance their baseline only).`);
1416
+ return { promoted, demoted_skipped: demotedSkipped };
1417
+ }
1418
+ // ---------------------------------------------------------------------------
1356
1419
  // Stage 4.5: Memory cap eviction (#245)
1357
1420
  // ---------------------------------------------------------------------------
1358
1421
  //
@@ -1365,10 +1428,13 @@ function stageDecayPrune(db, dryRun) {
1365
1428
  //
1366
1429
  // Eviction reuses the SAME effectiveStrength() the recall ranker uses — no
1367
1430
  // formula duplication, so the eviction criterion cannot drift from what
1368
- // surfaces in the top-k. The evicted tail is, by construction, the tail that
1369
- // was not surfacing anyway (cold, decayed). Ties are broken by oldest
1370
- // COALESCE(last_accessed, created_at) — i.e. the memories that have gone
1371
- // longest without anyone looking at them.
1431
+ // surfaces in the top-k. #448: the ordering is base + recency ONLY (the
1432
+ // access/link hardening terms are gone), and a row used since the last run
1433
+ // was promoted by stagePromotion earlier in the SAME run — a promoted row
1434
+ // survives a cap it would otherwise have lost. The evicted tail is, by
1435
+ // construction, the tail that was not surfacing anyway (cold, decayed). Ties
1436
+ // are broken by oldest COALESCE(last_accessed, created_at) — i.e. the
1437
+ // memories that have gone longest without anyone looking at them.
1372
1438
  //
1373
1439
  // `cap = 0` disables the stage (indefinite growth — the pre-#245 default is
1374
1440
  // preserved opt-out). The JS-side sort is O(n log n); at 10K memories the
@@ -1440,19 +1506,15 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1440
1506
  // never-shown memories sort by when they entered the corpus. Same
1441
1507
  // non-absorbed predicate as the count above.
1442
1508
  const rows = db
1443
- .prepare(`SELECT id, base_strength, last_accessed, access_count, created_at
1509
+ .prepare(`SELECT id, base_strength, last_accessed, created_at
1444
1510
  FROM memories
1445
1511
  WHERE COALESCE(status, '') != 'absorbed'`)
1446
1512
  .all();
1447
- const linkCounts = storage.getAllLinkCounts(db);
1448
1513
  const now = new Date();
1449
1514
  // Decorate + sort: lowest effectiveStrength first; ties broken by oldest
1450
1515
  // COALESCE(last_accessed, created_at). The victims are the first `surplus`.
1451
1516
  const decorated = rows.map((r) => {
1452
- const eff = (0, retrieval_js_1.effectiveStrength)(r.base_strength ?? 0.5, r.last_accessed, now, {
1453
- accessCount: r.access_count ?? 0,
1454
- linkCount: linkCounts.get(r.id) ?? 0,
1455
- });
1517
+ const eff = (0, retrieval_js_1.effectiveStrength)(r.base_strength ?? 0.5, r.last_accessed, now);
1456
1518
  return {
1457
1519
  id: r.id,
1458
1520
  eff,
@@ -1617,6 +1679,13 @@ deadline) {
1617
1679
  new_memory_count: precheck.newMemories.length,
1618
1680
  unscored_count: scoreMemories.length - precheck.newMemories.length,
1619
1681
  };
1682
+ // Strength promotion (#448) — runs in the pre-skip deterministic zone, in
1683
+ // the same placement discipline as memory_cap BELOW it: accesses happen on
1684
+ // quiet nights too (no new memories must still promote the day's uses),
1685
+ // and a used row must be promoted BEFORE eviction victims are chosen (a
1686
+ // promoted row survives a cap it would otherwise lose). Zero LLM — it sits
1687
+ // before the BudgetTracker exists, ungated by budget and deadline.
1688
+ report.stages.promotion = stagePromotion(db, dryRun);
1620
1689
  // Memory cap eviction (#245) — runs BEFORE the precheck skip so the corpus
1621
1690
  // is bounded even on quiet nights (no new memories → precheck would skip,
1622
1691
  // but the cap stage is pure DB: cheap, idempotent when under cap).
@@ -1689,10 +1758,6 @@ deadline) {
1689
1758
  if (!deadline?.hit("links")) {
1690
1759
  report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
1691
1760
  }
1692
- // Stage 3.5: Hub Detection — boost highly-connected memories
1693
- if (!deadline?.hit("hub_boost")) {
1694
- report.stages.hub_boost = stageHubBoost(db, dryRun);
1695
- }
1696
1761
  // Stage 3.7: Supersession Detection (#191 Phase B)
1697
1762
  if (!deadline?.hit("supersession")) {
1698
1763
  report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, { ...supersessionOptions, deadline });
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import type express from "express";
24
24
  import type Database from "better-sqlite3";
25
+ import { type CaptureHealthRow } from "./capture-health.js";
25
26
  import { type Stage } from "./stages.js";
26
27
  /** Corpus-shape snapshot. `adoption` is null in backfilled rows (point-in-time,
27
28
  * can't be reconstructed from created_at). */
@@ -202,7 +203,7 @@ export interface DashboardData {
202
203
  period_start: string | null;
203
204
  };
204
205
  };
205
- range: "7d" | "30d" | "90d" | "all";
206
+ range: "7d" | "30d" | "90d" | "180d" | "all";
206
207
  series: DashboardSnapshot[];
207
208
  composition: {
208
209
  by_type: Record<string, number>;
@@ -213,22 +214,28 @@ export interface DashboardData {
213
214
  };
214
215
  /**
215
216
  * #422 Phase 2 — capture health: per machine × agent /distill outcome
216
- * accounting for the most recent day with rows (posts / sessions / bytes /
217
- * held / retried). ALWAYS present; {day: null, rows: []} when nothing is
217
+ * accounting. ALWAYS present; {day: null, rows: []} when nothing is
218
218
  * recorded (fresh install / all rows pruned — the page degrades to the
219
219
  * phase-1 counts-only rows).
220
+ *
221
+ * #409 fix round 7 (owner ruling 2026-09-14: "the normal 30 day as the
222
+ * other cards"): `day`/`rows` keep the NEWEST night with rows (secondary
223
+ * info — the card's "tonight" suffix), while `window_days`/`window_rows`
224
+ * carry the ROLLING capture window's per machine × agent sums — the card's
225
+ * face. Both derive from the same distill_activity rows and the same
226
+ * CAPTURE_HEALTH_WINDOW_DAYS constant (retention == window). window_rows
227
+ * is [] exactly when rows is (same table) — the counts fallback then still
228
+ * applies. A pre-round-7 server sends neither window key; the page guards.
220
229
  */
221
230
  capture_health: {
222
231
  day: string | null;
223
- rows: Array<{
224
- machine: string;
225
- agent: string;
226
- posts: number;
227
- sessions: number;
228
- bytes: number;
229
- held: number;
230
- retried: number;
231
- }>;
232
+ rows: CaptureHealthRow[];
233
+ /** Echoed window length (the page renders "last N days" from it — never
234
+ * hardcoded client-side). */
235
+ window_days: number;
236
+ /** Per machine × agent sums over the rolling window (posts / sessions /
237
+ * bytes / held / retried), bytes DESC. */
238
+ window_rows: CaptureHealthRow[];
232
239
  };
233
240
  /**
234
241
  * #423 phase 3 — fleet presence: the operator's capture pauses + per-bundle
@@ -565,7 +572,10 @@ export declare function handleDashboardField(db: Database.Database): {
565
572
  export declare function dashboardFieldHandler(getDb: () => Database.Database): express.RequestHandler;
566
573
  /** One night of the replay ledger. `by_agent` keys are source_agent strings
567
574
  * ("(unknown)" when the memory row is gone); learned counts added, enriched
568
- * counts distinct enriched memories, merged counts dedup losers. */
575
+ * counts distinct enriched memories, merged counts dedup losers, and the
576
+ * #452 truth-management keys count superseded/retracted sources and
577
+ * reconsolidation rewrites (agent = the OLD memory's source — the event
578
+ * belongs to the memory that was demoted). */
569
579
  export interface DashboardEventsNight {
570
580
  date: string;
571
581
  added: string[];
@@ -578,10 +588,25 @@ export interface DashboardEventsNight {
578
588
  a: string;
579
589
  b: string;
580
590
  }>;
591
+ /** #452 — superseded_by links born that night ({old, by} ids). */
592
+ superseded: Array<{
593
+ old: string;
594
+ by: string;
595
+ }>;
596
+ /** #452 — corrected_by links whose source is currently status='retracted'. */
597
+ retracted: Array<{
598
+ old: string;
599
+ by: string;
600
+ }>;
601
+ /** #452 — distinct memories reconsolidation rewrote that night. */
602
+ rewritten: string[];
581
603
  by_agent: Record<string, {
582
604
  learned: number;
583
605
  enriched: number;
584
606
  merged: number;
607
+ superseded: number;
608
+ retracted: number;
609
+ rewritten: number;
585
610
  }>;
586
611
  }
587
612
  /** The /dashboard/events response. */
@@ -589,9 +614,9 @@ export interface DashboardEvents {
589
614
  nights: DashboardEventsNight[];
590
615
  }
591
616
  /**
592
- * The pure data handler for GET /dashboard/events?days=N. Buckets the four
593
- * event sources into UTC nights (only nights with events, ascending) within
594
- * the last `days` calendar days (today inclusive).
617
+ * The pure data handler for GET /dashboard/events?days=N. Buckets the event
618
+ * sources into UTC nights (only nights with events, ascending) within the
619
+ * last `days` calendar days (today inclusive).
595
620
  */
596
621
  export declare function handleDashboardEvents(db: Database.Database, query: {
597
622
  days?: unknown;