claude-mem-lite 5.3.1 → 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.3.1",
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.3.1",
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
@@ -120,7 +120,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
120
120
  - **Schema auto-migration** -- Idempotent `ALTER TABLE` migrations run on every startup, safely adding new columns and indexes without data loss
121
121
  - **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
122
122
  - **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
123
- - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm does not currently fire** (tracked as R10-P1-1). The measurement, first: on the maintainer's own install `session_handoffs` holds 4 `exit` rows and **0** `clear` rows the `clear` snapshot has never once been written. The mechanism, as a hypothesis: SessionStart treats a session file left on disk as the marker of a previous session that ended without `Stop` (`hook.mjs:2393-2405`, whose own comment reads "Normal `/exit` deletes the file, so this only triggers for `/clear`, `/compact`, or crash recovery"), and that file is exactly what `Stop` deletes (`hook.mjs:1542`) which would make the branch unreachable if `Stop` runs at the end of every assistant turn rather than once per session. **That last clause is NOT verified**, and it is the whole question: the fix differs depending on whether Claude Code rotates its session id across `/clear`, so a real `/clear` stdin capture comes before any code change
123
+ - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm fires since v5.4.0** (R10-P1-1); before that it had never once written a row — `session_handoffs` on the maintainer's install held 4 `exit` rows and **0** `clear` rows. Two host facts settled it, both measured rather than assumed. (1) `Stop` runs at the end of every assistant *turn*, not once per session, and it deleted the session file that SessionStart reads to learn which session just ended so the branch was unreachable, and mem sessions were minted per turn (58 prompts over 16 host sessions produced 56 mem sessions and 56 summary rows, 2026-09-07). (2) Claude Code **rotates its session id across `/clear`**: of 21 real transcripts, 12 carry a `/clear` command record, and in 12/12 that record's timestamp precedes its own file's first record by ~0.1s the command is issued in the old session and replayed into a new file under a new id. So `Stop` no longer deletes the file, SessionStart asks the host's `source` (`startup`/`clear`/`compact`/`resume`) instead of guessing from the file, and the handoff's prompt lookup falls back to the unscoped set when the new session's id matches none. Revert path: `CLAUDE_MEM_LEGACY_STOP_UNLINK=1`
124
124
  - **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
125
125
  - **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
126
126
  - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic. (v3.39: the `/lesson` and `/bug` slash commands were redirected from this events table to searchable **observations** — `mem_search` never read the events table, so explicit saves were unfindable; the events table remains the auto-capture activity log.)
@@ -431,9 +431,10 @@ FTS5 indexes: `observations_fts` (title, subtitle, narrative, text, facts, conce
431
431
 
432
432
  ```
433
433
  SessionStart
434
- -> Generate session ID
435
- (the /clear|/compact handoff branch here is currently unreachable R10-P1-1,
436
- see Cross-session handoff above)
434
+ -> Read the host's `source` (startup | clear | compact | resume) from stdin
435
+ -> On clear/compact: read the outgoing session from the session file, save its
436
+ 'clear' handoff, emit the Working State block (R10-P1-1, fixed v5.4.0)
437
+ -> Generate session ID (overwrites the session file)
437
438
  -> Mark stale sessions (>24h active) as abandoned
438
439
  -> Clean orphaned/stale lock files
439
440
  -> Query recent observations (24h)
@@ -462,8 +463,9 @@ Stop
462
463
  -> Flush final episode buffer
463
464
  -> Save handoff snapshot (type 'exit')
464
465
  -> Mark session completed
465
- -> Delete the session file <- what makes the SessionStart /clear branch unreachable
466
466
  -> Spawn LLM summary worker (poll-based wait)
467
+ -> Keep the session file <- Stop fires per TURN; deleting it here re-minted a mem
468
+ session every turn and left the SessionStart /clear branch unreachable (v5.4.0)
467
469
  ```
468
470
 
469
471
 
@@ -800,6 +802,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
800
802
  | `CLAUDE_MEM_CJK_PREC_MIN` | Precision floor for CJK segmentation candidates. | `0.2` |
801
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)_ |
802
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)_ |
803
806
  | `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
804
807
  | `CLAUDE_MEM_VECTORS` | `1` re-enables the persisted TF-IDF vector arm (off by default; also needs a vector rebuild via `maintain`). | _(off)_ |
805
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)_ |
@@ -828,6 +831,7 @@ what is already stored — only whether new work runs.
828
831
  | Variable | Description | Default |
829
832
  |----------|-------------|---------|
830
833
  | `CLAUDE_MEM_SKIP_SUMMARY` | Skip the background LLM session summary at **both** of its spawn sites — `Stop`, and the SessionStart `/clear`-handoff path. Until v5.3.0 only the `Stop` one honoured it. | _(runs)_ |
834
+ | `CLAUDE_MEM_LEGACY_STOP_UNLINK` | Restore the pre-v5.4.0 behaviour where `Stop` deletes the session file. Documented revert path for the session-lifecycle change, not a supported configuration: it re-mints a mem session per turn and makes the `/clear` handoff unreachable again. Only reach for it on a host that fires `Stop` once per session rather than once per turn. | _(file kept)_ |
831
835
  | `CLAUDE_MEM_SKIP_EPISODE_LLM` | Skip LLM extraction on episode flush — observations are still batched, just not summarized. | _(runs)_ |
832
836
  | `CLAUDE_MEM_SKIP_SAVE_ENRICH` | Skip the background Haiku call that backfills `lesson_learned` / search aliases after a save. | _(runs)_ |
833
837
  | `CLAUDE_MEM_SKIP_COMPRESS` | Skip auto-compression of old observations. | _(runs)_ |
package/hook-handoff.mjs CHANGED
@@ -56,25 +56,35 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
56
56
  // scopeSessionId is absent or == sessionId (legacy/test/no-stdin), fall back to the
57
57
  // unfiltered query (identical to pre-D#26 behavior).
58
58
  const ccScope = scopeSessionId && scopeSessionId !== sessionId ? scopeSessionId : null;
59
- const prompts = ccScope
60
- ? db
61
- .prepare(
62
- `
59
+ const unscopedPrompts = () =>
60
+ db
61
+ .prepare(
62
+ `
63
63
  SELECT prompt_text FROM user_prompts
64
- WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
64
+ WHERE content_session_id = ?
65
65
  ORDER BY prompt_number ASC LIMIT 5
66
66
  `,
67
- )
68
- .all(sessionId, ccScope)
69
- : db
67
+ )
68
+ .all(sessionId);
69
+ let prompts = ccScope
70
+ ? db
70
71
  .prepare(
71
72
  `
72
73
  SELECT prompt_text FROM user_prompts
73
- WHERE content_session_id = ?
74
+ WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
74
75
  ORDER BY prompt_number ASC LIMIT 5
75
76
  `,
76
77
  )
77
- .all(sessionId);
78
+ .all(sessionId, ccScope)
79
+ : unscopedPrompts();
80
+ // R10-P1-1: on the /clear path the scope is the NEW session's CC id while the prompts
81
+ // being handed off belong to the OLD one, and the host rotates that id across /clear
82
+ // (measured 12/12 on real transcripts, 2026-09-07) — so the scoped query returns 0 and
83
+ // the whole handoff was silently skipped. Fall back to the unscoped set when, and only
84
+ // when, the scoped one is EMPTY: D#26 exists to stop two live sessions being MERGED into
85
+ // one working_on, and there is nothing to merge with when this session contributed no
86
+ // prompts. The alternative at that point is not a cleaner row, it is no row at all.
87
+ if (ccScope && prompts.length === 0) prompts = unscopedPrompts();
78
88
  if (prompts.length === 0) return; // Empty session — nothing to hand off
79
89
 
80
90
  // Filter prompts whose only content is workflow/control language ("继续",
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 });
package/hook.mjs CHANGED
@@ -1537,10 +1537,32 @@ async function handleStop() {
1537
1537
  // recreate at 432ms and watched a 300ms grace lose.
1538
1538
  if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) spawnBackground('llm-summary', sessionId, project);
1539
1539
 
1540
- // Clean session file AFTER spawning background
1541
- try {
1542
- unlinkSync(sessionFile());
1543
- } catch {}
1540
+ // The session file deliberately SURVIVES Stop (R10-P1-1). It used to be unlinked here,
1541
+ // on the model "Stop = /exit = the session is over". The host does not work that way:
1542
+ // Stop fires at the end of EVERY assistant turn, so the unlink minted a fresh mem
1543
+ // session on the next event and cost two things at once —
1544
+ //
1545
+ // • `sdk_sessions` / `session_summaries` counted turns, not sessions. Measured on the
1546
+ // maintainer's live DB 2026-09-07: 58 prompts over 16 host sessions produced 56
1547
+ // distinct mem sessions and 56 summary rows, 0 of which carried the LLM-only fields.
1548
+ // • handleSessionStart's mid-restart probe reads this file to learn which session just
1549
+ // ended. With it deleted every turn the probe never fired, so the /clear handoff
1550
+ // branch was unreachable in production — 0 `clear` rows against 21 real sessions.
1551
+ //
1552
+ // Lifetime is bounded by SESSION_EXPIRY_MS (12h) in getSessionId(), and every
1553
+ // SessionStart overwrites it via createSessionId(), so "one mem session per host
1554
+ // session" holds without anything having to delete it.
1555
+ //
1556
+ // CLAUDE_MEM_LEGACY_STOP_UNLINK=1 restores the pre-v5.4.0 unlink. It exists because the
1557
+ // measurements above are from ONE host build; a host that fires Stop once per session
1558
+ // instead of once per turn would be better served by the old shape, and a user who hits
1559
+ // that has no other lever. It is not a supported configuration — it re-breaks the /clear
1560
+ // handoff by design.
1561
+ if (process.env.CLAUDE_MEM_LEGACY_STOP_UNLINK === '1') {
1562
+ try {
1563
+ unlinkSync(sessionFile());
1564
+ } catch {}
1565
+ }
1544
1566
  }
1545
1567
 
1546
1568
  // ─── SessionStart Handler + CLAUDE.md Persistence (Tier 1 A, E) ─────────────
@@ -2336,13 +2358,20 @@ async function handleSessionStart() {
2336
2358
 
2337
2359
  // Read CC real session_id from hook stdin — used to scope handoff rows so parallel
2338
2360
  // sessions for the same project don't clobber each other (see docs/bug.txt).
2361
+ // `source` (startup | clear | compact | resume) is read here too: since Stop stopped
2362
+ // deleting the session file, the file's survival no longer tells us WHY this session
2363
+ // started, and the host's own word is the only non-guess (R10-P1-1).
2339
2364
  let ccSessionId = null;
2365
+ let startSource = null;
2340
2366
  try {
2341
2367
  const raw = await readStdin();
2342
2368
  const hookData = JSON.parse(raw.text);
2343
2369
  if (typeof hookData?.session_id === 'string' && hookData.session_id.length > 0) {
2344
2370
  ccSessionId = hookData.session_id;
2345
2371
  }
2372
+ if (typeof hookData?.source === 'string' && hookData.source.length > 0) {
2373
+ startSource = hookData.source;
2374
+ }
2346
2375
  } catch {
2347
2376
  /* stdin unavailable — legacy behavior */
2348
2377
  }
@@ -2390,19 +2419,32 @@ async function handleSessionStart() {
2390
2419
  }
2391
2420
  }
2392
2421
 
2393
- // Detect mid-session restart (/clear or /compact): if a recent session file exists,
2394
- // the previous session ended without Stop hook firing. Read BEFORE createSessionId()
2395
- // overwrites the session file. Normal /exit deletes the file, so this only triggers
2396
- // for /clear, /compact, or crash recovery.
2422
+ // Detect mid-session restart (/clear or /compact) and carry the ending session forward.
2423
+ // Read BEFORE createSessionId() overwrites the session file.
2424
+ //
2425
+ // The discriminator is the host's `source`, NOT the session file's survival. The old
2426
+ // comment here read "normal /exit deletes the file, so this only triggers for /clear,
2427
+ // /compact, or crash recovery" — but the deleter was Stop, which fires every turn, so
2428
+ // the file was always gone and this branch never triggered (R10-P1-1). Now that Stop
2429
+ // keeps the file, the file is always THERE, and asking it "why did this session start"
2430
+ // would answer /clear for a plain launch too. Only the host knows.
2431
+ //
2432
+ // `startup` and `resume` mean the previous session ended on its own terms and already
2433
+ // wrote its per-turn `exit` handoff, which UserPromptSubmit reads back — no clear
2434
+ // snapshot is owed. A null source (no stdin: tests, legacy hosts) keeps the old
2435
+ // file-presence behavior so nothing that used to reach this branch stops reaching it.
2436
+ const isMidSessionRestart = startSource !== 'startup' && startSource !== 'resume';
2397
2437
  let prevSessionId = null;
2398
2438
  let prevProject = null;
2399
- try {
2400
- const data = JSON.parse(readFileSync(sessionFile(), 'utf8'));
2401
- if (Date.now() - data.startedAt < SESSION_EXPIRY_MS) {
2402
- prevSessionId = data.id;
2403
- prevProject = data.project;
2404
- }
2405
- } catch {} // No session file = fresh startup, nothing to recover
2439
+ if (isMidSessionRestart) {
2440
+ try {
2441
+ const data = JSON.parse(readFileSync(sessionFile(), 'utf8'));
2442
+ if (Date.now() - data.startedAt < SESSION_EXPIRY_MS) {
2443
+ prevSessionId = data.id;
2444
+ prevProject = data.project;
2445
+ }
2446
+ } catch {} // No session file = fresh startup, nothing to recover
2447
+ }
2406
2448
 
2407
2449
  // Tier 1 A: Create unique session ID
2408
2450
  const sessionId = createSessionId();
package/install.mjs CHANGED
@@ -509,7 +509,7 @@ function registerMcpServer() {
509
509
  }
510
510
  }
511
511
 
512
- function dedupePluginCacheAndHooks({ managedHooks } = {}) {
512
+ export function dedupePluginCacheAndHooks({ managedHooks, isDev = false } = {}) {
513
513
  // 3b. Deduplicate: if marketplace plugin also registers MCP + hooks,
514
514
  // clear them to prevent double execution. install.mjs hooks (in settings.json)
515
515
  // point to ~/.claude-mem-lite/ (latest code in dev mode via symlinks),
@@ -593,17 +593,47 @@ function dedupePluginCacheAndHooks({ managedHooks } = {}) {
593
593
  const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
594
594
  if (existsSync(cacheBase)) {
595
595
  const launchSyncFiles = ['launch.mjs', 'launch-preflight.mjs'];
596
+ // Read, not remembered: the cache dir names ARE versions, so the comparison has to
597
+ // be against what this installer actually is. A stale constant here would re-open
598
+ // R10-P2-11 on the next release without changing a line of this block.
599
+ let selfVersion = null;
600
+ try {
601
+ selfVersion = JSON.parse(readFileSync(join(PROJECT_DIR, 'package.json'), 'utf8')).version;
602
+ } catch {
603
+ /* no readable package.json — treat every version as non-matching (sync nothing) */
604
+ }
596
605
  let clearedHooks = 0;
597
606
  for (const ver of readdirSync(cacheBase)) {
598
607
  const verDir = join(cacheBase, ver);
599
608
 
600
- // Sync launch.mjs + its preflight companion (issue #15)
601
- if (existsSync(join(verDir, 'scripts'))) {
609
+ // Sync launch.mjs + its preflight companion (issue #15).
610
+ //
611
+ // R10-P2-11: this used to run for EVERY cached version. Issue #15 is a dev-mode
612
+ // routing fix — the point is that a dev tree's launch.mjs reaches the cache the
613
+ // MCP server starts from — but nothing gated it, so a plain `install` (and the
614
+ // repair that SessionStart spawns in the background) pushed the installer's entry
615
+ // point into every OLD version dir, where it runs against that version's own
616
+ // `lib/`. Entry point and library are versioned together: HEAD's launch.mjs:72-73
617
+ // destructures `nativeBindingRepairHint` from ../lib/binding-probe.mjs, which
618
+ // v3.95.0 does not export, so :110 throws inside a catch and the user's repair
619
+ // hint disappears — a silent downgrade of the one message that tells them how to
620
+ // fix a dead binding. Reproduced in tests/sandbox/phaseB-npm.mjs §B9 (the old
621
+ // dir came back 9802B with `nativeBindingRepairHint` in it), which is the
622
+ // reproduction R10 §8 required before touching install().
623
+ //
624
+ // Dev mode still syncs everything: that is the fix's whole purpose, and a dev
625
+ // tree has no old versions to protect. Otherwise only the version dir that
626
+ // matches this installer — same release, so same expectations of `lib/`.
627
+ const versionMatches = isDev || ver === selfVersion;
628
+ if (versionMatches && existsSync(join(verDir, 'scripts'))) {
602
629
  for (const f of launchSyncFiles) {
603
630
  const src = join(PROJECT_DIR, 'scripts', f);
604
631
  if (existsSync(src)) {
605
632
  try {
606
- copyFileSync(src, join(verDir, 'scripts', f));
633
+ // Atomic for the same reason the two hooks.json writes above are: a
634
+ // torn launch.mjs is the MCP server's entry point, and the reader is
635
+ // Claude Code starting it, not us.
636
+ atomicWriteFileSync(join(verDir, 'scripts', f), readFileSync(src));
607
637
  } catch {
608
638
  /* keep going */
609
639
  }
@@ -959,7 +989,7 @@ async function install() {
959
989
  // re-read settings.json) is what keeps a future reorder from silently turning the
960
990
  // dedup off — the dependency is data, not sequence.
961
991
  const managedHooks = configureHooks();
962
- dedupePluginCacheAndHooks({ managedHooks });
992
+ dedupePluginCacheAndHooks({ managedHooks, isDev: IS_DEV });
963
993
  backupLegacyClaudeMemData();
964
994
  verifyDatabase();
965
995
  await dogfoodAutoAdopt();
@@ -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.3.1",
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.3.1",
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.3.1",
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.