claude-mem-lite 5.4.0 → 5.5.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "5.4.0",
13
+ "version": "5.5.1",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.4.0",
3
+ "version": "5.5.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -802,6 +802,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
802
802
  | `CLAUDE_MEM_CJK_PREC_MIN` | Precision floor for CJK segmentation candidates. | `0.2` |
803
803
  | `CLAUDE_MEM_AUTO_DEEP` | `0` disables automatic deep-search escalation (one Haiku call rewriting a weak query into keyword/concept/HyDE variants). Explicit `deep: true` still works. | _(auto)_ |
804
804
  | `CLAUDE_MEM_DEEP_DISCLOSURE` | `off` suppresses the one-line caveat appended to a multi-variant deep result. The caveat exists because deep search fills the page even when the corpus cannot answer — measured at 10 of 10 slots on queries whose answers had been removed (`benchmark/deep-search-holdout.mjs`) — and `deep` is AUTO by default on the MCP surface, i.e. it escalates precisely when the honest answer is "nothing". It does not change retrieval, ranking, or which rows are returned. | _(on)_ |
805
+ | `CLAUDE_MEM_REACH_DISCLOSURE` | `off` suppresses the one-line note that fires when a search's reported `total` exceeds what its pagination can hand back. The candidate pool is sized from `limit` alone and deliberately does not grow with `offset` (D#30 — an offset-scaled pool re-ranks its own prefix under RRF, so pages overlapped and gapped), while `total` is the full match count. Measured on a 128-row corpus: at the default limit of 20 the last non-empty offset is 59, so 60 of 128 rows are unreachable at any offset. The note reports that; it does not change retrieval, ranking, or which rows are returned. | _(on)_ |
805
806
  | `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
806
807
  | `CLAUDE_MEM_VECTORS` | `1` re-enables the persisted TF-IDF vector arm (off by default; also needs a vector rebuild via `maintain`). | _(off)_ |
807
808
  | `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
package/deep-search.mjs CHANGED
@@ -191,12 +191,18 @@ export function resolveDeepMode(explicitDeep, { surface, env = process.env } = {
191
191
  * D#3. benchmark/deep-search-holdout.mjs asks the suite's own queries of a corpus with
192
192
  * their relevant_ids deleted, so the correct answer is zero rows and every returned row is
193
193
  * a false positive by construction. It reads mean FP@10 = 10.00 across 12/12 queries: deep
194
- * fills every slot, every time. The single-query baseline returns 1-2 rows on the same
195
- * negatives the flood is the UNION across paraphrase variants, which is also where deep's
196
- * recall win comes from, so this is not a bug to be thresholded away. Three gates were
197
- * tested against both arms and rejected; suppressing OR-fallback on rewrites takes deep
198
- * R@10 from 0.7383 to 0.3962, because the vocab-mismatch win IS that fallback. rrfFuseN
194
+ * fills every slot, every time. THE FLOOD IS NOT THE PARAPHRASE UNION, and an earlier
195
+ * version of this paragraph said it was ("the single-query baseline returns 1-2 rows on the
196
+ * same negatives"). Measured 2026-09-07, same fixture: the single-variant baseline already
197
+ * returns mean 9.42 of 10 (min 5, max 10, n=12), so fusion adds about half a slot to a page
198
+ * that was already full. A counterfactual names the real source disabling the AND->OR
199
+ * fallback in search-engine.mjs takes mean FP@10 from 10.00 to 0.08, with 0/12 queries
200
+ * flooded instead of 12/12. Read that as a MECHANISM PROBE, not a candidate fix: the same
201
+ * fallback IS the vocab-mismatch recall win, and suppressing it on rewrites takes deep R@10
202
+ * from 0.7383 to 0.3962. Three gates were tested against both arms and rejected. rrfFuseN
199
203
  * fuses by RANK, so no magnitude signal survives the merge for a downstream floor to read.
204
+ * The same counterfactual explains why auto-escalation never fires here (D#8): with the
205
+ * fallback off, plain hits drop to min 0 and the escalation reach goes 0/12 -> 12/12.
200
206
  *
201
207
  * The discrimination is not available at this layer, so the honest move is to hand the
202
208
  * caller what the caller cannot otherwise see. Two things were missing:
package/hook-optimize.mjs CHANGED
@@ -102,7 +102,7 @@ export function rebuildVector(db, obsId, textPartsOrRow) {
102
102
  *
103
103
  * @param {object} db better-sqlite3 database handle
104
104
  * @param {number} limit max candidates to return
105
- * @param {{ scope?: 'narrow' | 'wide' | 'aliases' | 'scopes', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
105
+ * @param {{ scope?: 'narrow' | 'wide' | 'aliases' | 'scopes' | 'concepts', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
106
106
  */
107
107
  export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', project } = {}) {
108
108
  const projectClause = project ? 'AND project = ?' : '';
@@ -129,7 +129,8 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
129
129
  ${projectClause}
130
130
  ORDER BY
131
131
  CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 0 ELSE 1 END,
132
- created_at_epoch DESC
132
+ created_at_epoch DESC,
133
+ id DESC
133
134
  LIMIT ?
134
135
  `);
135
136
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -149,12 +150,48 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
149
150
  AND LENGTH(COALESCE(narrative, '')) > 100
150
151
  AND ${notLowSignalTitleClause('')}
151
152
  ${projectClause}
152
- ORDER BY created_at_epoch DESC
153
+ ORDER BY created_at_epoch DESC, id DESC
154
+ LIMIT ?
155
+ `);
156
+ return project ? stmt.all(project, limit) : stmt.all(limit);
157
+ }
158
+ if (scope === 'concepts') {
159
+ // D#6 concepts backfill: substantive rows with no concepts, REGARDLESS of lesson,
160
+ // aliases or scope. Same shape and same reason as the two pools above, one column
161
+ // over — and this one exists because the P1-2 fix created it. save-enrich fires on
162
+ // every successful manual save and writes search_aliases (always) + lesson_learned
163
+ // (bugfix/decision) + scope, which are precisely narrow's, wide's, aliases' and
164
+ // scopes' predicates, so a save-enriched row matches NONE of the four and never
165
+ // receives concepts. Measured on the real DB 2026-09-07: 14/14 live observations
166
+ // conceptless, 14/14 with aliases, 0/14 with optimized_at, all four pools empty.
167
+ //
168
+ // Keyed on `concepts` ALONE, not on concepts+facts: idempotency here is "the column
169
+ // this pass fills becomes non-empty", the same contract aliases and scopes carry. A
170
+ // facts term in the predicate would re-select forever every row whose narrative
171
+ // yields no extractable fact.
172
+ //
173
+ // Deliberately NOT gated on optimized_at, for the reason the alias branch gives and
174
+ // one more: the general pass preserves-on-empty, so a re-enrich whose model returned
175
+ // no concepts leaves the row stamped AND conceptless. Gating on the stamp would
176
+ // strand exactly those rows — the R10 P2-2 shape, where one pass's bookkeeping
177
+ // evicts a row from a backfill it never visited.
178
+ const stmt = db.prepare(`
179
+ SELECT id, title, narrative, type, subtitle, concepts, facts, text, importance, project
180
+ FROM observations
181
+ WHERE ${liveObsFilterSql('')}
182
+ AND (concepts IS NULL OR concepts = '')
183
+ AND LENGTH(COALESCE(narrative, '')) > 100
184
+ AND ${notLowSignalTitleClause('')}
185
+ ${projectClause}
186
+ ORDER BY created_at_epoch DESC, id DESC
153
187
  LIMIT ?
154
188
  `);
155
189
  return project ? stmt.all(project, limit) : stmt.all(limit);
156
190
  }
157
191
  if (scope === 'wide') {
192
+ // This pool's ORDER BY leads with a CASE term and spans lines, which is exactly why the
193
+ // first pass of the D#9 tiebreaker missed it. The full note lives in the default pool at
194
+ // the bottom of this function -- read it before touching any ORDER BY here.
158
195
  const stmt = db.prepare(`
159
196
  SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
160
197
  FROM observations
@@ -167,7 +204,8 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
167
204
  ${projectClause}
168
205
  ORDER BY
169
206
  CASE type WHEN 'decision' THEN 0 WHEN 'bugfix' THEN 1 WHEN 'refactor' THEN 2 ELSE 3 END,
170
- created_at_epoch DESC
207
+ created_at_epoch DESC,
208
+ id DESC
171
209
  LIMIT ?
172
210
  `);
173
211
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -182,7 +220,36 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
182
220
  AND search_aliases IS NULL
183
221
  AND optimized_at IS NULL
184
222
  ${projectClause}
185
- ORDER BY created_at_epoch DESC
223
+ -- D#9: the id term is a REACHABILITY guard, not cosmetics. Every pool in this file is
224
+ -- ORDER BY created_at_epoch DESC LIMIT n feeding JS-side work, so a tie AT THE
225
+ -- BOUNDARY decides pool MEMBERSHIP. Measured 2026-09-07: two inserts land in the same
226
+ -- millisecond 272/300 times, and on a tie SQLite returns ASCENDING rowid -- the exact
227
+ -- opposite of the "newest first" this clause states -- so the newest rows fell out of
228
+ -- the pool whenever the clock had not ticked. SQLite's tie order is deterministic here
229
+ -- (8 rows on one epoch, 200 queries, one returned order), so this is not defending
230
+ -- against a varying plan; it is making the stated order total.
231
+ --
232
+ -- TWO DIFFERENT COUNTS, AND AN EARLIER DRAFT OF THIS COMMENT CONFLATED THEM. This
233
+ -- function, findReenrichCandidates, holds FIVE pools -- five db.prepare blocks:
234
+ -- 'scopes', 'aliases', 'concepts', 'wide', and this default 'narrow' one. The FILE
235
+ -- holds SEVEN "ORDER BY ... created_at_epoch DESC" sites: those five plus
236
+ -- extractUniqueConcepts and findMergeCandidates. All seven now carry the id term.
237
+ -- DO NOT GREP FOR THE ONE-LINE FORM: two of the seven ('scopes' and 'wide') lead with
238
+ -- a CASE ... term and span several lines, so grepping the joined
239
+ -- "created_at_epoch DESC, id DESC" spelling sees only five. That is how the first pass
240
+ -- read six and shipped 'wide' untiebroken -- the pool the DAILY unattended path passes
241
+ -- explicitly, on a budget of 6, where a boundary tie decides which rows reach the LLM
242
+ -- on a given run. Caught later by a test driving scope 'wide'; the original boundary
243
+ -- case drove only 'narrow', so nothing went red.
244
+ -- NOT FIXED, AND NAMED SO THE COMPLETENESS CLAIM IS TRUE: findSmartCompressCandidates
245
+ -- carries an eighth ordering, "ORDER BY project, created_at_epoch" -- ASCENDING, no id
246
+ -- term, no LIMIT. It is outside the seven by construction and is left alone under Iron
247
+ -- Law #1: it feeds clusterForCompression, whose vector branch seeds clusters in SQL
248
+ -- order, so a tie could move cluster membership -- but that branch needs
249
+ -- CLAUDE_MEM_VECTORS=1 and is off by default, and no failing case has been built.
250
+ -- Unjudged, not cleared.
251
+ -- This comment is INSIDE a template literal, so it must never contain a backtick.
252
+ ORDER BY created_at_epoch DESC, id DESC
186
253
  LIMIT ?
187
254
  `);
188
255
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -298,9 +365,23 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
298
365
  // scope rides this call for free (D#135 P3). COALESCE, not a plain set:
299
366
  // an omitted or off-enum value normalizes to null and must not erase a
300
367
  // classification an earlier face already wrote.
301
- db.prepare(
302
- `UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope) WHERE id = ?`,
303
- ).run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
368
+ // D#12: the live-row guard, on the WHERE and not merely on the SELECT that chose
369
+ // the row. The Haiku call above is up to BG_LLM_TIMEOUT_MS (45 s), long enough for
370
+ // a concurrent hook to supersede or auto-compress this row — R10 P3-3's finding,
371
+ // fixed then on the general branch only and carried by the concepts branch since
372
+ // D#6. This was the one branch of the four without it. `changes === 0` is a SKIP,
373
+ // not a success: it must not count as processed and must not rebuild a vector for
374
+ // a row that is no longer live.
375
+ const res = db
376
+ .prepare(
377
+ `UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope)
378
+ WHERE id = ? AND ${liveObsFilterSql('')}`,
379
+ )
380
+ .run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
381
+ if (res.changes === 0) {
382
+ skipped++;
383
+ continue;
384
+ }
304
385
  // Refresh the TF-IDF vector from the just-updated FTS text so the new
305
386
  // aliases reach the vector arm too — the narrow/wide branch rebuilds, this
306
387
  // one must as well. No-ops when the vector arm is off / vocab unbuilt.
@@ -308,6 +389,69 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
308
389
  processed++;
309
390
  continue;
310
391
  }
392
+ if (scope === 'concepts') {
393
+ // Concepts-only backfill (D#6). Writes concepts + facts and APPENDS them to the
394
+ // existing FTS text — never rebuilds it, for the reason the alias branch gives:
395
+ // a rebuild from concepts/facts drops the original narrative and alias terms and
396
+ // regresses recall. Never touches the user's curated title / narrative / lesson /
397
+ // type / importance, and never stamps optimized_at, so the wide pass keeps its
398
+ // own candidates exactly as the alias and scopes passes leave them.
399
+ const conceptsPrompt = `Extract search concepts and concrete facts from this coding memory. Return ONLY valid JSON, no markdown fences.
400
+
401
+ Title: ${truncate(cand.title || '(untitled)', 200)}
402
+ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
403
+
404
+ JSON: {"concepts":["kw1","kw2"],"facts":["specific fact 1","specific fact 2"]}
405
+ concepts: 3-8 short keyword phrases naming what this memory is ABOUT (systems, components, error classes, techniques).
406
+ facts: 1-4 specific, checkable statements the narrative actually asserts. Omit rather than invent.`;
407
+ const parsed = await callModelJSONAsync(conceptsPrompt, 'haiku', {
408
+ timeout: BG_LLM_TIMEOUT_MS,
409
+ maxTokens: 300,
410
+ });
411
+ const pickStrings = (v) =>
412
+ Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s.trim().length > 0) : [];
413
+ const conceptArr = pickStrings(parsed && parsed.concepts);
414
+ // No concepts is a SKIP, not an empty write: writing '' would leave the row in
415
+ // this pool forever, and the pass would burn one Haiku call per cycle on it.
416
+ if (!conceptArr.length) {
417
+ skipped++;
418
+ continue;
419
+ }
420
+ const factArr = pickStrings(parsed && parsed.facts);
421
+ const conceptsOnly = conceptArr.slice(0, 10).join(' ');
422
+ const factsOnly = factArr.slice(0, 10).join(' ');
423
+ const appendedText = [
424
+ cand.text || '',
425
+ conceptsOnly,
426
+ factsOnly,
427
+ cjkBigrams(`${conceptsOnly} ${factsOnly}`),
428
+ ]
429
+ .filter(Boolean)
430
+ .join(' ');
431
+ const safe = scrubRecord('observations', {
432
+ concepts: conceptsOnly,
433
+ facts: factsOnly,
434
+ text: appendedText,
435
+ });
436
+ // Fill-only-empty on `concepts` plus the live-row guard, both on the WHERE and
437
+ // not merely on the SELECT: the round-trip above is up to 45 s, long enough for a
438
+ // concurrent hook to supersede or compress the row (R10 P3-3) or for save-enrich
439
+ // to fill it. `facts` rides along with preserve-on-empty for the same reason the
440
+ // general pass preserves it — a partial answer must not wipe a filled column.
441
+ const res = db
442
+ .prepare(
443
+ `UPDATE observations SET concepts = ?, facts = COALESCE(NULLIF(?, ''), facts), text = ?
444
+ WHERE id = ? AND (concepts IS NULL OR concepts = '') AND ${liveObsFilterSql('')}`,
445
+ )
446
+ .run(safe.concepts, safe.facts, safe.text, cand.id);
447
+ if (res.changes === 0) {
448
+ skipped++;
449
+ continue;
450
+ }
451
+ rebuildVector(db, cand.id, [safe.text]);
452
+ processed++;
453
+ continue;
454
+ }
311
455
  const prompt = `Re-enrich this observation with structured metadata. Return ONLY valid JSON, no markdown fences.
312
456
 
313
457
  Title: ${truncate(cand.title || '(untitled)', 200)}
@@ -336,9 +480,24 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
336
480
  // hide a real observation until manual surgery. In wide scope, fall through and let
337
481
  // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
338
482
  if ((parsed.importance === 0 || parsed.importance === '0') && scope !== 'wide') {
339
- db.prepare(
340
- `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ? WHERE id = ?`,
341
- ).run(Date.now(), cand.id);
483
+ // D#12, and this one is not a stale-write guard — it is a POINTER guard.
484
+ // `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
485
+ // concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
486
+ // call, it holds a POSITIVE keeper id; overwriting that with -1 does not merely
487
+ // stamp a dead row, it destroys the link — lib/maintain-core.mjs:316 recovers
488
+ // orphans with `compressed_into > 0`, and recoverChildrenOf follows the same id.
489
+ // The sibling write in lib/maintain-core.mjs:631 already carries this predicate,
490
+ // so the codebase had decided the question and this site had not been updated.
491
+ const res = db
492
+ .prepare(
493
+ `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
494
+ WHERE id = ? AND ${liveObsFilterSql('')}`,
495
+ )
496
+ .run(Date.now(), cand.id);
497
+ if (res.changes === 0) {
498
+ skipped++;
499
+ continue;
500
+ }
342
501
  processed++;
343
502
  continue;
344
503
  }
@@ -507,7 +666,7 @@ export function extractUniqueConcepts(db, limit = 500, { project } = {}) {
507
666
  WHERE ${liveObsFilterSql('')}
508
667
  AND concepts IS NOT NULL AND concepts != ''
509
668
  ${projectClause}
510
- ORDER BY created_at_epoch DESC
669
+ ORDER BY created_at_epoch DESC, id DESC -- D#9: total order, see findReenrichCandidates
511
670
  LIMIT 2000
512
671
  `);
513
672
  const rows = project ? stmt.all(project) : stmt.all();
@@ -682,7 +841,10 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
682
841
  AND title IS NOT NULL AND title != ''
683
842
  AND created_at_epoch > ?
684
843
  ${projectClause}
685
- ORDER BY created_at_epoch DESC
844
+ -- D#9: this pool's head is what the keeper reduce falls back to on a full tie, so an
845
+ -- arbitrary tie order decides WHICH DUPLICATE SURVIVES a merge. Same-episode rows are
846
+ -- exactly that tie (same project, same importance, access_count 0, same millisecond).
847
+ ORDER BY created_at_epoch DESC, id DESC
686
848
  LIMIT 200
687
849
  `);
688
850
  const rows = project ? stmt.all(cutoff, project) : stmt.all(cutoff);
@@ -751,14 +913,27 @@ Return ONLY valid JSON:
751
913
  });
752
914
  if (!parsed || !parsed.should_merge) return { merged: false };
753
915
 
754
- // Keeper = highest importance, then highest access_count. Previously access_count
755
- // alone, so a critical (importance=3) but never-accessed observation lost the keeper
756
- // role to a trivial (importance=1) accessed one and was compressed away.
916
+ // Keeper = highest importance, then highest access_count, then highest id. Previously
917
+ // access_count alone, so a critical (importance=3) but never-accessed observation lost
918
+ // the keeper role to a trivial (importance=1) accessed one and was compressed away.
919
+ //
920
+ // D#9: the third term is the one that makes this TOTAL. Without it a full tie fell
921
+ // through to `cluster[0]` — the SQL head — and same-episode duplicates are exactly a
922
+ // full tie: same project, same importance, access_count 0, and a created_at_epoch in
923
+ // the same millisecond 272 times out of 300 (measured 2026-09-07). On a tie SQLite
924
+ // returns ASCENDING rowid while an untied pool returns the newest first, so which
925
+ // duplicate survived flipped on whether two writes straddled a millisecond. Ordering
926
+ // the pool alone would not have been enough: this reduce is exported to callers that
927
+ // build their own cluster, so it has to be total on its own. Highest id = written last
928
+ // = the version whose content the merged summary should be anchored on.
757
929
  const keeper = cluster.reduce((best, o) => {
758
930
  const oi = o.importance || 1,
759
931
  bi = best.importance || 1;
760
932
  if (oi !== bi) return oi > bi ? o : best;
761
- return (o.access_count || 0) > (best.access_count || 0) ? o : best;
933
+ const oa = o.access_count || 0,
934
+ ba = best.access_count || 0;
935
+ if (oa !== ba) return oa > ba ? o : best;
936
+ return (o.id || 0) > (best.id || 0) ? o : best;
762
937
  }, cluster[0]);
763
938
  const others = cluster.filter((o) => o.id !== keeper.id);
764
939
  // Floor the merged importance at the cluster max — merging must never silently
@@ -1016,6 +1191,52 @@ export function clusterForCompression(candidates, db) {
1016
1191
  return clusters;
1017
1192
  }
1018
1193
 
1194
+ /**
1195
+ * The smart-compress prompt. Exported so a RULER can measure the shipped text.
1196
+ *
1197
+ * Extracted for benchmark/compress-veto-rate.mjs (D#10). It has to be one string in one
1198
+ * place: a ruler that retypes the prompt measures its own copy, which is exactly how
1199
+ * tests/handoff-simulation.test.mjs came to assert on a re-implementation while the real
1200
+ * hook emitted a block no user had ever seen.
1201
+ *
1202
+ * D#10. This prompt used to OPEN with "Summarize these related code memory observations",
1203
+ * asserting the premise it should have been testing, and the only bail was a missing title
1204
+ * — so the model had no way to refuse. The sibling executeMergeCluster has had
1205
+ * `should_merge` since it was written; these two LLM cluster paths disagreed about whether
1206
+ * the model may say no, and this is the one that HIDES its inputs (compressed_into removes
1207
+ * them from every injection and search surface and puts them out of recoverBuriedLessons'
1208
+ * reach).
1209
+ *
1210
+ * It matters because the upstream relatedness check is not always on:
1211
+ * clusterForCompression only computes cosine similarity when getVocabulary returns a
1212
+ * vocabulary, and that is null whenever the vector arm is off — which is the default
1213
+ * (CLAUDE_MEM_VECTORS !== '1'). The else branch groups by a 14-day window ALONE. Measured
1214
+ * with a control arm 2026-09-07: three unrelated observations over 12 days form 1 cluster
1215
+ * with the arm off and 0 with it on. Until that branch is decided (D#10 option a), this
1216
+ * veto is the only thing standing between the heuristic and an unattended write that hides
1217
+ * real rows.
1218
+ *
1219
+ * @param {Array<object>} observations cluster members
1220
+ * @returns {string}
1221
+ */
1222
+ export function buildCompressPrompt(observations) {
1223
+ const obsDescriptions = observations
1224
+ .map(
1225
+ (o, i) =>
1226
+ `${i + 1}. [${o.type || 'change'}] "${truncate(o.title || '(untitled)', 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}${o.lesson_learned ? ` | Lesson: ${truncate(o.lesson_learned, 200)}` : ''}`,
1227
+ )
1228
+ .join('\n');
1229
+
1230
+ return `These code memory observations were grouped by a heuristic that may be wrong. FIRST decide whether they are one story worth collapsing into a single memory. Return ONLY valid JSON.
1231
+
1232
+ Observations:
1233
+ ${obsDescriptions}
1234
+
1235
+ JSON: {"should_compress":true,"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}
1236
+ should_compress: false when these are about unrelated systems, files or problems, or when a merged summary would lose more than it saves. Compressing HIDES the originals from search, so refuse when in doubt. When false, the other fields are ignored.
1237
+ When true: preserve all important decisions, lessons, and specific facts.`;
1238
+ }
1239
+
1019
1240
  export async function executeSmartCompressCluster(db, observations, project) {
1020
1241
  if (observations.length < 3) return { compressed: false };
1021
1242
 
@@ -1023,25 +1244,18 @@ export async function executeSmartCompressCluster(db, observations, project) {
1023
1244
  if (!gotSlot) return { compressed: false };
1024
1245
 
1025
1246
  try {
1026
- const obsDescriptions = observations
1027
- .map(
1028
- (o, i) =>
1029
- `${i + 1}. [${o.type || 'change'}] "${truncate(o.title || '(untitled)', 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}${o.lesson_learned ? ` | Lesson: ${truncate(o.lesson_learned, 200)}` : ''}`,
1030
- )
1031
- .join('\n');
1032
-
1033
- const prompt = `Summarize these related code memory observations into ONE comprehensive summary. Preserve all important decisions, lessons, and specific facts. Return ONLY valid JSON.
1034
-
1035
- Observations:
1036
- ${obsDescriptions}
1037
-
1038
- JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
1247
+ const prompt = buildCompressPrompt(observations);
1039
1248
 
1040
1249
  const parsed = await callModelJSONAsync(prompt, 'sonnet', {
1041
1250
  timeout: BG_LLM_TIMEOUT_MS,
1042
1251
  maxTokens: 1000,
1043
1252
  });
1044
- if (!parsed || !parsed.title) return { compressed: false };
1253
+ // Fail CLOSED, exactly as `should_merge` does: an omitted verdict refuses. The two
1254
+ // failure directions are not symmetric — refusing wrongly means a compression did not
1255
+ // happen, proceeding wrongly means unrelated observations were hidden from every
1256
+ // surface. On a path that hides its inputs, silence is not consent.
1257
+ if (!parsed || !parsed.should_compress) return { compressed: false };
1258
+ if (!parsed.title) return { compressed: false };
1045
1259
 
1046
1260
  // Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
1047
1261
  const title = truncate(scrubSecrets(parsed.title || ''), 120);
@@ -1193,6 +1407,11 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1193
1407
  // lesson_learned per row, so counting by materialising was the one place this
1194
1408
  // round pulled megabytes to print an integer. (pre-tag review NOTE 11)
1195
1409
  const reenrichScopes = countReenrichCandidates(db, 'scopes', project);
1410
+ // D#6: the concepts-backfill backlog. Reported for the same reason as the three
1411
+ // above — a pool whose size is invisible cannot be sized for a one-shot drain
1412
+ // (`optimize --run --task re-enrich --scope concepts --max N`), and this pool is
1413
+ // the one that holds every save-enriched manual save.
1414
+ const reenrichConcepts = findReenrichCandidates(db, 5000, { scope: 'concepts', project }).length;
1196
1415
 
1197
1416
  const concepts = extractUniqueConcepts(db, 500, { project });
1198
1417
  const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
@@ -1209,6 +1428,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1209
1428
  reenrichWide,
1210
1429
  reenrichAliases,
1211
1430
  reenrichScopes,
1431
+ reenrichConcepts,
1212
1432
  normalize: normalizeReady ? concepts.length : 0,
1213
1433
  normalizeGateOpen: shouldRunNormalize(project),
1214
1434
  clusterMerge,
@@ -1240,7 +1460,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1240
1460
  * is budgeted separately, up to the re-enrich slice again — see the rationale at
1241
1461
  * the call site. Its calls are enum-classification only (maxTokens 60).
1242
1462
  * @param {boolean} [opts.force=false] Bypass time-based gates (e.g. normalize interval).
1243
- * @param {'narrow'|'wide'|'aliases'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
1463
+ * @param {'narrow'|'wide'|'aliases'|'concepts'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
1244
1464
  * 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
1245
1465
  * 'aliases' (P1) backfills search_aliases on substantive alias-less rows regardless
1246
1466
  * of lesson (lesson-bearing manual saves) — adds ONLY aliases, never rewrites content.
@@ -1295,16 +1515,32 @@ export async function optimizeRun(
1295
1515
  // mis-prices it by an order of magnitude.
1296
1516
  // Cap is budget.reenrich, so the daily pass adds at most that many cheap
1297
1517
  // classification calls and an empty pool still costs nothing.
1518
+ //
1519
+ // D#6 adds a FOURTH claimant, 'concepts', and it SHARES the aliases half
1520
+ // rather than taking one of its own. Sharing keeps the boundary this comment
1521
+ // already describes: the main scope still gets at least half the budget, so
1522
+ // adding a pool cannot starve the lesson enrichment that is the point of the
1523
+ // pass. Aliases is served FIRST out of that shared half, on a stated
1524
+ // ordering: an alias-less row is paraphrase-UNFINDABLE (a recall zero),
1525
+ // while a conceptless row is findable and merely ranks worse — measured at
1526
+ // +0.0846 R@10 on the benchmark fixture, which is real but is not a zero.
1527
+ // Both pools drain (each is idempotent via the column it fills), so the
1528
+ // ordering decides which drains first, not which gets served at all.
1298
1529
  const half = Math.max(1, Math.floor(budget.reenrich / 2));
1299
1530
  const aliasBudget = Math.min(
1300
1531
  half,
1301
1532
  findReenrichCandidates(db, half, { scope: 'aliases', project }).length,
1302
1533
  );
1534
+ const conceptsBudget = Math.min(
1535
+ half - aliasBudget,
1536
+ findReenrichCandidates(db, Math.max(0, half - aliasBudget), { scope: 'concepts', project })
1537
+ .length,
1538
+ );
1303
1539
  const scopesBudget = Math.min(
1304
1540
  budget.reenrich,
1305
1541
  findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
1306
1542
  );
1307
- const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, {
1543
+ const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget - conceptsBudget, {
1308
1544
  scope: reenrichScope,
1309
1545
  project,
1310
1546
  });
@@ -1312,14 +1548,31 @@ export async function optimizeRun(
1312
1548
  aliasBudget > 0
1313
1549
  ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
1314
1550
  : { processed: 0, skipped: 0 };
1551
+ const conceptsRes =
1552
+ conceptsBudget > 0
1553
+ ? await executeReenrich(db, conceptsBudget, { scope: 'concepts', project })
1554
+ : { processed: 0, skipped: 0 };
1315
1555
  const scopesRes =
1316
1556
  scopesBudget > 0
1317
1557
  ? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
1318
1558
  : { processed: 0, skipped: 0 };
1319
1559
  results.reenrich = {
1320
- processed: (mainRes.processed || 0) + (aliasRes.processed || 0) + (scopesRes.processed || 0),
1321
- skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0) + (scopesRes.skipped || 0),
1322
- byScope: { [reenrichScope]: mainRes, aliases: aliasRes, scopes: scopesRes },
1560
+ processed:
1561
+ (mainRes.processed || 0) +
1562
+ (aliasRes.processed || 0) +
1563
+ (conceptsRes.processed || 0) +
1564
+ (scopesRes.processed || 0),
1565
+ skipped:
1566
+ (mainRes.skipped || 0) +
1567
+ (aliasRes.skipped || 0) +
1568
+ (conceptsRes.skipped || 0) +
1569
+ (scopesRes.skipped || 0),
1570
+ byScope: {
1571
+ [reenrichScope]: mainRes,
1572
+ aliases: aliasRes,
1573
+ concepts: conceptsRes,
1574
+ scopes: scopesRes,
1575
+ },
1323
1576
  };
1324
1577
  } else {
1325
1578
  results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
@@ -465,6 +465,67 @@ export function applyTierFilter(db, results, { tier, sourceKey, currentProject }
465
465
  });
466
466
  }
467
467
 
468
+ /**
469
+ * Tell the caller when `total` promises rows this pagination can never hand back.
470
+ *
471
+ * D#5. `computePerSourceWindow` is offset-INDEPENDENT by design (D#30: an
472
+ * offset-scaled pool re-ranks its own prefix under RRF, so pages overlapped and
473
+ * gapped on a vector-populated DB). The bound is right and stays. What was never
474
+ * adjusted is the REPORTED NUMBER: `countSearchTotal` re-derives the full
475
+ * MATCH+filter population, so a search over 128 matching rows prints
476
+ * "Found 10 of 128" at offset 50 and "No results at offset 60" — with nothing
477
+ * saying that offsets past the candidate pool are empty by construction.
478
+ * Measured 2026-09-07 on a 128-row sandbox corpus: the last non-empty offset is
479
+ * 59 / 59 / 89 for limits 10 / 20 / 30, i.e. at the default `mem_search` limit of
480
+ * 20, 60 of 128 rows (46.9%) are unreachable at ANY offset.
481
+ *
482
+ * `reachable` is the pre-slice candidate count (`preFinalizeCount` =
483
+ * `results.length`), NOT a re-derived `max(limit*3, 60)`. That matters and is not
484
+ * a style choice: `perSourceLimit` is PER SOURCE, so a cross-source search fuses
485
+ * up to four such pools and its real ceiling is several times the formula. A note
486
+ * quoting the formula would understate the reach of every cross-source query.
487
+ *
488
+ * Silent for deep (explicit or auto-escalated): there `total` IS the fused variant
489
+ * set already in `results`, so `total > reachable` cannot hold and a note would be
490
+ * describing a bound that is not the one in force.
491
+ *
492
+ * Off switch: CLAUDE_MEM_REACH_DISCLOSURE=off (mirrors CLAUDE_MEM_DEEP_DISCLOSURE).
493
+ *
494
+ * @param {object} [opts]
495
+ * @param {number} [opts.total] the reported population (countSearchTotal)
496
+ * @param {number} [opts.reachable] pre-slice candidate count (preFinalizeCount)
497
+ * @param {number} [opts.offset] the offset this page asked for
498
+ * @param {boolean} [opts.isDeep]
499
+ * @param {object} [opts.env]
500
+ * @returns {string} the note, or '' when it should not be shown
501
+ */
502
+ export function reachabilityNote({
503
+ total = 0,
504
+ reachable = 0,
505
+ offset = 0,
506
+ isDeep = false,
507
+ env = process.env,
508
+ } = {}) {
509
+ if (String(env.CLAUDE_MEM_REACH_DISCLOSURE || '').toLowerCase() === 'off') return '';
510
+ if (isDeep) return '';
511
+ if (!Number.isFinite(total) || !Number.isFinite(reachable)) return '';
512
+ // Nothing came back AT ALL is a different question from "this page is past the
513
+ // pool" — a tier filter that dropped every candidate leaves total > 0 with
514
+ // reachable 0, and answering that with a pagination note would misattribute it.
515
+ // The CLI's own zero-result branch owns that case; D#5 is about pagination reach.
516
+ if (!(reachable > 0)) return '';
517
+ if (!(total > reachable)) return '';
518
+ const tail =
519
+ 'The candidate pool is sized from `limit` alone and deliberately does not grow with ' +
520
+ '`offset`, so same-limit pages stay stable and disjoint (D#30). Raise the limit to widen ' +
521
+ 'the pool, or narrow the query.';
522
+ return offset >= reachable
523
+ ? `[search: offset ${offset} is past this query's reach — ${total} rows match but only the ` +
524
+ `first ${reachable} are pageable. ${tail}]`
525
+ : `[search: ${total} rows match but only the first ${reachable} are pageable; offsets at or ` +
526
+ `past ${reachable} return empty. ${tail}]`;
527
+ }
528
+
468
529
  /**
469
530
  * Finalize a merged, scored result set into one page: compute the TRUE
470
531
  * (limit/offset-invariant) population, slice the requested page, and attach the
@@ -485,6 +546,9 @@ export function applyTierFilter(db, results, { tier, sourceKey, currentProject }
485
546
  * expansion (concept co-occurrence / PRF / vector), and `offset` is applied
486
547
  * exactly ONCE here (the per-source SQL always saw offset 0).
487
548
  *
549
+ * `total` therefore reports a population LARGER than this call can ever hand back —
550
+ * see reachabilityNote, which is what tells the caller so.
551
+ *
488
552
  * @returns {{ total: number, page: object[] }}
489
553
  */
490
554
  export function finalizeSearchPage(
package/mem-cli.mjs CHANGED
@@ -120,6 +120,7 @@ import {
120
120
  parseDateBounds,
121
121
  parseDuration,
122
122
  coreRunSearchPipeline,
123
+ reachabilityNote,
123
124
  } from './lib/search-core.mjs';
124
125
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
125
126
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
@@ -479,6 +480,19 @@ async function cmdSearch(db, args, { llm } = {}) {
479
480
  return;
480
481
  }
481
482
 
483
+ // D#5. Same channel and same reasoning as the deep disclosure above: `total` is the
484
+ // real population, the candidate pool is offset-independent by design (D#30), and
485
+ // nothing else tells the caller that offsets past the pool are empty by construction.
486
+ // Emitted BEFORE the two return paths below so it covers both the past-the-pool page
487
+ // and a normal page whose total is unreachable — one call, not two wordings.
488
+ const reachNote = reachabilityNote({
489
+ total,
490
+ reachable: res.preFinalizeCount,
491
+ offset,
492
+ isDeep,
493
+ });
494
+ if (reachNote) process.stderr.write(`${reachNote}\n`);
495
+
482
496
  if (paged.length === 0) {
483
497
  if (jsonOutput) {
484
498
  out(
@@ -3202,7 +3216,7 @@ Commands:
3202
3216
  --run-all Execute bypassing gates
3203
3217
  --task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
3204
3218
  --max N Max items per task (1-100, default 15)
3205
- --scope S re-enrich scope: narrow (default) | wide | aliases | scopes
3219
+ --scope S re-enrich scope: narrow (default) | wide | aliases | scopes | concepts
3206
3220
  (aliases: backfill search_aliases on substantive rows that
3207
3221
  lack them — incl. lesson-bearing manual saves — adds ONLY
3208
3222
  aliases, never rewrites title/narrative/lesson)
@@ -3477,8 +3491,8 @@ async function cmdOptimize(db, args) {
3477
3491
  let reenrichScope = 'narrow';
3478
3492
  if (scopeIdx >= 0 && args[scopeIdx + 1] !== undefined) {
3479
3493
  const raw = args[scopeIdx + 1];
3480
- if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases' && raw !== 'scopes') {
3481
- fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases, scopes`);
3494
+ if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases' && raw !== 'scopes' && raw !== 'concepts') {
3495
+ fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases, scopes, concepts`);
3482
3496
  return;
3483
3497
  }
3484
3498
  reenrichScope = raw;
@@ -3502,7 +3516,7 @@ async function cmdOptimize(db, args) {
3502
3516
  out('[mem] 🔍 LLM Optimization Preview:');
3503
3517
  if (project) out(` Project filter: ${project}`);
3504
3518
  out(
3505
- ` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}${preview.reenrichScopes ? ` (scopes scope: ${preview.reenrichScopes})` : ''}`,
3519
+ ` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}${preview.reenrichScopes ? ` (scopes scope: ${preview.reenrichScopes})` : ''}${preview.reenrichConcepts ? ` (concepts scope: ${preview.reenrichConcepts})` : ''}`,
3506
3520
  );
3507
3521
  out(
3508
3522
  ` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.4.0",
3
+ "version": "5.5.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "5.4.0",
9
+ "version": "5.5.1",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.4.0",
3
+ "version": "5.5.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -30,6 +30,8 @@
30
30
  "test:coverage": "vitest run --coverage",
31
31
  "benchmark": "node benchmark/benchmark.mjs",
32
32
  "benchmark:gate": "node benchmark/ci-gate.mjs",
33
+ "benchmark:multipliers": "node benchmark/multiplier-discrimination.mjs",
34
+ "benchmark:multipliers:gate": "node benchmark/multiplier-discrimination.mjs --self-check",
33
35
  "audit:inventory": "node scripts/audit-metrics.mjs --inventory",
34
36
  "audit:deps": "node scripts/audit-metrics.mjs --deps",
35
37
  "audit:selfcheck": "node scripts/audit-metrics.mjs --self-check",
package/scoring-sql.mjs CHANGED
@@ -10,25 +10,55 @@ import { DAY_MS } from './lib/time-constants.mjs';
10
10
  // multipliers below encode PRODUCT PRIORS: recent / same-project / important /
11
11
  // high-signal-type / frequently-cited memories are more relevant to the CURRENT
12
12
  // dev session. A periodic audit tends to flag them as "0-lift dead weight" —
13
- // resist that on benchmark evidence alone. Measured (audit ②, obs #8773):
14
- // * benchmark.mjs --matrix (micro-fixture, now models the full FULL_SCORE
15
- // chain): type-quality is the TOP contributor (drop-type ΔnDCG=0.0082,
16
- // ΔMRR=0.0166), decay +0.0043 nDCG, importance +0.0012; the chain lifts
17
- // hybrid over bm25_only by +0.0093 nDCG / +0.0166 MRR (net 0 queries hurt).
18
- // project, access and lesson read exactly 0 but that is STRUCTURAL: the
19
- // fixture is single-project, access_count=0, and has 0 lesson_learned rows,
20
- // so it cannot vary those three axes.
13
+ // resist that, and reach for the right instrument instead of the aggregate one.
14
+ //
15
+ // ALL EIGHT ARE ALIVE AND CARRY THE MAGNITUDE DECLARED HERE. Measured
16
+ // 2026-09-07 at `main` @ f25e8ae with benchmark/multiplier-discrimination.mjs,
17
+ // which ranks pairs of rows with byte-identical indexed text differing in one
18
+ // column, so BM25 ties and the score quotient IS the multiplier:
19
+ // decay 1.9770 type 1.8333 project 2.0000 importance 2.0000
20
+ // access 1.5004 lesson 1.3000 noise 5.0000 cite 2.0000
21
+ // Each matches its declared ratio to 4 decimals, hybrid ranks the preferred row
22
+ // 12/12, and removing the term drops that to 6/12 — a coin flip.
23
+ //
24
+ // THE AGGREGATE MATRIX CANNOT SEE THAT, and its zeros must not be read as death.
25
+ // Same tree, `benchmark.mjs --matrix`: bm25_only ALONE reads R@10 0.8996 /
26
+ // P@10 0.9731 / nDCG 0.9728, so the fixture is saturated and all eight
27
+ // multipliers together buy +0.0002 R@10. Five ablation arms (project, access,
28
+ // lesson, noise, cite) read 0 on all four metrics, and dropping importance reads
29
+ // BETTER (ΔnDCG -0.0019). Why each zero, corrected 2026-09-07 — an earlier
30
+ // version of this note said "the fixture is single-project", which is false
31
+ // (seed-data.json is 5 projects x 40 rows):
32
+ // * project — 29 of 30 queries set no project, and the one that does also
33
+ // FILTERS on it, which makes the boost a constant over the survivors and
34
+ // therefore rank-invariant. The harness used to pass the filter value as the
35
+ // boost; it now mirrors search-engine.mjs:606 and disables the boost under a
36
+ // filter, which is rank-invariant on the matrix (verified: all 11 delta
37
+ // blocks byte-identical across the change).
38
+ // * access / lesson / noise / cite — seed-data.json carries no access_count,
39
+ // no lesson_learned and no injection/cite counters at all, so those four
40
+ // columns are constant and the terms are 1.0x on every row.
41
+ // A multiplier reading 0 there is a benchmark-MISMATCH artifact, NOT dead weight.
21
42
  // * longmemeval.mjs --temporal (n=500, real dates): bit-identical to uniform —
22
43
  // LongMemEval-S windows (mean 27.9d, 74% <30d) are far shorter than these
23
44
  // half-lives, so decay moves no rank there either.
24
- // Where a multiplier reads 0 it is a benchmark-MISMATCH artifact (the instrument
25
- // can't vary that axis), NOT proven dead weight. Decision: KEEP them; do NOT
26
- // delete on "0 lift". Guardrail: the ci-gate `hybrid_over_bm25 >= -0.05` floor
27
- // (benchmark/ci-gate.mjs) covers the full modelled chain D#121: cite + noise
28
- // joined the matrix MULT_EXPR after M-3 put them in FULL_SCORE (fixture carries
29
- // zero cite/noise state, so both read 0 by construction, same caveat as lesson;
30
- // their real-SQL direction pins live in benchmark/events-pipeline-probes.mjs).
31
- // Genuine validation of the prior-encoding axes needs a labeled real-dev-memory eval.
45
+ //
46
+ // DO NOT TRUST THE CI GATE TO CATCH A CHANGE HERE. The `hybrid_over_bm25 >= -0.05`
47
+ // floor does NOT cover the chain, measured 2026-09-07 by mutating the real tree
48
+ // and reverting it: neutering the importance multiplier left the gate at exit 0
49
+ // with all four checks PASS and `hybrid_over_bm25` going UP (R 0.0002 -> 0.0019),
50
+ // because importance is a negative contributor on that fixture; changing lesson's
51
+ // 0.3 to 0.5 left the gate's output byte-identical. The eight per-term ablation
52
+ // deltas the matrix prints are gated by nothing. Retune a constant in this file
53
+ // and re-run benchmark/multiplier-discrimination.mjs, which reports MISMATCH on
54
+ // exactly that shape — the aggregate gate will not.
55
+ // D#121: cite + noise joined the matrix MULT_EXPR after M-3 put them in
56
+ // FULL_SCORE; their real-SQL direction pins live in
57
+ // benchmark/events-pipeline-probes.mjs.
58
+ // Still open, and NOT what the ruler above answers: whether these priors help a
59
+ // REAL user. "Wired up with the declared magnitude" is a different question from
60
+ // "correctly calibrated", and the second one still needs a labeled
61
+ // real-dev-memory eval.
32
62
 
33
63
  // ─── Type-Differentiated Recency Decay ──────────────────────────────────────
34
64
 
package/server.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  parseDateBounds,
31
31
  parseDuration,
32
32
  coreRunSearchPipeline,
33
+ reachabilityNote,
33
34
  } from './lib/search-core.mjs';
34
35
  import {
35
36
  runMaintainOps,
@@ -595,6 +596,20 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
595
596
  });
596
597
  if (disclosure) output.content[0].text += `\n\n${disclosure}`;
597
598
  }
599
+ // D#5. Same split as the deep note: an MCP client reads the tool RESULT, not stderr,
600
+ // so a caller that pages past the candidate pool has to be told inside the payload or
601
+ // it reads "0 results" against a total it was just handed and concludes the corpus is
602
+ // empty. `reachable` is r.preFinalizeCount — the pre-slice candidate count — because
603
+ // perSourceLimit is PER SOURCE and a cross-source query fuses several of those pools.
604
+ if (output.content?.[0]?.type === 'text') {
605
+ const reachNote = reachabilityNote({
606
+ total: r.total,
607
+ reachable: r.preFinalizeCount,
608
+ offset,
609
+ isDeep: r.isDeep,
610
+ });
611
+ if (reachNote) output.content[0].text += `\n\n${reachNote}`;
612
+ }
598
613
  appendDeferredTrailer(output);
599
614
 
600
615
  // Expose structured fields for tests + the MCP content blob.