@gamaze/hicortex 0.21.0 → 0.22.1

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 (50) hide show
  1. package/assets/dashboard.html +282 -27
  2. package/dist/calibration.d.ts +77 -12
  3. package/dist/calibration.js +82 -15
  4. package/dist/capture-health.d.ts +42 -5
  5. package/dist/capture-health.js +57 -7
  6. package/dist/capture-pause.d.ts +7 -4
  7. package/dist/capture-pause.js +7 -4
  8. package/dist/consolidate.d.ts +19 -0
  9. package/dist/consolidate.js +125 -51
  10. package/dist/dashboard.d.ts +41 -16
  11. package/dist/dashboard.js +110 -41
  12. package/dist/db.js +16 -0
  13. package/dist/dedup.js +4 -1
  14. package/dist/eval/decay-eval.d.ts +6 -5
  15. package/dist/eval/decay-eval.js +10 -48
  16. package/dist/eval/eval-clock.d.ts +32 -0
  17. package/dist/eval/eval-clock.js +47 -0
  18. package/dist/eval/graph-eval.d.ts +15 -2
  19. package/dist/eval/graph-eval.js +51 -5
  20. package/dist/eval/planted-eval.d.ts +4 -0
  21. package/dist/eval/planted-eval.js +27 -2
  22. package/dist/eval/planted-harness.d.ts +7 -0
  23. package/dist/eval/planted-harness.js +2 -0
  24. package/dist/eval/ranking-battery.d.ts +49 -2
  25. package/dist/eval/ranking-battery.js +110 -2
  26. package/dist/eval/ranking-eval.d.ts +26 -6
  27. package/dist/eval/ranking-eval.js +197 -34
  28. package/dist/eval/ranking-fixtures.d.ts +46 -6
  29. package/dist/eval/ranking-fixtures.js +268 -9
  30. package/dist/eval/recall-sweep.d.ts +7 -2
  31. package/dist/eval/recall-sweep.js +42 -13
  32. package/dist/eval/relevance-eval.d.ts +115 -1
  33. package/dist/eval/relevance-eval.js +318 -32
  34. package/dist/eval/run-eval.d.ts +7 -4
  35. package/dist/eval/run-eval.js +37 -10
  36. package/dist/graph.d.ts +1 -2
  37. package/dist/graph.js +3 -4
  38. package/dist/mcp-server.js +3 -2
  39. package/dist/nofit.d.ts +2 -1
  40. package/dist/nofit.js +2 -1
  41. package/dist/recall-index.d.ts +3 -2
  42. package/dist/recall-index.js +3 -2
  43. package/dist/retrieval.d.ts +43 -18
  44. package/dist/retrieval.js +148 -86
  45. package/dist/status.js +18 -0
  46. package/dist/storage.d.ts +2 -1
  47. package/dist/storage.js +6 -1
  48. package/dist/types.d.ts +25 -4
  49. package/package.json +1 -1
  50. package/server.json +4 -4
@@ -24,7 +24,8 @@
24
24
  * the removal is never silent.
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.DIAGNOSTIC_ENV_TIER = exports.OLLAMA_FLUSH_WAIT_MS = exports.OLLAMA_FLUSH_EVERY = exports.NUM_CTX = exports.DEFAULT_FIRST_RUN_LOOKBACK_DAYS = exports.ENRICH_STRENGTH_DELTA = exports.STAGE_TRUTH_STRENGTH = exports.STAGE_BELIEF_STRENGTH = exports.STAGE_FADING_STRENGTH = exports.STAGE_FADING_DAYS = exports.WEAK_PRIMARY_FLOOR = exports.CORRECTION_REWRITE_MIN_CONFIDENCE = exports.CORRECTION_MIN_SIMILARITY = exports.SUPERSESSION_MIN_SIMILARITY = exports.DEDUP_AUTO_MERGE_THRESHOLD = exports.IMPORTANCE_CEILING = exports.BM25_WEIGHT_DOMAIN = exports.BM25_WEIGHT_PROJECT = exports.BM25_WEIGHT_BODY = exports.BOTH_CHANNEL_BOOST = exports.RRF_VECTOR_WEIGHT = exports.RRF_FTS_WEIGHT = exports.RRF_COMPOSITE_WEIGHT = exports.RRF_K = exports.DOMAIN_AFFINITY_WEIGHT = exports.PROJECT_AFFINITY_WEIGHT = exports.SUPERSEDED_DEMOTION = exports.FRESHNESS_BOOST_WEIGHT = exports.FRESHNESS_BOOST_DAYS = exports.SCORE_RECENCY_WEIGHT = exports.SCORE_CONNECTIONS_WEIGHT = exports.SCORE_STRENGTH_WEIGHT = exports.SCORE_SIMILARITY_WEIGHT = exports.RECALL_USES_AXIS_MAX = exports.RECALL_USES_NORMAL_MAX = exports.RECALL_USES_LOW_MAX = exports.RECALL_RESHOW_TURNS = exports.NOVELTY_FLOOR_SLOTS = exports.RECALL_TITLE_CHARS = exports.RECALL_MIN_PROMPT_CHARS = exports.RECALL_MAX_ITEMS = exports.RECALL_MIN_SIMILARITY = exports.SESSION_INTENT_WEIGHT = exports.COLD_EXPOSURE_SLOTS = exports.RECENT_WINDOW_DAYS = exports.RECENT_LIMIT = exports.SEARCH_LIMIT = exports.DECAY_HALF_LIFE_DAYS = void 0;
27
+ exports.OLLAMA_FLUSH_WAIT_MS = exports.OLLAMA_FLUSH_EVERY = exports.NUM_CTX = exports.DEFAULT_FIRST_RUN_LOOKBACK_DAYS = exports.ENRICH_STRENGTH_DELTA = exports.STAGE_TRUTH_STRENGTH = exports.STAGE_BELIEF_STRENGTH = exports.STAGE_FADING_STRENGTH = exports.STAGE_FADING_DAYS = exports.WEAK_PRIMARY_FLOOR = exports.CORRECTION_REWRITE_MIN_CONFIDENCE = exports.CORRECTION_MIN_SIMILARITY = exports.SUPERSESSION_MIN_SIMILARITY = exports.DEDUP_AUTO_MERGE_THRESHOLD = exports.IMPORTANCE_CEILING = exports.BM25_WEIGHT_DOMAIN = exports.BM25_WEIGHT_PROJECT = exports.BM25_WEIGHT_BODY = exports.BOTH_CHANNEL_BOOST = exports.RRF_VECTOR_WEIGHT = exports.RRF_FTS_WEIGHT = exports.RRF_COMPOSITE_WEIGHT = exports.RRF_K = exports.SCOPE_AFFINITY_WEIGHT = exports.SUPERSEDED_DEMOTION = exports.RECENCY_HEAD_DAYS = exports.RECENCY_HEAD_WEIGHT = exports.RECENCY_HOURLY_DECAY = exports.SCORE_RECENCY_WEIGHT = exports.CONNECTIONS_SATURATION_DEGREE = exports.SCORE_CONNECTIONS_WEIGHT = exports.SCORE_STRENGTH_WEIGHT = exports.SCORE_SIMILARITY_WEIGHT = exports.RECALL_USES_AXIS_MAX = exports.RECALL_USES_NORMAL_MAX = exports.RECALL_USES_LOW_MAX = exports.RECALL_RESHOW_TURNS = exports.NOVELTY_FLOOR_SLOTS = exports.RECALL_TITLE_CHARS = exports.RECALL_MIN_PROMPT_CHARS = exports.RECALL_MAX_ITEMS = exports.RECALL_MIN_SIMILARITY = exports.SESSION_INTENT_WEIGHT = exports.COLD_EXPOSURE_SLOTS = exports.RECENT_WINDOW_DAYS = exports.RECENT_LIMIT = exports.SEARCH_LIMIT = exports.PROMOTION_STRENGTH_FLOOR = exports.PROMOTION_RATE = exports.DECAY_HALF_LIFE_DAYS = void 0;
28
+ exports.DIAGNOSTIC_ENV_TIER = void 0;
28
29
  exports.resolveNumCtx = resolveNumCtx;
29
30
  exports.resolveOllamaFlushEvery = resolveOllamaFlushEvery;
30
31
  exports.resolveOllamaFlushWaitMs = resolveOllamaFlushWaitMs;
@@ -39,6 +40,36 @@ exports.resolveOllamaFlushWaitMs = resolveOllamaFlushWaitMs;
39
40
  * ranking. Long-term remembering is the product; time preference stays mild.
40
41
  */
41
42
  exports.DECAY_HALF_LIFE_DAYS = 365;
43
+ /**
44
+ * Per-use strength promotion rate (#448, the strength model's upward path):
45
+ * one application of `promotion = PROMOTION_RATE × (1 − S / IMPORTANCE_CEILING)
46
+ * × S^(−0.3)` per new use (S = stored base_strength), iterated once per
47
+ * access-count delta by the nightly stagePromotion stage and clamped at the
48
+ * ceiling. Calibrated so the first access on a median-0.35 memory gives ~+0.03
49
+ * (0.35 → 0.3786). Shape grounded in LTP saturation curves (Cao & Harris
50
+ * 2014), the power law of practice (α ≈ 0.3, Newell & Rosenbloom 1981), and
51
+ * spaced-repetition stability multipliers (S^−0.53, SuperMemo SM-17,
52
+ * n > 60,000 real repetitions): bigger boosts for weaker memories,
53
+ * saturating near the ceiling, never fully stopping (asymptotic). The decay
54
+ * side is untouched — a memory's fade RATE no longer depends on its use or
55
+ * link history (the #448 hardening removal); use raises the STORED score
56
+ * instead, and abandonment fades it on the same 365-day clock as everything
57
+ * else. RELEASE-MANAGED per this module's evolution contract — no config key.
58
+ */
59
+ exports.PROMOTION_RATE = 0.033;
60
+ /**
61
+ * Lower bound on the promotion formula's S input (#453 review Minor finding,
62
+ * owner decision 2026-09-16). base_strength 0.0 is a writable value (legacy
63
+ * rows, operator edits), and S^(−0.3) has a zero-singularity: unfloored, one
64
+ * access on a 0.0 row computed delta = 0.033 × (1 − 0/0.95) × 0^−0.3 =
65
+ * Infinity, and the ceiling clamp turned that into a leap straight to 0.95.
66
+ * Floored at 0.01, the same access lands at 0.033 × (1 − 0.01/0.95) ×
67
+ * 0.01^(−0.3) ≈ +0.13 (0.0 → ≈0.14) — the biggest first boost the formula
68
+ * can give, bounded and far from the ceiling. The floor clamps the FORMULA
69
+ * INPUT only: storage keeps a 0.0 row at 0.0 until it is used, and the
70
+ * promoted result is nonzero from then on. RELEASE-MANAGED like PROMOTION_RATE.
71
+ */
72
+ exports.PROMOTION_STRENGTH_FLOOR = 0.01;
42
73
  /** Default k for retrieve() (/search without an explicit limit). #192. */
43
74
  exports.SEARCH_LIMIT = 8;
44
75
  /** Default k for searchRecent() (/recent without an explicit limit). #192. */
@@ -97,8 +128,9 @@ exports.RECALL_USES_NORMAL_MAX = 0.25;
97
128
  * keeps a visible span, not a measured bound. */
98
129
  exports.RECALL_USES_AXIS_MAX = 0.30;
99
130
  // ---------------------------------------------------------------------------
100
- // Composite ranking weights (was: score*Weight, freshnessBoost*,
101
- // supersededDemotion, *AffinityWeight, rrf*)
131
+ // Composite ranking weights (was: score*Weight, supersededDemotion,
132
+ // *AffinityWeight, rrf*; the pre-#430 freshness bonus constants merged into
133
+ // the RECENCY family below)
102
134
  // ---------------------------------------------------------------------------
103
135
  /** Semantic-similarity share of the composite score. 0.50 (raised from 0.40
104
136
  * in the 0.15.2 rebalance, #191 Phase B): on the production corpus effective
@@ -110,23 +142,58 @@ exports.SCORE_SIMILARITY_WEIGHT = 0.50;
110
142
  exports.SCORE_STRENGTH_WEIGHT = 0.20;
111
143
  /** Graph-centrality share of the composite score (was 0.20; see above). */
112
144
  exports.SCORE_CONNECTIONS_WEIGHT = 0.15;
113
- /** Slow recency curve share of the composite score (was 0.10; see above). */
145
+ /**
146
+ * Log-saturation degree K for the connections term (#449 PR E, items 1+3):
147
+ * the credit is min(1, log1p(k) / log1p(K)) × SCORE_CONNECTIONS_WEIGHT on
148
+ * the ABSOLUTE undirected-degree scale k — full at k = K, exactly +0 at
149
+ * k = 0 (unlinked rows score bit-identically to the linear term they
150
+ * replace). Provenance: p99 of the REAL undirected degree distribution
151
+ * (read-only audit 2026-09-17 of the canonical wave snapshot, 18,798
152
+ * memories / 14,600 linked: p50 = 4, p90 = 8, p95 = 9, p99 = 15, p99.9 =
153
+ * 28.4, max = 40; 135 memories ≥ 16 links = 0.92% of linked) — so the top
154
+ * ~1% of hubs tie at full credit and everything below differentiates
155
+ * modestly. Shape grounded in the #449 research base: ACT-R fan saturation
156
+ * (Anderson & Reder 1999 — activation falls with the LOG of fan), SAM's
157
+ * saturating returns, cue overload (Watkins & Watkins 1975). Replaces the
158
+ * candidate-set-relative normalization #449 deleted (k / the per-query max
159
+ * degree) — a row's score no longer depends on which other rows happened
160
+ * to match. Alternatives K = 8 (p90 — collapses all p90+ rows to
161
+ * full credit) and K = 32 (≈p99.9 — leaves the p99 hub at 0.12 of the
162
+ * term) were tabled in the #449 spec. RELEASE-MANAGED per this module's
163
+ * evolution contract — overridable only through the configureScoring seam.
164
+ */
165
+ exports.CONNECTIONS_SATURATION_DEGREE = 16;
166
+ /** Time-curve share of the composite score — the merged curve's blend weight
167
+ * AND slow-region amplitude (was 0.10; see above). The four blend weights
168
+ * still sum to 1.0; the head amplitude below is a documented overshoot, NOT
169
+ * a fifth blend weight (#430). Zero (via the seam) disables the whole term. */
114
170
  exports.SCORE_RECENCY_WEIGHT = 0.15;
115
- /** Fresh-memory window: the additive bonus fades linearly to 0 over this
116
- * many days. 7 — nightly capture means 1 day is the floor of "fresh"
117
- * (#191 Phase B). */
118
- exports.FRESHNESS_BOOST_DAYS = 7;
119
- /** Fresh-memory bonus size at age 0 (#191 Phase B; 0 = disabled via seam). */
120
- exports.FRESHNESS_BOOST_WEIGHT = 0.15;
171
+ /** Hourly decay of the merged curve's slow region (#430) — promoted from the
172
+ * inline literal computeScore carried; half-life ≈ 57.7 days. At and beyond
173
+ * RECENCY_HEAD_DAYS the curve is bit-identical to the pre-#430 slow term
174
+ * (SCORE_RECENCY_WEIGHT × RECENCY_HOURLY_DECAY^hours). */
175
+ exports.RECENCY_HOURLY_DECAY = 0.9995;
176
+ /** Merged time-curve head amplitude at age 0 (#430) — the pre-#430 slow
177
+ * weight 0.15 + fresh bonus 0.15: the freshness job is now the early steep
178
+ * part of ONE curve. The head joins value-continuously onto the slow region
179
+ * at RECENCY_HEAD_DAYS; its rate is DERIVED from that continuity, never a
180
+ * free constant. */
181
+ exports.RECENCY_HEAD_WEIGHT = 0.30;
182
+ /** Join age in days of the merged time curve (#430) — the head window edge,
183
+ * carried over from the pre-#430 freshness window. Nightly capture means
184
+ * 1 day is the floor of "fresh" (#191 Phase B). */
185
+ exports.RECENCY_HEAD_DAYS = 7;
121
186
  /** Score multiplier for a memory a later decision superseded (0.15.2; the
122
187
  * belief walk (#393 D) is the primary mechanism — this is the safety net
123
188
  * for rows the walk does not reach). */
124
189
  exports.SUPERSEDED_DEMOTION = 0.50;
125
- /** #203 soft boost on exact project match. ADDITIVE, zero-boost neutral,
126
- * never a penalty — a foreign memory ranks equal, not lower. */
127
- exports.PROJECT_AFFINITY_WEIGHT = 0.15;
128
- /** #203 soft boost multiplier on max overlapping domain-tag weight. */
129
- exports.DOMAIN_AFFINITY_WEIGHT = 0.15;
190
+ /** #430 merged scope affinity — ONE term replacing #203's two boosts (the
191
+ * project-affinity and domain-affinity constants) at their shared value.
192
+ * Boost = max(project-match indicator (1 on exact match), max overlapping
193
+ * domain-tag weight) × this weight: the strongest single scope signal counts
194
+ * once, never stacked. ADDITIVE, zero-boost neutral, never a penalty — an
195
+ * absent scope adds exactly 0; a foreign memory ranks equal, not lower. */
196
+ exports.SCOPE_AFFINITY_WEIGHT = 0.15;
130
197
  /** #205 RRF k parameter (1/(k+rank+1)) — matches the pre-#205 hardcoded 60
131
198
  * so the no-config path was byte-identical to 0.15.3. */
132
199
  exports.RRF_K = 60;
@@ -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,112 @@ 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
+ let totalGain = 0;
1380
+ const writes = [];
1381
+ for (const row of rows) {
1382
+ const accessCount = row.access_count ?? 0;
1383
+ // Absorbed rows are invisible to recall but sit in the memories table —
1384
+ // they are demotion-set members this query CAN see (findDemotedIds
1385
+ // deliberately excludes them), so name them here.
1386
+ if (demoted.has(row.id) || row.status === "absorbed") {
1387
+ demotedSkipped++;
1388
+ writes.push({ id: row.id, fields: { promotion_last_count: accessCount } });
1389
+ continue;
1390
+ }
1391
+ // base_strength is NOT NULL after scoring; the `?? 0.5` mirrors
1392
+ // stageDecayPrune's defensive default for unscored rows (inserts at 0.5).
1393
+ const startStrength = row.base_strength ?? 0.5;
1394
+ let strength = startStrength;
1395
+ for (let i = 0; i < row.delta; i++)
1396
+ strength = applyStrengthPromotion(strength);
1397
+ totalGain += strength - startStrength;
1398
+ promoted++;
1399
+ writes.push({
1400
+ id: row.id,
1401
+ fields: { base_strength: strength, promotion_last_count: accessCount },
1402
+ });
1403
+ }
1404
+ // #459: the stage's one-line summary in the shared stage idiom (rows
1405
+ // examined / promoted / total gain, like the supersession summary) — the
1406
+ // stage writes its report in-memory only, so the log line is the soak-time
1407
+ // health signal. Zero examined rows stay silent (the supersession gate).
1408
+ if (dryRun) {
1409
+ console.log(`[hicortex] Strength promotion (dry-run): ${rows.length} examined, would promote ` +
1410
+ `${promoted} (+${totalGain.toFixed(3)} total strength, ` +
1411
+ `${demotedSkipped} demotion-set rows advance their baseline only).`);
1412
+ return { promoted, demoted_skipped: demotedSkipped };
1413
+ }
1414
+ // One transaction for the whole stage (the stageMemoryCapEviction pattern):
1415
+ // strength + baseline move together or not at all — a baseline that
1416
+ // advanced without its bump (or vice versa) would lose or replay a use.
1417
+ const tx = db.transaction(() => {
1418
+ for (const w of writes)
1419
+ storage.updateMemory(db, w.id, w.fields);
1420
+ });
1421
+ tx();
1422
+ console.log(`[hicortex] Strength promotion: ${rows.length} examined, promoted ${promoted} ` +
1423
+ `(+${totalGain.toFixed(3)} total strength, ` +
1424
+ `${demotedSkipped} demotion-set rows advance their baseline only).`);
1425
+ return { promoted, demoted_skipped: demotedSkipped };
1426
+ }
1427
+ // ---------------------------------------------------------------------------
1356
1428
  // Stage 4.5: Memory cap eviction (#245)
1357
1429
  // ---------------------------------------------------------------------------
1358
1430
  //
@@ -1365,10 +1437,13 @@ function stageDecayPrune(db, dryRun) {
1365
1437
  //
1366
1438
  // Eviction reuses the SAME effectiveStrength() the recall ranker uses — no
1367
1439
  // 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.
1440
+ // surfaces in the top-k. #448: the ordering is base + recency ONLY (the
1441
+ // access/link hardening terms are gone), and a row used since the last run
1442
+ // was promoted by stagePromotion earlier in the SAME run — a promoted row
1443
+ // survives a cap it would otherwise have lost. The evicted tail is, by
1444
+ // construction, the tail that was not surfacing anyway (cold, decayed). Ties
1445
+ // are broken by oldest COALESCE(last_accessed, created_at) — i.e. the
1446
+ // memories that have gone longest without anyone looking at them.
1372
1447
  //
1373
1448
  // `cap = 0` disables the stage (indefinite growth — the pre-#245 default is
1374
1449
  // preserved opt-out). The JS-side sort is O(n log n); at 10K memories the
@@ -1440,19 +1515,15 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1440
1515
  // never-shown memories sort by when they entered the corpus. Same
1441
1516
  // non-absorbed predicate as the count above.
1442
1517
  const rows = db
1443
- .prepare(`SELECT id, base_strength, last_accessed, access_count, created_at
1518
+ .prepare(`SELECT id, base_strength, last_accessed, created_at
1444
1519
  FROM memories
1445
1520
  WHERE COALESCE(status, '') != 'absorbed'`)
1446
1521
  .all();
1447
- const linkCounts = storage.getAllLinkCounts(db);
1448
1522
  const now = new Date();
1449
1523
  // Decorate + sort: lowest effectiveStrength first; ties broken by oldest
1450
1524
  // COALESCE(last_accessed, created_at). The victims are the first `surplus`.
1451
1525
  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
- });
1526
+ const eff = (0, retrieval_js_1.effectiveStrength)(r.base_strength ?? 0.5, r.last_accessed, now);
1456
1527
  return {
1457
1528
  id: r.id,
1458
1529
  eff,
@@ -1617,6 +1688,13 @@ deadline) {
1617
1688
  new_memory_count: precheck.newMemories.length,
1618
1689
  unscored_count: scoreMemories.length - precheck.newMemories.length,
1619
1690
  };
1691
+ // Strength promotion (#448) — runs in the pre-skip deterministic zone, in
1692
+ // the same placement discipline as memory_cap BELOW it: accesses happen on
1693
+ // quiet nights too (no new memories must still promote the day's uses),
1694
+ // and a used row must be promoted BEFORE eviction victims are chosen (a
1695
+ // promoted row survives a cap it would otherwise lose). Zero LLM — it sits
1696
+ // before the BudgetTracker exists, ungated by budget and deadline.
1697
+ report.stages.promotion = stagePromotion(db, dryRun);
1620
1698
  // Memory cap eviction (#245) — runs BEFORE the precheck skip so the corpus
1621
1699
  // is bounded even on quiet nights (no new memories → precheck would skip,
1622
1700
  // but the cap stage is pure DB: cheap, idempotent when under cap).
@@ -1689,10 +1767,6 @@ deadline) {
1689
1767
  if (!deadline?.hit("links")) {
1690
1768
  report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
1691
1769
  }
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
1770
  // Stage 3.7: Supersession Detection (#191 Phase B)
1697
1771
  if (!deadline?.hit("supersession")) {
1698
1772
  report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, { ...supersessionOptions, deadline });