claude-mem-lite 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "5.4.0",
13
+ "version": "5.5.0",
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.0",
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/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 = ?' : '';
@@ -154,6 +154,39 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
154
154
  `);
155
155
  return project ? stmt.all(project, limit) : stmt.all(limit);
156
156
  }
157
+ if (scope === 'concepts') {
158
+ // D#6 concepts backfill: substantive rows with no concepts, REGARDLESS of lesson,
159
+ // aliases or scope. Same shape and same reason as the two pools above, one column
160
+ // over — and this one exists because the P1-2 fix created it. save-enrich fires on
161
+ // every successful manual save and writes search_aliases (always) + lesson_learned
162
+ // (bugfix/decision) + scope, which are precisely narrow's, wide's, aliases' and
163
+ // scopes' predicates, so a save-enriched row matches NONE of the four and never
164
+ // receives concepts. Measured on the real DB 2026-09-07: 14/14 live observations
165
+ // conceptless, 14/14 with aliases, 0/14 with optimized_at, all four pools empty.
166
+ //
167
+ // Keyed on `concepts` ALONE, not on concepts+facts: idempotency here is "the column
168
+ // this pass fills becomes non-empty", the same contract aliases and scopes carry. A
169
+ // facts term in the predicate would re-select forever every row whose narrative
170
+ // yields no extractable fact.
171
+ //
172
+ // Deliberately NOT gated on optimized_at, for the reason the alias branch gives and
173
+ // one more: the general pass preserves-on-empty, so a re-enrich whose model returned
174
+ // no concepts leaves the row stamped AND conceptless. Gating on the stamp would
175
+ // strand exactly those rows — the R10 P2-2 shape, where one pass's bookkeeping
176
+ // evicts a row from a backfill it never visited.
177
+ const stmt = db.prepare(`
178
+ SELECT id, title, narrative, type, subtitle, concepts, facts, text, importance, project
179
+ FROM observations
180
+ WHERE ${liveObsFilterSql('')}
181
+ AND (concepts IS NULL OR concepts = '')
182
+ AND LENGTH(COALESCE(narrative, '')) > 100
183
+ AND ${notLowSignalTitleClause('')}
184
+ ${projectClause}
185
+ ORDER BY created_at_epoch DESC
186
+ LIMIT ?
187
+ `);
188
+ return project ? stmt.all(project, limit) : stmt.all(limit);
189
+ }
157
190
  if (scope === 'wide') {
158
191
  const stmt = db.prepare(`
159
192
  SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
@@ -298,9 +331,23 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
298
331
  // scope rides this call for free (D#135 P3). COALESCE, not a plain set:
299
332
  // an omitted or off-enum value normalizes to null and must not erase a
300
333
  // 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);
334
+ // D#12: the live-row guard, on the WHERE and not merely on the SELECT that chose
335
+ // the row. The Haiku call above is up to BG_LLM_TIMEOUT_MS (45 s), long enough for
336
+ // a concurrent hook to supersede or auto-compress this row — R10 P3-3's finding,
337
+ // fixed then on the general branch only and carried by the concepts branch since
338
+ // D#6. This was the one branch of the four without it. `changes === 0` is a SKIP,
339
+ // not a success: it must not count as processed and must not rebuild a vector for
340
+ // a row that is no longer live.
341
+ const res = db
342
+ .prepare(
343
+ `UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope)
344
+ WHERE id = ? AND ${liveObsFilterSql('')}`,
345
+ )
346
+ .run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
347
+ if (res.changes === 0) {
348
+ skipped++;
349
+ continue;
350
+ }
304
351
  // Refresh the TF-IDF vector from the just-updated FTS text so the new
305
352
  // aliases reach the vector arm too — the narrow/wide branch rebuilds, this
306
353
  // one must as well. No-ops when the vector arm is off / vocab unbuilt.
@@ -308,6 +355,69 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
308
355
  processed++;
309
356
  continue;
310
357
  }
358
+ if (scope === 'concepts') {
359
+ // Concepts-only backfill (D#6). Writes concepts + facts and APPENDS them to the
360
+ // existing FTS text — never rebuilds it, for the reason the alias branch gives:
361
+ // a rebuild from concepts/facts drops the original narrative and alias terms and
362
+ // regresses recall. Never touches the user's curated title / narrative / lesson /
363
+ // type / importance, and never stamps optimized_at, so the wide pass keeps its
364
+ // own candidates exactly as the alias and scopes passes leave them.
365
+ const conceptsPrompt = `Extract search concepts and concrete facts from this coding memory. Return ONLY valid JSON, no markdown fences.
366
+
367
+ Title: ${truncate(cand.title || '(untitled)', 200)}
368
+ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
369
+
370
+ JSON: {"concepts":["kw1","kw2"],"facts":["specific fact 1","specific fact 2"]}
371
+ concepts: 3-8 short keyword phrases naming what this memory is ABOUT (systems, components, error classes, techniques).
372
+ facts: 1-4 specific, checkable statements the narrative actually asserts. Omit rather than invent.`;
373
+ const parsed = await callModelJSONAsync(conceptsPrompt, 'haiku', {
374
+ timeout: BG_LLM_TIMEOUT_MS,
375
+ maxTokens: 300,
376
+ });
377
+ const pickStrings = (v) =>
378
+ Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s.trim().length > 0) : [];
379
+ const conceptArr = pickStrings(parsed && parsed.concepts);
380
+ // No concepts is a SKIP, not an empty write: writing '' would leave the row in
381
+ // this pool forever, and the pass would burn one Haiku call per cycle on it.
382
+ if (!conceptArr.length) {
383
+ skipped++;
384
+ continue;
385
+ }
386
+ const factArr = pickStrings(parsed && parsed.facts);
387
+ const conceptsOnly = conceptArr.slice(0, 10).join(' ');
388
+ const factsOnly = factArr.slice(0, 10).join(' ');
389
+ const appendedText = [
390
+ cand.text || '',
391
+ conceptsOnly,
392
+ factsOnly,
393
+ cjkBigrams(`${conceptsOnly} ${factsOnly}`),
394
+ ]
395
+ .filter(Boolean)
396
+ .join(' ');
397
+ const safe = scrubRecord('observations', {
398
+ concepts: conceptsOnly,
399
+ facts: factsOnly,
400
+ text: appendedText,
401
+ });
402
+ // Fill-only-empty on `concepts` plus the live-row guard, both on the WHERE and
403
+ // not merely on the SELECT: the round-trip above is up to 45 s, long enough for a
404
+ // concurrent hook to supersede or compress the row (R10 P3-3) or for save-enrich
405
+ // to fill it. `facts` rides along with preserve-on-empty for the same reason the
406
+ // general pass preserves it — a partial answer must not wipe a filled column.
407
+ const res = db
408
+ .prepare(
409
+ `UPDATE observations SET concepts = ?, facts = COALESCE(NULLIF(?, ''), facts), text = ?
410
+ WHERE id = ? AND (concepts IS NULL OR concepts = '') AND ${liveObsFilterSql('')}`,
411
+ )
412
+ .run(safe.concepts, safe.facts, safe.text, cand.id);
413
+ if (res.changes === 0) {
414
+ skipped++;
415
+ continue;
416
+ }
417
+ rebuildVector(db, cand.id, [safe.text]);
418
+ processed++;
419
+ continue;
420
+ }
311
421
  const prompt = `Re-enrich this observation with structured metadata. Return ONLY valid JSON, no markdown fences.
312
422
 
313
423
  Title: ${truncate(cand.title || '(untitled)', 200)}
@@ -336,9 +446,24 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
336
446
  // hide a real observation until manual surgery. In wide scope, fall through and let
337
447
  // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
338
448
  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);
449
+ // D#12, and this one is not a stale-write guard — it is a POINTER guard.
450
+ // `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
451
+ // concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
452
+ // call, it holds a POSITIVE keeper id; overwriting that with -1 does not merely
453
+ // stamp a dead row, it destroys the link — lib/maintain-core.mjs:316 recovers
454
+ // orphans with `compressed_into > 0`, and recoverChildrenOf follows the same id.
455
+ // The sibling write in lib/maintain-core.mjs:631 already carries this predicate,
456
+ // so the codebase had decided the question and this site had not been updated.
457
+ const res = db
458
+ .prepare(
459
+ `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
460
+ WHERE id = ? AND ${liveObsFilterSql('')}`,
461
+ )
462
+ .run(Date.now(), cand.id);
463
+ if (res.changes === 0) {
464
+ skipped++;
465
+ continue;
466
+ }
342
467
  processed++;
343
468
  continue;
344
469
  }
@@ -1016,6 +1141,52 @@ export function clusterForCompression(candidates, db) {
1016
1141
  return clusters;
1017
1142
  }
1018
1143
 
1144
+ /**
1145
+ * The smart-compress prompt. Exported so a RULER can measure the shipped text.
1146
+ *
1147
+ * Extracted for benchmark/compress-veto-rate.mjs (D#10). It has to be one string in one
1148
+ * place: a ruler that retypes the prompt measures its own copy, which is exactly how
1149
+ * tests/handoff-simulation.test.mjs came to assert on a re-implementation while the real
1150
+ * hook emitted a block no user had ever seen.
1151
+ *
1152
+ * D#10. This prompt used to OPEN with "Summarize these related code memory observations",
1153
+ * asserting the premise it should have been testing, and the only bail was a missing title
1154
+ * — so the model had no way to refuse. The sibling executeMergeCluster has had
1155
+ * `should_merge` since it was written; these two LLM cluster paths disagreed about whether
1156
+ * the model may say no, and this is the one that HIDES its inputs (compressed_into removes
1157
+ * them from every injection and search surface and puts them out of recoverBuriedLessons'
1158
+ * reach).
1159
+ *
1160
+ * It matters because the upstream relatedness check is not always on:
1161
+ * clusterForCompression only computes cosine similarity when getVocabulary returns a
1162
+ * vocabulary, and that is null whenever the vector arm is off — which is the default
1163
+ * (CLAUDE_MEM_VECTORS !== '1'). The else branch groups by a 14-day window ALONE. Measured
1164
+ * with a control arm 2026-09-07: three unrelated observations over 12 days form 1 cluster
1165
+ * with the arm off and 0 with it on. Until that branch is decided (D#10 option a), this
1166
+ * veto is the only thing standing between the heuristic and an unattended write that hides
1167
+ * real rows.
1168
+ *
1169
+ * @param {Array<object>} observations cluster members
1170
+ * @returns {string}
1171
+ */
1172
+ export function buildCompressPrompt(observations) {
1173
+ const obsDescriptions = observations
1174
+ .map(
1175
+ (o, i) =>
1176
+ `${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)}` : ''}`,
1177
+ )
1178
+ .join('\n');
1179
+
1180
+ 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.
1181
+
1182
+ Observations:
1183
+ ${obsDescriptions}
1184
+
1185
+ 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"]}
1186
+ 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.
1187
+ When true: preserve all important decisions, lessons, and specific facts.`;
1188
+ }
1189
+
1019
1190
  export async function executeSmartCompressCluster(db, observations, project) {
1020
1191
  if (observations.length < 3) return { compressed: false };
1021
1192
 
@@ -1023,25 +1194,18 @@ export async function executeSmartCompressCluster(db, observations, project) {
1023
1194
  if (!gotSlot) return { compressed: false };
1024
1195
 
1025
1196
  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"]}`;
1197
+ const prompt = buildCompressPrompt(observations);
1039
1198
 
1040
1199
  const parsed = await callModelJSONAsync(prompt, 'sonnet', {
1041
1200
  timeout: BG_LLM_TIMEOUT_MS,
1042
1201
  maxTokens: 1000,
1043
1202
  });
1044
- if (!parsed || !parsed.title) return { compressed: false };
1203
+ // Fail CLOSED, exactly as `should_merge` does: an omitted verdict refuses. The two
1204
+ // failure directions are not symmetric — refusing wrongly means a compression did not
1205
+ // happen, proceeding wrongly means unrelated observations were hidden from every
1206
+ // surface. On a path that hides its inputs, silence is not consent.
1207
+ if (!parsed || !parsed.should_compress) return { compressed: false };
1208
+ if (!parsed.title) return { compressed: false };
1045
1209
 
1046
1210
  // Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
1047
1211
  const title = truncate(scrubSecrets(parsed.title || ''), 120);
@@ -1193,6 +1357,11 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1193
1357
  // lesson_learned per row, so counting by materialising was the one place this
1194
1358
  // round pulled megabytes to print an integer. (pre-tag review NOTE 11)
1195
1359
  const reenrichScopes = countReenrichCandidates(db, 'scopes', project);
1360
+ // D#6: the concepts-backfill backlog. Reported for the same reason as the three
1361
+ // above — a pool whose size is invisible cannot be sized for a one-shot drain
1362
+ // (`optimize --run --task re-enrich --scope concepts --max N`), and this pool is
1363
+ // the one that holds every save-enriched manual save.
1364
+ const reenrichConcepts = findReenrichCandidates(db, 5000, { scope: 'concepts', project }).length;
1196
1365
 
1197
1366
  const concepts = extractUniqueConcepts(db, 500, { project });
1198
1367
  const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
@@ -1209,6 +1378,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1209
1378
  reenrichWide,
1210
1379
  reenrichAliases,
1211
1380
  reenrichScopes,
1381
+ reenrichConcepts,
1212
1382
  normalize: normalizeReady ? concepts.length : 0,
1213
1383
  normalizeGateOpen: shouldRunNormalize(project),
1214
1384
  clusterMerge,
@@ -1240,7 +1410,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
1240
1410
  * is budgeted separately, up to the re-enrich slice again — see the rationale at
1241
1411
  * the call site. Its calls are enum-classification only (maxTokens 60).
1242
1412
  * @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.
1413
+ * @param {'narrow'|'wide'|'aliases'|'concepts'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
1244
1414
  * 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
1245
1415
  * 'aliases' (P1) backfills search_aliases on substantive alias-less rows regardless
1246
1416
  * of lesson (lesson-bearing manual saves) — adds ONLY aliases, never rewrites content.
@@ -1295,16 +1465,32 @@ export async function optimizeRun(
1295
1465
  // mis-prices it by an order of magnitude.
1296
1466
  // Cap is budget.reenrich, so the daily pass adds at most that many cheap
1297
1467
  // classification calls and an empty pool still costs nothing.
1468
+ //
1469
+ // D#6 adds a FOURTH claimant, 'concepts', and it SHARES the aliases half
1470
+ // rather than taking one of its own. Sharing keeps the boundary this comment
1471
+ // already describes: the main scope still gets at least half the budget, so
1472
+ // adding a pool cannot starve the lesson enrichment that is the point of the
1473
+ // pass. Aliases is served FIRST out of that shared half, on a stated
1474
+ // ordering: an alias-less row is paraphrase-UNFINDABLE (a recall zero),
1475
+ // while a conceptless row is findable and merely ranks worse — measured at
1476
+ // +0.0846 R@10 on the benchmark fixture, which is real but is not a zero.
1477
+ // Both pools drain (each is idempotent via the column it fills), so the
1478
+ // ordering decides which drains first, not which gets served at all.
1298
1479
  const half = Math.max(1, Math.floor(budget.reenrich / 2));
1299
1480
  const aliasBudget = Math.min(
1300
1481
  half,
1301
1482
  findReenrichCandidates(db, half, { scope: 'aliases', project }).length,
1302
1483
  );
1484
+ const conceptsBudget = Math.min(
1485
+ half - aliasBudget,
1486
+ findReenrichCandidates(db, Math.max(0, half - aliasBudget), { scope: 'concepts', project })
1487
+ .length,
1488
+ );
1303
1489
  const scopesBudget = Math.min(
1304
1490
  budget.reenrich,
1305
1491
  findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
1306
1492
  );
1307
- const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, {
1493
+ const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget - conceptsBudget, {
1308
1494
  scope: reenrichScope,
1309
1495
  project,
1310
1496
  });
@@ -1312,14 +1498,31 @@ export async function optimizeRun(
1312
1498
  aliasBudget > 0
1313
1499
  ? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
1314
1500
  : { processed: 0, skipped: 0 };
1501
+ const conceptsRes =
1502
+ conceptsBudget > 0
1503
+ ? await executeReenrich(db, conceptsBudget, { scope: 'concepts', project })
1504
+ : { processed: 0, skipped: 0 };
1315
1505
  const scopesRes =
1316
1506
  scopesBudget > 0
1317
1507
  ? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
1318
1508
  : { processed: 0, skipped: 0 };
1319
1509
  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 },
1510
+ processed:
1511
+ (mainRes.processed || 0) +
1512
+ (aliasRes.processed || 0) +
1513
+ (conceptsRes.processed || 0) +
1514
+ (scopesRes.processed || 0),
1515
+ skipped:
1516
+ (mainRes.skipped || 0) +
1517
+ (aliasRes.skipped || 0) +
1518
+ (conceptsRes.skipped || 0) +
1519
+ (scopesRes.skipped || 0),
1520
+ byScope: {
1521
+ [reenrichScope]: mainRes,
1522
+ aliases: aliasRes,
1523
+ concepts: conceptsRes,
1524
+ scopes: scopesRes,
1525
+ },
1323
1526
  };
1324
1527
  } else {
1325
1528
  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.0",
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.0",
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.0",
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.