@gamaze/hicortex 0.20.7 → 0.20.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +18 -41
  2. package/assets/dashboard.html +3989 -836
  3. package/dist/calibration.d.ts +293 -0
  4. package/dist/calibration.js +379 -0
  5. package/dist/capture-health.d.ts +87 -0
  6. package/dist/capture-health.js +106 -0
  7. package/dist/capture-pause.d.ts +86 -0
  8. package/dist/capture-pause.js +127 -0
  9. package/dist/capture.d.ts +24 -3
  10. package/dist/capture.js +11 -1
  11. package/dist/classify-domains.d.ts +6 -0
  12. package/dist/classify-domains.js +7 -1
  13. package/dist/cli.js +38 -3
  14. package/dist/config-read.d.ts +1 -1
  15. package/dist/config-read.js +96 -9
  16. package/dist/consolidate.d.ts +114 -68
  17. package/dist/consolidate.js +302 -182
  18. package/dist/dashboard.d.ts +326 -6
  19. package/dist/dashboard.js +592 -7
  20. package/dist/db.js +105 -0
  21. package/dist/dedup.d.ts +34 -26
  22. package/dist/dedup.js +91 -57
  23. package/dist/distiller.js +1 -1
  24. package/dist/domain-classify.d.ts +7 -6
  25. package/dist/domain-classify.js +12 -10
  26. package/dist/eval/decay-eval.d.ts +3 -3
  27. package/dist/eval/decay-eval.js +4 -4
  28. package/dist/eval/importance-eval.d.ts +85 -0
  29. package/dist/eval/importance-eval.js +286 -0
  30. package/dist/eval/planted-eval.d.ts +26 -0
  31. package/dist/eval/planted-eval.js +97 -0
  32. package/dist/eval/planted-fixtures.d.ts +107 -0
  33. package/dist/eval/planted-fixtures.js +283 -0
  34. package/dist/eval/planted-harness.d.ts +176 -0
  35. package/dist/eval/planted-harness.js +649 -0
  36. package/dist/eval/ranking-battery.d.ts +78 -0
  37. package/dist/eval/ranking-battery.js +181 -0
  38. package/dist/eval/ranking-eval.d.ts +41 -0
  39. package/dist/eval/ranking-eval.js +391 -0
  40. package/dist/eval/ranking-fixtures.d.ts +77 -0
  41. package/dist/eval/ranking-fixtures.js +226 -0
  42. package/dist/identity-store.d.ts +21 -0
  43. package/dist/identity-store.js +49 -0
  44. package/dist/index.js +4 -3
  45. package/dist/init.d.ts +23 -3
  46. package/dist/init.js +84 -9
  47. package/dist/llm.d.ts +43 -58
  48. package/dist/llm.js +87 -101
  49. package/dist/mcp-server.d.ts +12 -0
  50. package/dist/mcp-server.js +213 -32
  51. package/dist/nightly.d.ts +9 -1
  52. package/dist/nightly.js +164 -110
  53. package/dist/nofit.d.ts +4 -11
  54. package/dist/nofit.js +6 -23
  55. package/dist/prompts.d.ts +10 -0
  56. package/dist/prompts.js +28 -5
  57. package/dist/recall-index.d.ts +30 -28
  58. package/dist/recall-index.js +21 -18
  59. package/dist/recall-registry.d.ts +2 -1
  60. package/dist/recall-registry.js +35 -1
  61. package/dist/reconsolidation.d.ts +168 -87
  62. package/dist/reconsolidation.js +818 -377
  63. package/dist/relink.js +3 -4
  64. package/dist/rescore-importance.d.ts +80 -0
  65. package/dist/rescore-importance.js +236 -0
  66. package/dist/retrieval.d.ts +80 -35
  67. package/dist/retrieval.js +322 -105
  68. package/dist/run-deadline.d.ts +62 -0
  69. package/dist/run-deadline.js +73 -0
  70. package/dist/schema-prototypes.d.ts +3 -3
  71. package/dist/schema-prototypes.js +3 -3
  72. package/dist/stages.d.ts +37 -0
  73. package/dist/stages.js +51 -0
  74. package/dist/state.d.ts +34 -9
  75. package/dist/storage.d.ts +50 -18
  76. package/dist/storage.js +125 -30
  77. package/dist/telemetry.d.ts +8 -7
  78. package/dist/token-budget.js +3 -4
  79. package/dist/type-classify.js +4 -4
  80. package/dist/types.d.ts +143 -155
  81. package/domains.example.json +4 -5
  82. package/hermes-plugin/hicortex/README.md +2 -2
  83. package/openclaw.plugin.json +1 -1
  84. package/package.json +4 -1
  85. package/pi-extension/hicortex/README.md +1 -1
  86. package/server.json +3 -3
package/dist/retrieval.js CHANGED
@@ -3,14 +3,15 @@
3
3
  * Retrieval layer with composite scoring, RRF fusion, and graph traversal.
4
4
  * Ported from hicortex/retrieval.py — same scoring model and weights.
5
5
  *
6
- * Scoring model (weights are config-driven since 0.15.2 — see configureScoring):
6
+ * Scoring model (weights are RELEASE-MANAGED since #408 — calibration.ts;
7
+ * configureScoring is the eval/test seam only):
7
8
  * score = similarity * 0.50 + effective_strength * 0.20
8
9
  * + connection_score * 0.15 + recency * 0.15
9
10
  * + fresh-memory bonus (≤ 0.15, linear over the first 7 days)
10
11
  * then × 0.50 if the memory was superseded by a later decision
11
12
  *
12
13
  * Decay model (B+E+D):
13
- * base_decay = derived from decayHalfLifeDays (config; default 365 → ~1-year
14
+ * base_decay = derived from the calibration half-life (365 → ~1-year
14
15
  * half-life at importance 0.5, importance-scaled either way)
15
16
  * decay_rate = 1 - base_decay * (1 - importance)
16
17
  * decay_rate = 1 - (1 - decay_rate) * 0.7^access_count
@@ -52,7 +53,7 @@ var __importStar = (this && this.__importStar) || (function () {
52
53
  };
53
54
  })();
54
55
  Object.defineProperty(exports, "__esModule", { value: true });
55
- exports.SESSION_INTENT_ALPHA = exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
56
+ exports.BELIEF_WALK_MAX_HOPS = exports.SESSION_INTENT_ALPHA = exports.DEFAULT_DECAY_HALF_LIFE_DAYS = void 0;
56
57
  exports.decayConstantForHalfLife = decayConstantForHalfLife;
57
58
  exports.configureDecay = configureDecay;
58
59
  exports.configureRecall = configureRecall;
@@ -64,7 +65,9 @@ exports.blendQueryVector = blendQueryVector;
64
65
  exports.recallQueryVector = recallQueryVector;
65
66
  exports.findSupersededIds = findSupersededIds;
66
67
  exports.findDemotedIds = findDemotedIds;
68
+ exports.beliefWalkTerminal = beliefWalkTerminal;
67
69
  exports.l2ToCosine = l2ToCosine;
70
+ exports.cosineBetweenVectors = cosineBetweenVectors;
68
71
  exports.effectiveStrength = effectiveStrength;
69
72
  exports.computeScore = computeScore;
70
73
  exports.retrieve = retrieve;
@@ -72,11 +75,10 @@ exports.searchRecent = searchRecent;
72
75
  const storage = __importStar(require("./storage.js"));
73
76
  const schema_prototypes_js_1 = require("./schema-prototypes.js");
74
77
  const type_labels_js_1 = require("./type-labels.js");
75
- /** Default decay half-life (days) at importance 0.5. #192: was 0.0005/h
76
- * (~115-day half-life at base 0.5) — aggressive enough to bury the long tail
77
- * in ranking. Long-term remembering is the product; time preference stays,
78
- * but mild. */
79
- exports.DEFAULT_DECAY_HALF_LIFE_DAYS = 365;
78
+ const CALIBRATION = __importStar(require("./calibration.js"));
79
+ /** Default decay half-life (days) at importance 0.5 — release-managed
80
+ * (#408): the constant lives in calibration.ts with its provenance. */
81
+ exports.DEFAULT_DECAY_HALF_LIFE_DAYS = CALIBRATION.DECAY_HALF_LIFE_DAYS;
80
82
  /**
81
83
  * Derive the per-hour base decay constant from a half-life target: for the
82
84
  * decayable portion, retention^hours = 0.5 at `days`, evaluated at the
@@ -89,110 +91,103 @@ function decayConstantForHalfLife(days) {
89
91
  }
90
92
  let BASE_DECAY = decayConstantForHalfLife(exports.DEFAULT_DECAY_HALF_LIFE_DAYS);
91
93
  /**
92
- * Configure the decay speed from config (`decayHalfLifeDays`). Called at boot
93
- * by the server and the nightly so both processes score with the same clock.
94
- * Invalid/absent values keep the default. Exported value for tests.
94
+ * Configure the decay speed for THIS process (the eval/test seam — #408).
95
+ * Production NEVER passes an argument: every process scores with the
96
+ * calibration half-life (calibration.ts DECAY_HALF_LIFE_DAYS). An
97
+ * invalid/absent value keeps the default. Exported value for tests.
95
98
  */
96
- function configureDecay(options) {
97
- const days = Number(options?.halfLifeDays);
99
+ function configureDecay(halfLifeDays) {
100
+ const days = Number(halfLifeDays);
98
101
  BASE_DECAY = decayConstantForHalfLife(Number.isFinite(days) && days > 0 ? days : exports.DEFAULT_DECAY_HALF_LIFE_DAYS);
99
102
  return BASE_DECAY;
100
103
  }
101
104
  const RECALL_DEFAULTS = {
102
- searchLimit: 8,
103
- recentLimit: 12,
104
- recentWindowDays: 180,
105
- coldExposureSlots: 2,
105
+ searchLimit: CALIBRATION.SEARCH_LIMIT,
106
+ recentLimit: CALIBRATION.RECENT_LIMIT,
107
+ recentWindowDays: CALIBRATION.RECENT_WINDOW_DAYS,
108
+ coldExposureSlots: CALIBRATION.COLD_EXPOSURE_SLOTS,
106
109
  };
107
110
  let recallDefaults = { ...RECALL_DEFAULTS };
108
111
  /**
109
- * Configure recall breadth from config. Called at boot next to
110
- * configureDecay(); invalid/absent values keep the shipped defaults.
111
- * Returns the resolved values (for logging + tests).
112
+ * Configure recall breadth from RESOLVED overrides (the eval/test seam —
113
+ * #408). Production calls this with no argument: the calibration defaults
114
+ * (calibration.ts) apply. Invalid/absent values keep the shipped default per
115
+ * key. Returns the resolved values (for logging + tests).
112
116
  */
113
- function configureRecall(config) {
114
- const pick = (key) => {
115
- const v = Number(config?.[key]);
116
- return Number.isFinite(v) && v >= 0 ? Math.floor(v) : RECALL_DEFAULTS[key];
117
+ function configureRecall(overrides) {
118
+ const pick = (v, dflt) => {
119
+ const n = Number(v);
120
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : dflt;
117
121
  };
118
122
  recallDefaults = {
119
- searchLimit: Math.max(1, pick("searchLimit")),
120
- recentLimit: Math.max(1, pick("recentLimit")),
121
- recentWindowDays: Math.max(1, pick("recentWindowDays")),
122
- coldExposureSlots: pick("coldExposureSlots"),
123
+ searchLimit: Math.max(1, pick(overrides?.searchLimit, RECALL_DEFAULTS.searchLimit)),
124
+ recentLimit: Math.max(1, pick(overrides?.recentLimit, RECALL_DEFAULTS.recentLimit)),
125
+ recentWindowDays: Math.max(1, pick(overrides?.recentWindowDays, RECALL_DEFAULTS.recentWindowDays)),
126
+ coldExposureSlots: pick(overrides?.coldExposureSlots, RECALL_DEFAULTS.coldExposureSlots),
123
127
  };
124
128
  return { ...recallDefaults };
125
129
  }
126
130
  const SCORING_DEFAULTS = {
127
- similarity: 0.5,
128
- strength: 0.2,
129
- connections: 0.15,
130
- recency: 0.15,
131
- freshnessBoostDays: 7,
132
- freshnessBoostWeight: 0.15,
133
- supersededDemotion: 0.5,
134
- projectAffinity: 0.15,
135
- domainAffinity: 0.15,
136
- // #205 defaults: rrfK + rrfCompositeWeight match the pre-#205 hardcoded
137
- // values (60 and 0.8) so the no-config path is byte-identical to 0.15.3
138
- // except for the FTS per-list weight (1.0 → 0.5) — the one deliberate
139
- // nudge toward vector that the recall-sweep eval gates. The eval showed
140
- // 0.7 was too timid (Q4 marine contamination persisted) and 0.5 is the
141
- // bisection point where BM25F + composite-affinity finally flip the
142
- // token-exact marine body match below the same-scope hardware field
143
- // (Q4 ON contamination 0.20 → 0.00). 0.5 is still "conservative" — FTS
144
- // contributes half its RRF share, enough that pure-keyword queries (the
145
- // focused-family "login/CORS/webhook" turns) keep recall@5 = 1.0.
146
- rrfK: 60,
147
- rrfCompositeWeight: 0.8,
148
- rrfFtsWeight: 0.5,
149
- rrfVectorWeight: 1.0,
131
+ similarity: CALIBRATION.SCORE_SIMILARITY_WEIGHT,
132
+ strength: CALIBRATION.SCORE_STRENGTH_WEIGHT,
133
+ connections: CALIBRATION.SCORE_CONNECTIONS_WEIGHT,
134
+ recency: CALIBRATION.SCORE_RECENCY_WEIGHT,
135
+ freshnessBoostDays: CALIBRATION.FRESHNESS_BOOST_DAYS,
136
+ freshnessBoostWeight: CALIBRATION.FRESHNESS_BOOST_WEIGHT,
137
+ supersededDemotion: CALIBRATION.SUPERSEDED_DEMOTION,
138
+ projectAffinity: CALIBRATION.PROJECT_AFFINITY_WEIGHT,
139
+ domainAffinity: CALIBRATION.DOMAIN_AFFINITY_WEIGHT,
140
+ // #205 (calibration.ts): rrfK + rrfCompositeWeight match the pre-#205
141
+ // hardcoded values (60 and 0.8); the FTS per-list weight (1.0 → 0.5) is the
142
+ // one deliberate nudge toward vector — the bisection point where BM25F +
143
+ // composite-affinity flip the token-exact marine body match below the
144
+ // same-scope hardware field while pure-keyword queries keep recall@5 = 1.0.
145
+ rrfK: CALIBRATION.RRF_K,
146
+ rrfCompositeWeight: CALIBRATION.RRF_COMPOSITE_WEIGHT,
147
+ rrfFtsWeight: CALIBRATION.RRF_FTS_WEIGHT,
148
+ rrfVectorWeight: CALIBRATION.RRF_VECTOR_WEIGHT,
149
+ // #425 (calibration.ts): the both-channel genuine-match boost. 0.10 is
150
+ // the sweep-chosen size — D3's dominance margin (>= 0.10 x the similarity
151
+ // weight) with the battery stability gates intact.
152
+ bothChannelBoost: CALIBRATION.BOTH_CHANNEL_BOOST,
150
153
  };
151
154
  let scoringWeights = { ...SCORING_DEFAULTS };
152
155
  /**
153
- * Configure scoring weights + ranking knobs from config. Called at boot by the
154
- * server and the nightly (alongside configureDecay/configureRecall) so
155
- * retrieval and consolidation rank identically. Invalid/absent values keep the
156
- * shipped default per key. Returns the resolved set for logging/tests. Also
157
- * pushes the #205 BM25F field weights into storage (storage.configureBm25Fts)
158
- * so searchFts ranks with the same config — BM25F weights live in storage.ts
159
- * (next to the FTS column declaration they mirror) but are read here from the
160
- * SAME config object for one-place tuning.
156
+ * Configure scoring weights + ranking knobs from RESOLVED overrides (the
157
+ * eval/test seam — #408). Production calls this with no argument: the
158
+ * calibration defaults (calibration.ts) apply, identically in the daemon and
159
+ * the nightly. Invalid/absent values keep the shipped default per key.
160
+ * Returns the resolved set for logging/tests. (The #205 BM25F field weights
161
+ * are NOT touched here — they live in storage.ts and resolve from the same
162
+ * calibration module via storage.configureBm25Fts.)
161
163
  */
162
- function configureScoring(config) {
163
- const num = (key, dflt, min, max) => {
164
- const v = Number(config?.[key]);
165
- return Number.isFinite(v) && v >= min && v <= max ? v : dflt;
164
+ function configureScoring(overrides) {
165
+ const num = (v, dflt, min, max) => {
166
+ const n = Number(v);
167
+ return Number.isFinite(n) && n >= min && n <= max ? n : dflt;
166
168
  };
167
- // #205 BM25F weights use a [0, ∞) range (no upper bound — a field can dominate
168
- // if the operator wills it; 0 drops the field entirely). Invalid ⇒ default.
169
- const numW = (key, dflt) => {
170
- const v = Number(config?.[key]);
171
- return Number.isFinite(v) && v >= 0 ? v : dflt;
169
+ // #205 BM25F-style weights use a [0, ∞) range (no upper bound; 0 drops the
170
+ // field/list entirely). Invalid ⇒ default.
171
+ const numW = (v, dflt) => {
172
+ const n = Number(v);
173
+ return Number.isFinite(n) && n >= 0 ? n : dflt;
172
174
  };
173
175
  scoringWeights = {
174
- similarity: num("scoreSimilarityWeight", SCORING_DEFAULTS.similarity, 0, 1),
175
- strength: num("scoreStrengthWeight", SCORING_DEFAULTS.strength, 0, 1),
176
- connections: num("scoreConnectionsWeight", SCORING_DEFAULTS.connections, 0, 1),
177
- recency: num("scoreRecencyWeight", SCORING_DEFAULTS.recency, 0, 1),
178
- freshnessBoostDays: num("freshnessBoostDays", SCORING_DEFAULTS.freshnessBoostDays, 0, 365),
179
- freshnessBoostWeight: num("freshnessBoostWeight", SCORING_DEFAULTS.freshnessBoostWeight, 0, 1),
180
- supersededDemotion: num("supersededDemotion", SCORING_DEFAULTS.supersededDemotion, 0, 1),
181
- projectAffinity: num("projectAffinityWeight", SCORING_DEFAULTS.projectAffinity, 0, 1),
182
- domainAffinity: num("domainAffinityWeight", SCORING_DEFAULTS.domainAffinity, 0, 1),
183
- rrfK: numW("rrfK", SCORING_DEFAULTS.rrfK),
184
- rrfCompositeWeight: num("rrfCompositeWeight", SCORING_DEFAULTS.rrfCompositeWeight, 0, 1),
185
- rrfFtsWeight: numW("rrfFtsWeight", SCORING_DEFAULTS.rrfFtsWeight),
186
- rrfVectorWeight: numW("rrfVectorWeight", SCORING_DEFAULTS.rrfVectorWeight),
176
+ similarity: num(overrides?.similarity, SCORING_DEFAULTS.similarity, 0, 1),
177
+ strength: num(overrides?.strength, SCORING_DEFAULTS.strength, 0, 1),
178
+ connections: num(overrides?.connections, SCORING_DEFAULTS.connections, 0, 1),
179
+ recency: num(overrides?.recency, SCORING_DEFAULTS.recency, 0, 1),
180
+ freshnessBoostDays: num(overrides?.freshnessBoostDays, SCORING_DEFAULTS.freshnessBoostDays, 0, 365),
181
+ freshnessBoostWeight: num(overrides?.freshnessBoostWeight, SCORING_DEFAULTS.freshnessBoostWeight, 0, 1),
182
+ supersededDemotion: num(overrides?.supersededDemotion, SCORING_DEFAULTS.supersededDemotion, 0, 1),
183
+ projectAffinity: num(overrides?.projectAffinity, SCORING_DEFAULTS.projectAffinity, 0, 1),
184
+ domainAffinity: num(overrides?.domainAffinity, SCORING_DEFAULTS.domainAffinity, 0, 1),
185
+ rrfK: numW(overrides?.rrfK, SCORING_DEFAULTS.rrfK),
186
+ rrfCompositeWeight: num(overrides?.rrfCompositeWeight, SCORING_DEFAULTS.rrfCompositeWeight, 0, 1),
187
+ rrfFtsWeight: numW(overrides?.rrfFtsWeight, SCORING_DEFAULTS.rrfFtsWeight),
188
+ rrfVectorWeight: numW(overrides?.rrfVectorWeight, SCORING_DEFAULTS.rrfVectorWeight),
189
+ bothChannelBoost: num(overrides?.bothChannelBoost, SCORING_DEFAULTS.bothChannelBoost, 0, 1),
187
190
  };
188
- // #205: push BM25F field weights into storage so searchFts uses them. Same
189
- // config object, one tuning surface; storage owns the module-level mirror
190
- // next to the FTS column declaration (the positional order matters there).
191
- storage.configureBm25Fts({
192
- bm25WeightBody: numW("bm25WeightBody", 1.0),
193
- bm25WeightProject: numW("bm25WeightProject", 2.0),
194
- bm25WeightDomain: numW("bm25WeightDomain", 2.0),
195
- });
196
191
  return { ...scoringWeights };
197
192
  }
198
193
  /** Current resolved weights (tests + status output). */
@@ -200,11 +195,11 @@ function getScoringWeights() {
200
195
  return { ...scoringWeights };
201
196
  }
202
197
  // ---------------------------------------------------------------------------
203
- // Session-intent keying (#192, 0.15.3). ONE config knob:
204
- // sessionIntentWeight 0.33 blend weight of the rolling centroid in the
205
- // search vector: query = (1-w)·prompt + w·centroid.
206
- // 0 = DISABLED (pure prompt, the kill-switch —
207
- // current behavior). Range [0, 1].
198
+ // Session-intent keying (#192, 0.15.3). ONE calibration constant (#408):
199
+ // SESSION_INTENT_WEIGHT 0.33 blend weight of the rolling centroid in the
200
+ // search vector: query = (1-w)·prompt + w·centroid.
201
+ // configureSessionIntent(0) is the eval-only
202
+ // kill-switch (pure prompt). Range [0, 1].
208
203
  //
209
204
  // The EMA rate α is a shipped constant (SESSION_INTENT_ALPHA, 0.4), not a
210
205
  // second knob — owner directive 0.15.3: one knob is enough to tune/disable;
@@ -218,18 +213,18 @@ function getScoringWeights() {
218
213
  // ---------------------------------------------------------------------------
219
214
  /** EMA rate for the session-intent centroid: centroid_new = (1-α)·old + α·prompt. */
220
215
  exports.SESSION_INTENT_ALPHA = 0.4;
221
- const SESSION_INTENT_DEFAULT_WEIGHT = 0.33;
216
+ const SESSION_INTENT_DEFAULT_WEIGHT = CALIBRATION.SESSION_INTENT_WEIGHT;
222
217
  let sessionIntentWeight = SESSION_INTENT_DEFAULT_WEIGHT;
223
218
  /**
224
- * Configure session-intent keying from config. Called at server boot next to
225
- * configureScoring (the nightly does no recall, so it does not need this).
226
- * Reads only `sessionIntentWeight` ([0,1]; 0 = disabled). Invalid/out-of-range
227
- * values keep the shipped default. Returns `{ weight, alpha }` — alpha is the
228
- * fixed constant, surfaced so the recall closure passes it to the registry in
229
- * one call.
219
+ * Configure session-intent keying for THIS process (the eval/test seam —
220
+ * #408). Production calls this with no argument: the calibration weight
221
+ * (calibration.ts SESSION_INTENT_WEIGHT) applies. `weight` is [0,1] (0 =
222
+ * disabled — the eval kill-switch); invalid/out-of-range values keep the
223
+ * shipped default. Returns `{ weight, alpha }` — alpha is the fixed constant,
224
+ * surfaced so the recall closure passes it to the registry in one call.
230
225
  */
231
- function configureSessionIntent(config) {
232
- const v = Number(config?.sessionIntentWeight);
226
+ function configureSessionIntent(weight) {
227
+ const v = Number(weight);
233
228
  sessionIntentWeight =
234
229
  Number.isFinite(v) && v >= 0 && v <= 1 ? v : SESSION_INTENT_DEFAULT_WEIGHT;
235
230
  return { weight: sessionIntentWeight, alpha: exports.SESSION_INTENT_ALPHA };
@@ -305,6 +300,123 @@ function findDemotedIds(db, candidateIds) {
305
300
  .all(...candidateIds, ...candidateIds);
306
301
  return new Set(rows.map((r) => r.id));
307
302
  }
303
+ // ---------------------------------------------------------------------------
304
+ // Belief walk (#393 increment D)
305
+ // ---------------------------------------------------------------------------
306
+ /**
307
+ * Hop cap for the belief walk (#393 D). Supersession edges advance
308
+ * created_at monotonically (the stage only links old → new), so chains are
309
+ * acyclic by construction and 10 hops is far beyond any real revision depth;
310
+ * the cap is cheap insurance (see beliefWalkTerminal for why it is needed
311
+ * anyway).
312
+ */
313
+ exports.BELIEF_WALK_MAX_HOPS = 10;
314
+ /**
315
+ * The one outgoing supersession edge to follow from `id` (#393 D): when a
316
+ * memory carries several `superseded_by` edges the NEWEST target by
317
+ * created_at wins (deterministic target_id tie-break), null when there is
318
+ * none. Indexed by idx_links_source; one row read.
319
+ */
320
+ function nextSupersedingId(db, id) {
321
+ const row = db
322
+ .prepare(`SELECT ml.target_id AS target_id
323
+ FROM memory_links ml
324
+ JOIN memories m ON m.id = ml.target_id
325
+ WHERE ml.source_id = ? AND ml.relationship = 'superseded_by'
326
+ ORDER BY m.created_at DESC, ml.target_id ASC
327
+ LIMIT 1`)
328
+ .get(id);
329
+ return row ? row.target_id : null;
330
+ }
331
+ /**
332
+ * Terminal of the supersession chain starting at `id` (#393 D): follow
333
+ * superseded_by edges transitively until a memory with no outgoing edge and
334
+ * return it — `id` itself when there is nothing to walk, or whichever node
335
+ * the walk stopped on when it aborts. Shared by retrieval (the belief-walk
336
+ * splice in retrieve()/searchRecent()) and the eval harness (the
337
+ * planted-pairs version_chain class probe), so both agree on what "the
338
+ * chain's current truth" is.
339
+ *
340
+ * Edges advance created_at monotonically (acyclic by construction), BUT
341
+ * applyExplicitMark does no age check and created_at is backdatable from
342
+ * session_date — so a cycle or an absurdly long chain is not impossible.
343
+ * The visited set (seeded with `id`) and the BELIEF_WALK_MAX_HOPS cap are
344
+ * cheap insurance against exactly that; the supersededDemotion multiplier
345
+ * in computeScore remains the safety net for rows the walk does not fully
346
+ * resolve (no edge, cycle, cap abort, absorbed terminal).
347
+ */
348
+ function beliefWalkTerminal(db, id, maxHops = exports.BELIEF_WALK_MAX_HOPS) {
349
+ const visited = new Set([id]);
350
+ let current = id;
351
+ for (let hop = 0; hop < maxHops; hop++) {
352
+ const next = nextSupersedingId(db, current);
353
+ if (next === null || visited.has(next))
354
+ return current;
355
+ visited.add(next);
356
+ current = next;
357
+ }
358
+ return current;
359
+ }
360
+ /**
361
+ * The belief-walk splice (#393 D) — the retrieval-side half of "newest wins
362
+ * by construction". Applied to the FINAL top-k of retrieve()/searchRecent(),
363
+ * after the sort and after the cold-exposure splice: a superseded candidate
364
+ * is replaced IN ITS SLOT by its chain's terminal (beliefWalkTerminal), so a
365
+ * strong stale record can never outrank — or appear alongside — the truth
366
+ * that replaced it. The ancestor stays fetchable by id (evidence) but never
367
+ * surfaces as a competing truth in recall.
368
+ *
369
+ * Per entry of `top`, in order:
370
+ * - not superseded → kept untouched (fast path, zero queries)
371
+ * - terminal === entry → kept (no outgoing edge — the entry is its
372
+ * own terminal; a self-loop aborts here too.
373
+ * A hop-cap or non-self-cycle abort stops the
374
+ * walk on a DIFFERENT node, so the entry
375
+ * falls through to the bullets below — e.g.
376
+ * a 2-cycle with both members in top drops
377
+ * both, conservative but nothing stale
378
+ * surfaces (corrupt-data corner only; edges
379
+ * advance created_at, acyclic by
380
+ * construction). supersededDemotion in
381
+ * computeScore stays the safety net for rows
382
+ * the walk does not resolve)
383
+ * - terminal already surfaced → the entry is DROPPED (its truth is present)
384
+ * - otherwise → buildReplacement(terminal); null (terminal
385
+ * unfetchable or absorbed — absorbed rows
386
+ * are invisible to recall by contract) keeps
387
+ * the ancestor in-slot, fail-soft
388
+ *
389
+ * The replacement takes the ancestor's SLOT (position), not its score — no
390
+ * re-sort after the splice; ranking remains the sort's verdict. Idempotent
391
+ * by construction: walk-stable and unsuperseded entries are returned as-is.
392
+ */
393
+ function applyBeliefWalk(db, top, supersededIds, buildReplacement) {
394
+ const present = new Set(top.map((t) => t.mem.id));
395
+ const out = [];
396
+ for (const entry of top) {
397
+ if (!supersededIds.has(entry.mem.id)) {
398
+ out.push(entry);
399
+ continue;
400
+ }
401
+ const terminal = beliefWalkTerminal(db, entry.mem.id);
402
+ if (terminal === entry.mem.id) {
403
+ out.push(entry);
404
+ continue;
405
+ }
406
+ if (present.has(terminal)) {
407
+ // The chain's truth is already surfaced — drop the ancestor.
408
+ continue;
409
+ }
410
+ const replacement = buildReplacement(terminal);
411
+ if (replacement === null) {
412
+ out.push(entry);
413
+ continue;
414
+ }
415
+ out.push(replacement);
416
+ present.add(terminal);
417
+ }
418
+ return out;
419
+ }
308
420
  /**
309
421
  * Placeholder L2 distance for candidates that have no measured vector
310
422
  * distance (FTS-only hits and graph-discovered neighbors). Chosen so that
@@ -332,6 +444,28 @@ const DEFAULT_RRF_K = 60;
332
444
  function l2ToCosine(distance) {
333
445
  return 1 - (distance * distance) / 2;
334
446
  }
447
+ /**
448
+ * Cosine similarity between two stored embeddings (#393 increment B). The
449
+ * similarity source measures cosines transitively via vec0 L2 distances; the
450
+ * scout source finds its candidates through FTS (no vec0 query), so it
451
+ * measures the pair cosine directly from the stored vectors instead —
452
+ * valid because every embedding we store is L2-normalized (embedder.ts).
453
+ * Used as link strength / a ranker, never as a gate (the scout has no
454
+ * similarity floor — that is the point of the increment).
455
+ */
456
+ function cosineBetweenVectors(a, b) {
457
+ let dot = 0;
458
+ let na = 0;
459
+ let nb = 0;
460
+ for (let i = 0; i < a.length; i++) {
461
+ dot += a[i] * b[i];
462
+ na += a[i] * a[i];
463
+ nb += b[i] * b[i];
464
+ }
465
+ if (na === 0 || nb === 0)
466
+ return 0;
467
+ return dot / Math.sqrt(na * nb);
468
+ }
335
469
  // ---------------------------------------------------------------------------
336
470
  // Timestamp parsing
337
471
  // ---------------------------------------------------------------------------
@@ -354,9 +488,16 @@ function parseTimestamp(ts) {
354
488
  /**
355
489
  * Compute decayed strength with adaptive decay (B+E+D model).
356
490
  * Exported for use by consolidation decay/prune stage.
491
+ *
492
+ * #425 read-side law: the decay-relevant importance is CLAMPED at the
493
+ * release-managed ceiling (calibration.ts IMPORTANCE_CEILING) — at importance
494
+ * exactly 1.0 the decay rate is exactly 1.0 and the row never decays, so
495
+ * legacy base-1.0 rows (and any write site that predates the cap) decay
496
+ * again. The clamp applies to explicit importance passes too.
357
497
  */
358
498
  function effectiveStrength(baseStrength, lastAccessed, now, options) {
359
- const importance = options?.importance ?? baseStrength;
499
+ const rawImportance = options?.importance ?? baseStrength;
500
+ const importance = Math.min(rawImportance, CALIBRATION.IMPORTANCE_CEILING);
360
501
  const accessCount = options?.accessCount ?? 0;
361
502
  const linkCount = options?.linkCount ?? 0;
362
503
  const hours = Math.max((now.getTime() - parseTimestamp(lastAccessed).getTime()) / 3_600_000, 0);
@@ -383,6 +524,11 @@ function computeScore(memory, distance, connectionCount, maxConnections, now, op
383
524
  // is a data-driven follow-up if the eval shows it is needed.
384
525
  const similarity = Math.max(0, l2ToCosine(distance));
385
526
  const effStrength = effectiveStrength(memory.base_strength ?? 0.5, memory.last_accessed, now, {
527
+ // #425: importance passed EXPLICITLY (the same default value
528
+ // effectiveStrength would apply — base strength IS importance at read
529
+ // time — now stated at the call site so the triple-win coupling
530
+ // (score share, decay rate, floor) is visible and single-sourced).
531
+ importance: memory.base_strength ?? 0.5,
386
532
  accessCount: memory.access_count ?? 0,
387
533
  linkCount: connectionCount,
388
534
  });
@@ -438,6 +584,15 @@ function computeScore(memory, distance, connectionCount, maxConnections, now, op
438
584
  score += maxWeight * scoringWeights.domainAffinity;
439
585
  }
440
586
  }
587
+ // #425 both-channel boost: vector KNN and BM25 FTS AGREEING on a candidate
588
+ // is the genuine-match signature (a distinctive proper noun the user knows
589
+ // exists — the field failure this fixes: 0.90/0.95-strength domain-adjacent
590
+ // memories outranked the best-similarity exact-token match). ADDITIVE,
591
+ // zero-boost neutral, never a penalty; rides the composite side only (like
592
+ // projectAffinity — the RRF side is #205 territory); applied BEFORE the
593
+ // superseded multiplier so a superseded both-channel row still demotes.
594
+ if (options?.bothChannel)
595
+ score += scoringWeights.bothChannelBoost;
441
596
  // Superseded demotion (#191 Phase B): a memory whose decision was reversed by
442
597
  // a later one keeps its content and strength but must not outrank the
443
598
  // decision that replaced it. Applied as an explicit multiplier here rather
@@ -673,6 +828,9 @@ async function retrieve(db, embedFn, query, options) {
673
828
  superseded: supersededIds.has(mid),
674
829
  scope,
675
830
  tagWeights: tagWeightsByMemory?.get(mid),
831
+ // #425: the two retrieval channels agreeing is the genuine-match
832
+ // signature — graph-only and single-channel candidates add nothing.
833
+ bothChannel: source === "both",
676
834
  });
677
835
  const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
678
836
  accessCount: mem.access_count ?? 0,
@@ -715,6 +873,38 @@ async function retrieve(db, embedFn, query, options) {
715
873
  }
716
874
  }
717
875
  }
876
+ // 6b. Belief walk (#393 D) — a superseded top-k member is replaced in-slot
877
+ // by its chain's terminal. A terminal already in `scored` reuses its honest
878
+ // entry (similarity/RRF/score); one outside the candidate set is computed
879
+ // fresh with source "graph" and RRF share 0 (it entered by LINK, not by any
880
+ // retrieval channel — finalScore = composite × rrfCompositeWeight only).
881
+ // The strengthen() below then fires on the SURFACED set: the terminal
882
+ // accrues the use signal, the dropped ancestor does not. supersededDemotion
883
+ // in computeScore stays as the safety net for rows the walk does not reach.
884
+ const scoredById = new Map(scored.map((s) => [s.mem.id, s]));
885
+ top = applyBeliefWalk(db, top, supersededIds, (terminalId) => {
886
+ const existing = scoredById.get(terminalId);
887
+ if (existing)
888
+ return existing;
889
+ const mem = storage.getMemory(db, terminalId);
890
+ if (!mem || mem.status === "absorbed")
891
+ return null; // fail-soft
892
+ // Hard-filter invariant (#393 D): sourceAgent is the one hard filter left
893
+ // in retrieve(), and the walk must not bypass it — a terminal authored by
894
+ // a different agent never slips into recall through its link entry
895
+ // (mirrors the graph pull-in guard above; fail-soft: the ancestor keeps
896
+ // its slot, demoted).
897
+ if (sourceAgent && mem.source_agent !== sourceAgent)
898
+ return null;
899
+ const connCount = storage.getLinks(db, terminalId, "both").length;
900
+ const composite = computeScore(mem, DEFAULT_GRAPH_DISTANCE, connCount, maxConnections, now, { superseded: supersededIds.has(terminalId) });
901
+ const finalScore = composite * scoringWeights.rrfCompositeWeight;
902
+ const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
903
+ accessCount: mem.access_count ?? 0,
904
+ linkCount: connCount,
905
+ });
906
+ return { mem, finalScore, effStr, connCount, similarity: null, source: "graph" };
907
+ });
718
908
  const results = top.map((t) => formatResult(t.mem, t.finalScore, t.effStr, t.connCount, {
719
909
  similarity: t.similarity,
720
910
  source: t.source,
@@ -765,7 +955,34 @@ function searchRecent(db, options) {
765
955
  scored.push({ mem, score, effStr, connCount });
766
956
  }
767
957
  scored.sort((a, b) => b.score - a.score);
768
- const top = scored.slice(0, limit);
958
+ let top = scored.slice(0, limit);
959
+ // Belief walk (#393 D) — same contract as retrieve(): a superseded recent
960
+ // memory is replaced in-slot by its chain terminal (this path has no
961
+ // similarity/RRF channels, so a fresh replacement carries the composite
962
+ // score only). The trailing strengthen() runs over the WALKED list — the
963
+ // terminal accrues the use signal, the dropped ancestor does not.
964
+ // supersededDemotion in computeScore stays as the safety net for rows the
965
+ // walk does not reach (no edge, absorbed terminal, cycles/caps).
966
+ top = applyBeliefWalk(db, top, supersededRecent, (terminalId) => {
967
+ const mem = storage.getMemory(db, terminalId);
968
+ if (!mem || mem.status === "absorbed")
969
+ return null; // fail-soft
970
+ // Hard-filter invariant (#393 D, review fix): project is the one hard
971
+ // filter in searchRecent(), and the walk must not bypass it — supersession
972
+ // edges are discovered corpus-wide (no project predicate), so a terminal
973
+ // in a different project never slips into project-scoped recall through
974
+ // its link entry (mirrors retrieve()'s sourceAgent guard; fail-soft: the
975
+ // ancestor keeps its slot, demoted).
976
+ if (project && mem.project !== project)
977
+ return null;
978
+ const connCount = storage.getLinks(db, terminalId, "both").length;
979
+ const score = computeScore(mem, DEFAULT_GRAPH_DISTANCE, connCount, maxConnections, now, { superseded: supersededRecent.has(terminalId) });
980
+ const effStr = effectiveStrength(mem.base_strength ?? 0.5, mem.last_accessed, now, {
981
+ accessCount: mem.access_count ?? 0,
982
+ linkCount: connCount,
983
+ });
984
+ return { mem, score, effStr, connCount };
985
+ });
769
986
  const results = top.map((t) => formatResult(t.mem, t.score, t.effStr, t.connCount));
770
987
  strengthen(db, top.map((t) => t.mem), now);
771
988
  return results;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The ONE cooperative wall-clock deadline for a nightly run (#405).
3
+ *
4
+ * Generalizes the #401/#402/#404 reconsolidation pattern (a deadline checked
5
+ * at safe boundaries + resumable cursors) from one stage to the whole
6
+ * pipeline: capture segments, every consolidation stage boundary, the
7
+ * item loops, and the deterministic merge zone all check the SAME deadline,
8
+ * created once at nightly start. On expiry each check site stops cleanly at
9
+ * its last safe boundary — cursors (capture per-session, supersession,
10
+ * reconsolidation) hold below unconfirmed work, so the next run resumes
11
+ * without redoing confirmed work or losing deferred work.
12
+ *
13
+ * Deferral is a REPORTED outcome, not an error: a run whose deadline fired
14
+ * reports consolidation status "deferred" and does NOT advance
15
+ * `lastConsolidated` (the same gate as endpoint_down — otherwise the
16
+ * pending-set queries would silently lose the deferred work).
17
+ *
18
+ * One structured log line per deferred stage (`event=deadline_deferred`,
19
+ * the same key=value style as `event=budget_exhausted` /
20
+ * `event=circuit_open`) so `grep event=deadline_deferred` is one of the two
21
+ * nightly health greps.
22
+ */
23
+ /** Default wall-clock budget for a full nightly run, in minutes (#405).
24
+ * 240 sits between the old stage-local reconsolidation clock (120) and the
25
+ * old systemd backstop (360) — the deadline is now the operating bound and
26
+ * the unit backstop (budget + 60 min slack) only catches a true hang. */
27
+ export declare const DEFAULT_NIGHTLY_TIME_BUDGET_MINUTES = 240;
28
+ /**
29
+ * Cooperative deadline handle. Injectable clock (`now`) so tests can pin or
30
+ * advance time deterministically (the reconsolidation.test.ts 0.000001-minute
31
+ * pattern). All methods are safe to call after expiry — they keep answering.
32
+ */
33
+ export interface RunDeadline {
34
+ /** Absolute epoch-ms timestamp the run must finish by. */
35
+ readonly deadlineAt: number;
36
+ /** Milliseconds left until the deadline (never negative). */
37
+ remainingMs(): number;
38
+ /** True once the clock has passed the deadline. */
39
+ expired(): boolean;
40
+ /**
41
+ * Check + record in one call: returns true when the deadline has fired,
42
+ * logging `event=deadline_deferred stage=<name>` ONCE per stage name (a
43
+ * boundary check and in-loop checks for the same stage log once). Use the
44
+ * same stage name at a stage's boundary and inside its loops.
45
+ */
46
+ hit(stage: string): boolean;
47
+ /** Stage names that deferred so far this run (insertion order). */
48
+ deferredStages(): readonly string[];
49
+ }
50
+ /**
51
+ * Create a run deadline of `minutes` (already-resolved value; see
52
+ * resolveNightlyTimeBudgetMinutes for the config boundary).
53
+ */
54
+ export declare function createRunDeadline(minutes: number, now?: () => number): RunDeadline;
55
+ /**
56
+ * Resolve `nightlyTimeBudgetMinutes` from the saved config (#405): positive
57
+ * finite number wins; absent/0/invalid → the 240 default. Unlike the old
58
+ * `reconsolidationMaxMinutes`, 0 does NOT disable — there is ALWAYS a
59
+ * deadline (readPositiveConfig rejects 0 with a warn, which is the loud
60
+ * migration signal for an operator who used 0 as "off").
61
+ */
62
+ export declare function resolveNightlyTimeBudgetMinutes(config: Record<string, unknown> | null | undefined): number;