claude-mem-lite 6.5.0 → 6.6.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.5.0",
12
+ "version": "6.6.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.5.0",
3
+ "version": "6.6.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
@@ -896,7 +896,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
896
896
  | `MEM_DISABLE_CITATION_DECAY` | `1` disables only the decay writes, keeping access-count bumps. | _(enabled)_ |
897
897
  | `CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD` | **Removed — inert.** Tuned the per-project adoption gate, which is gone (D#204). Setting it warns on stderr and changes nothing. | _(n/a)_ |
898
898
  | `CLAUDE_MEM_NO_CITE_NUDGE` | `1` fully silences the cite-back nudge. | _(enabled)_ |
899
- | `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.6` |
899
+ | `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.4` |
900
+ | `CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR` | `1` judges the wide cite-recall ratio (every `#NN`-shaped token the model saw) instead of the lessons the hooks injected. **Half of the revert**: the threshold moved too, so pre-v6.6.0 gating needs this **and** `CLAUDE_MEM_CITE_NUDGE_THRESHOLD=0.6`. This switch alone gives you the wide ratio judged at 0.4, which is neither release's behaviour. | unset |
900
901
  | `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
901
902
  | `CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER` | Consecutive low-cite sessions before the nudge goes quiet; `0` = never silence. | `3` |
902
903
  | `CLAUDE_MEM_CITATION_RELEVANCE_GATE` | Stop credits an `access_count` to a memory the session cited only when something made that memory relevant to the session — it was injected, or you typed its `#NN` yourself. `off` restores the pre-v3.84.0 behaviour of crediting every `#NN` the assistant wrote, which over-counts sessions that discuss memories in prose (release notes, audit reports): measured on real transcripts, 267 of 859 credited (id, session) pairs — 31.1% — were mentions nothing had put in front of the model. Superseded citations are redirected to their keeper on both settings. | _(on)_ |
package/hook-context.mjs CHANGED
@@ -336,6 +336,69 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
336
336
  * removes the paired hint comment if present, and normalizes residual whitespace
337
337
  * at the seam. Uses atomic tmp+rename write.
338
338
  */
339
+ /**
340
+ * Half-open [start, end) ranges of every fenced code span in a markdown document.
341
+ *
342
+ * A fence opens on a line whose first non-space run is three or more backticks or tildes,
343
+ * and closes on the next line opening with at least as many of the SAME character — CommonMark's
344
+ * rule, and the reason a ```` ```` ```` block can contain a ``` line. An unterminated fence
345
+ * runs to EOF.
346
+ */
347
+ function fencedRanges(content) {
348
+ const ranges = [];
349
+ let open = null;
350
+ let offset = 0;
351
+ for (const line of content.split('\n')) {
352
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
353
+ if (m) {
354
+ if (!open) {
355
+ open = { char: m[1][0], len: m[1].length, start: offset };
356
+ } else if (m[1][0] === open.char && m[1].length >= open.len) {
357
+ ranges.push([open.start, offset + line.length]);
358
+ open = null;
359
+ }
360
+ }
361
+ offset += line.length + 1; // +1 for the '\n' split removed
362
+ }
363
+ if (open) ranges.push([open.start, content.length]);
364
+ return ranges;
365
+ }
366
+
367
+ /** True when `idx` sits inside a backtick-delimited inline code span on its own line. */
368
+ function insideInlineCode(content, idx) {
369
+ const lineStart = content.lastIndexOf('\n', idx - 1) + 1;
370
+ // Count backtick RUNS before the position; an odd count means the position is inside a
371
+ // span. Runs, not characters: ``code with ` inside`` is one span opened by two ticks.
372
+ const before = content.slice(lineStart, idx);
373
+ const runs = before.match(/`+/g);
374
+ return runs !== null && runs.length % 2 === 1;
375
+ }
376
+
377
+ /**
378
+ * The last index of `needle` that is a real block's tag, or -1.
379
+ *
380
+ * Three exclusions, and the pre-ship review is why there are three rather than one. A
381
+ * legacy block written by the old `updateClaudeMd` put its tags at COLUMN 0 on their own
382
+ * lines, so anything else is prose ABOUT the tag:
383
+ * - inside a fenced code span (```/~~~) — the first cut, and only a third of it
384
+ * - not at the start of a line — an inline mention mid-sentence
385
+ * - indented four or more spaces — CommonMark's other code block
386
+ * Measured before the widening, with the shipped function: an inline span went 101 -> 55
387
+ * bytes and an indented block 95 -> 33, atomically, with no backup.
388
+ */
389
+ function lastIndexOutsideFences(content, needle, ranges) {
390
+ let idx = content.lastIndexOf(needle);
391
+ while (idx !== -1) {
392
+ const lineStart = content.lastIndexOf('\n', idx - 1) + 1;
393
+ const indent = content.slice(lineStart, idx);
394
+ const atLineStart = indent.length === 0;
395
+ const inFence = ranges.some(([a, b]) => idx >= a && idx < b);
396
+ if (atLineStart && !inFence && !insideInlineCode(content, idx)) return idx;
397
+ idx = content.lastIndexOf(needle, idx - 1);
398
+ }
399
+ return -1;
400
+ }
401
+
339
402
  export function cleanupClaudeMdLegacyBlock() {
340
403
  // v2.48 P2-4: idempotent marker. First run (whether it finds a block or not,
341
404
  // whether CLAUDE.md exists or not) drops a project-scoped marker in RUNTIME_DIR.
@@ -369,10 +432,21 @@ export function cleanupClaudeMdLegacyBlock() {
369
432
  const startTag = '<claude-mem-context>';
370
433
  const endTag = '</claude-mem-context>';
371
434
 
372
- // Use lastIndexOf so documentation references to the tag earlier in the file
373
- // (e.g. inside a code block in architecture notes) are not accidentally swept.
374
- const startIdx = content.lastIndexOf(startTag);
375
- const endIdx = content.lastIndexOf(endTag);
435
+ // A4 (audit 2026-09-08): `lastIndexOf` alone was NOT the protection the old comment
436
+ // here claimed. It shields a documentation reference only when a real block sits AFTER
437
+ // it; when the file's only occurrence IS the reference — the ordinary case for someone
438
+ // who wrote down what this plugin emits — both searches land on it, `startIdx < endIdx`
439
+ // holds, and the user's fenced sample is deleted with its two fences spliced into a
440
+ // broken ``````. Atomic write, no backup, once per project, so it shows up as a small
441
+ // stray diff days later. Measured on a plain 226-byte CLAUDE.md: 226 → 145.
442
+ //
443
+ // "The tag is alone on its line" does not discriminate — inside a fence it usually is.
444
+ // The fence itself is the signal, so fenced spans are excluded before the search, and an
445
+ // UNTERMINATED fence is treated as running to EOF: that errs toward leaving the file
446
+ // alone, which is the safe direction for a write into someone's own notes.
447
+ const fenced = fencedRanges(content);
448
+ const startIdx = lastIndexOutsideFences(content, startTag, fenced);
449
+ const endIdx = lastIndexOutsideFences(content, endTag, fenced);
376
450
  if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
377
451
  dropMarker();
378
452
  return;
package/hook-llm.mjs CHANGED
@@ -279,7 +279,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
279
279
  `
280
280
  SELECT title FROM observations
281
281
  WHERE project = ? AND created_at_epoch > ?
282
- ORDER BY created_at_epoch DESC LIMIT 10
282
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 10
283
283
  `,
284
284
  )
285
285
  .all(project, fiveMinAgo);
@@ -313,7 +313,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
313
313
  `
314
314
  SELECT title FROM observations
315
315
  WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
316
- ORDER BY created_at_epoch DESC LIMIT 60
316
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 60
317
317
  `,
318
318
  )
319
319
  .all(project, threeDaysAgo, fiveMinAgo);
@@ -331,7 +331,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
331
331
  `
332
332
  SELECT minhash_sig FROM observations
333
333
  WHERE project = ? AND created_at_epoch > ? AND minhash_sig IS NOT NULL
334
- ORDER BY created_at_epoch DESC LIMIT 200
334
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 200
335
335
  `,
336
336
  )
337
337
  .all(project, sevenDaysAgo);
@@ -1383,7 +1383,7 @@ export async function handleLLMSummary() {
1383
1383
  FROM observations
1384
1384
  WHERE memory_session_id = ?
1385
1385
  AND ${notLowSignalTitleClause('')}
1386
- ORDER BY created_at_epoch DESC
1386
+ ORDER BY created_at_epoch DESC, id DESC
1387
1387
  LIMIT 30
1388
1388
  `,
1389
1389
  )
@@ -1585,28 +1585,33 @@ ${obsList}`;
1585
1585
  // spinning up the full LLM dispatcher. Lets the e2e leak test verify that
1586
1586
  // the observations INSERT path scrubs all configured text fields.
1587
1587
  export const __insertObservationForTest = (db, obs) => {
1588
+ // R11 C-P3-3: this used to hand-spell an 18-column INSERT of its own while the
1589
+ // shipping writer above went through insertObservationRow's 19-column list, so the
1590
+ // secret-scrub leak test asserted on a REPLICA of the write point rather than the
1591
+ // write point. Column-list drift between the two is exactly what
1592
+ // lib/observation-write.mjs exists to make impossible, and a test copy is the one
1593
+ // caller that can drift without anyone noticing — it has no user.
1588
1594
  const safe = scrubRecord('observations', obs);
1589
- db.prepare(
1590
- `INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
1591
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1592
- ).run(
1593
- obs.session_id,
1594
- obs.project,
1595
- safe.text,
1596
- 'change',
1597
- safe.title,
1598
- safe.subtitle,
1599
- safe.narrative,
1600
- safe.concepts,
1601
- safe.facts,
1602
- obs.files_read,
1603
- obs.files_modified,
1604
- obs.importance,
1605
- obs.minhash_sig,
1606
- safe.lesson_learned,
1607
- safe.search_aliases,
1608
- obs.branch,
1609
- new Date().toISOString(),
1610
- Date.now(),
1611
- );
1595
+ const now = new Date();
1596
+ return insertObservationRow(db, {
1597
+ memory_session_id: obs.session_id,
1598
+ project: obs.project,
1599
+ text: safe.text,
1600
+ type: 'change',
1601
+ title: safe.title,
1602
+ subtitle: safe.subtitle,
1603
+ narrative: safe.narrative,
1604
+ concepts: safe.concepts,
1605
+ facts: safe.facts,
1606
+ files_read: obs.files_read,
1607
+ files_modified: obs.files_modified,
1608
+ importance: obs.importance,
1609
+ minhash_sig: obs.minhash_sig,
1610
+ lesson_learned: safe.lesson_learned,
1611
+ search_aliases: safe.search_aliases,
1612
+ branch: obs.branch,
1613
+ created_at: now.toISOString(),
1614
+ created_at_epoch: now.getTime(),
1615
+ scope: normalizeScope(obs.scope),
1616
+ });
1612
1617
  };
package/hook-memory.mjs CHANGED
@@ -8,12 +8,9 @@ import {
8
8
  OBS_BM25,
9
9
  notLowSignalTitleClause,
10
10
  noisePenaltyClause,
11
- tokenizeHandoff,
12
- HANDOFF_STOP_WORDS,
13
- extractCjkKeywords,
14
11
  neutralizeContextDelimiters,
15
12
  } from './utils.mjs';
16
- import { upsFtsQuery } from './lib/ups-query.mjs';
13
+ import { upsFtsQuery, upsQueryTerms } from './lib/ups-query.mjs';
17
14
  import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
18
15
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
19
16
  import { recordMetric } from './lib/metrics.mjs';
@@ -169,17 +166,6 @@ function getCrossProjectBoost() {
169
166
  const n = parseFloat(raw);
170
167
  return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
171
168
  }
172
- function extractQueryTerms(text) {
173
- if (!text) return [];
174
- const ascii = tokenizeHandoff(text).filter((t) => !HANDOFF_STOP_WORDS.has(t));
175
- let cjk = [];
176
- try {
177
- cjk = extractCjkKeywords(text) || [];
178
- } catch {
179
- /* CJK extraction best-effort */
180
- }
181
- return [...new Set([...ascii, ...cjk.map((t) => String(t).toLowerCase())])];
182
- }
183
169
  // v2.41: hay spans every FTS column whose BM25 weight is >=5 in OBS_BM25
184
170
  // (title=10, subtitle=5, narrative=5, lesson_learned=8). Pre-v2.41 was only
185
171
  // title + lesson_learned — rows that matched on narrative but happened to
@@ -509,7 +495,16 @@ export function searchRelevantMemories(
509
495
  let coverageFiltered = aboveThreshold;
510
496
  const coverageThreshold = getCoverageThreshold();
511
497
  if (coverageThreshold > 0) {
512
- const queryTerms = extractQueryTerms(userPrompt);
498
+ // A1: the denominator is the terms the query was actually BUILT from, via the one
499
+ // source (lib/ups-query.mjs -> nlp.mjs::ftsQueryTokens). Counting anything else
500
+ // counts terms that were never searched, so no matched row can cover them and the
501
+ // ratio falls with prompt length until the whole surface goes silent.
502
+ //
503
+ // The first cut of this fix shared only maxChars and the pre-ship review measured
504
+ // the hole it left: sanitizeFtsQuery also caps at maxTokens = 64, so a 1511-character
505
+ // prompt — under the char cap, where the shared cut is a no-op — still returned []
506
+ // against a row whose narrative was the entire prompt.
507
+ const queryTerms = upsQueryTerms(userPrompt);
513
508
  if (queryTerms.length >= COVERAGE_MIN_QUERY_TERMS) {
514
509
  coverageFiltered = aboveThreshold.filter(
515
510
  (r) => candidateCoverage(r, queryTerms) >= coverageThreshold,
package/hook-optimize.mjs CHANGED
@@ -1517,6 +1517,13 @@ export async function executeSmartCompressCluster(db, observations, project) {
1517
1517
  //
1518
1518
  // optimized_at is not in OBS_COLUMNS, so it stays a separate UPDATE below — the
1519
1519
  // summary must be marked processed so the re-enrich pools do not pick it up.
1520
+ //
1521
+ // R11 C-P3-2 read the three-item list above as a completeness claim and asked why
1522
+ // the fourth OBS_COLUMNS entry, `scope`, is absent. It is absent on purpose: the
1523
+ // merge prompt does not ask for one, so there is no value to pass, and NULL is the
1524
+ // `scopes` backfill pool's own predicate (findReenrichCandidates, scope==='scopes')
1525
+ // — which is deliberately NOT gated on optimized_at, so stamping this row processed
1526
+ // does not evict it. Writing a guessed scope here would.
1520
1527
  const newId = insertObservationRow(db, {
1521
1528
  memory_session_id: sessionId,
1522
1529
  project,
package/hook.mjs CHANGED
@@ -1406,6 +1406,34 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1406
1406
  // which was ~all of the cost.)
1407
1407
  try {
1408
1408
  const stats = computeCiteRecall(transcriptPath);
1409
+ // D#19: the RATIO GATE's own denominator, alongside the wide one. `stats` counts
1410
+ // every `#NN`-shaped token in non-assistant text — tool_result bodies, file
1411
+ // contents, CLI output, pasted reports — which is the right caliber for "what has
1412
+ // the model seen" and the wrong one for "did it cite back what the hooks gave it"
1413
+ // (measured 11.2x inflation, R11-B-P2-2). Both are persisted; buildCiteRecallNudge
1414
+ // gates on this pair and falls back to the wide one for older payloads.
1415
+ //
1416
+ // Cost is an array iteration, not a parse: this block runs after the decay loop's
1417
+ // own extractInjectedBySurface on the same path, and lib/transcript-scan.mjs memoizes
1418
+ // the parse. mainOnly mirrors the decay loop — an id injected only inside a subagent
1419
+ // would otherwise enter the denominator while its citation lands in another
1420
+ // transcript, scoring a miss the main thread never had a chance to avoid.
1421
+ let gate = { gateInjected: null, gateRecalled: null, gateRatio: null };
1422
+ try {
1423
+ const gateInjectedIds = unionSurfaces(extractInjectedBySurface(transcriptPath, { mainOnly: true }));
1424
+ const gateCited = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
1425
+ let hit = 0;
1426
+ for (const id of gateInjectedIds) if (gateCited.has(id)) hit++;
1427
+ gate = {
1428
+ gateInjected: gateInjectedIds.size,
1429
+ gateRecalled: hit,
1430
+ gateRatio: gateInjectedIds.size > 0 ? hit / gateInjectedIds.size : 0,
1431
+ };
1432
+ } catch (e) {
1433
+ // Leaving the gate* keys null is the defined fallback, not a silent loss: the
1434
+ // reader treats a payload without them exactly like a pre-release one.
1435
+ debugCatch(e, 'handleStop-cite-recall-gate-denominator');
1436
+ }
1409
1437
  // B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
1410
1438
  // the next SessionStart can surface "N unsaved bugfix-shape edits"
1411
1439
  // alongside cite-recall. Same scan target (transcript already in OS
@@ -1422,11 +1450,13 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1422
1450
  // always said. Stop fires once per assistant TURN, so incrementing here silenced
1423
1451
  // the nudge inside the first session — this machine read lowStreak 58 against 26
1424
1452
  // transcripts before the fix.
1425
- const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(
1426
- prevPayload,
1427
- ccSessionId,
1428
- stats,
1429
- );
1453
+ // D#19: the streak advances on the SAME gate the nudge fires on, so the merged
1454
+ // stats go in. Handing it the wide triple alone would let the streak climb on a
1455
+ // verdict the SessionStart surface never reached — two policies, one counter.
1456
+ const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(prevPayload, ccSessionId, {
1457
+ ...stats,
1458
+ ...gate,
1459
+ });
1430
1460
  // G3: finalized-in-conversation + zero deliberate persistence →
1431
1461
  // decisionSignal rides the payload; next SessionStart reminds once.
1432
1462
  let decisionSignal = null;
@@ -1449,6 +1479,7 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1449
1479
  }
1450
1480
  const payload = {
1451
1481
  ...stats,
1482
+ ...gate,
1452
1483
  ...bugfixStats,
1453
1484
  lowStreak,
1454
1485
  streakBase,
@@ -1593,8 +1624,9 @@ async function handleStop() {
1593
1624
 
1594
1625
  // Build the SessionStart nudge line shown when the prior session's cite-recall
1595
1626
  // fell below threshold. Empty string = no surface (insufficient signal, recall
1596
- // already healthy, or feature opted-out via env). Default threshold 0.6,
1597
- // min injected 5 — both env-overridable for ops tuning + tests.
1627
+ // already healthy, or feature opted-out via env). Default threshold 0.4 against the
1628
+ // HOOK-INJECTED denominator (v6.6.0, D#19), min injected 5 — both env-overridable for
1629
+ // ops tuning + tests; CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 restores the wide one.
1598
1630
  // Thin wrapper: lib/cite-back-hint.mjs owns the logic so it stays unit-tested.
1599
1631
  // Passing module-level RUNTIME_DIR keeps the call site identical to pre-v2.83.1.
1600
1632
  function buildCiteRecallNudge(project) {
package/install.mjs CHANGED
@@ -72,7 +72,6 @@ import {
72
72
  } from './lib/binding-probe.mjs';
73
73
  import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
74
74
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
75
- import { isDbUnusableError, dbUnusableRemedy } from './lib/db-unusable.mjs';
76
75
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
77
76
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
78
77
  import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
@@ -442,12 +441,23 @@ export function nonPluginMemRegistrations(listOutput) {
442
441
  * Exported for tests/doctor-db-remedy.test.mjs, which also drives the shipped doctor over a
443
442
  * corrupt file — a pure function nothing calls is the wiring gap this repo keeps finding.
444
443
  */
445
- export function dbCheckRemedy(dbPath, err) {
444
+ export async function dbCheckRemedy(dbPath, err) {
446
445
  if (isNativeBindingError(err)) return `Repair: ${nativeBindingRepairHint(PROJECT_DIR)}`;
447
446
  // Classification and remedy both live in lib/db-unusable.mjs since v6.5.0, because the hook
448
447
  // path now has to answer the same question in-session and two copies of a SQLite-message
449
448
  // regex is this repo's named twin-drift class. Doctor keeps its own SENTENCE (one line, no
450
449
  // leading banner); only the decision is shared.
450
+ //
451
+ // Audit 2026-09-08 P1-1: loaded HERE, not at the top of the file. `lib/db-unusable.mjs`
452
+ // reaches utils.mjs through db-backup, and utils.mjs pulls the whole retrieval/NLP
453
+ // subtree (nlp / synonyms / stop-words / scoring-sql / secret-scrub / …). As a static
454
+ // import that made every one of those files a prerequisite for doctor STARTING, on a
455
+ // command whose whole job is to tell a user which file is missing — so a copy install
456
+ // short one of them answered with a bare ERR_MODULE_NOT_FOUND stack. This function runs
457
+ // only inside the Database check's catch, so the cost of loading it lazily is paid by
458
+ // the rare failure rather than by every invocation. Same shape as the six dynamic
459
+ // imports doctor already uses.
460
+ const { isDbUnusableError, dbUnusableRemedy } = await import('./lib/db-unusable.mjs');
451
461
  if (!isDbUnusableError(err)) return null;
452
462
  const remedy = dbUnusableRemedy(dbPath);
453
463
  if (remedy.kind === 'unknown') return remedy.note;
@@ -1947,10 +1957,31 @@ async function doctor() {
1947
1957
  }
1948
1958
 
1949
1959
  // Plugin/hook lifecycle state
1950
- const settings = readSettings();
1951
- const hasHooks = hasMemHooksConfigured(settings);
1952
- const pluginDisabled = isPluginExplicitlyDisabled(settings);
1953
- if (pluginDisabled && hasHooks) {
1960
+ //
1961
+ // Audit 2026-09-08 P1-2: readSettings() THROWS on a settings.json that exists and does
1962
+ // not parse. That is the right answer for install / uninstall — every write path merges
1963
+ // into its return value, so refusing to act is the only safe move (R10 P1-8) — and the
1964
+ // wrong one to inherit here. doctor never writes settings.json, and a hand-edited
1965
+ // trailing comma is one of the most common self-inflicted "Claude Code is broken"
1966
+ // states, i.e. precisely when someone runs doctor. Inheriting the throw aborted the run
1967
+ // at this check: nine later checks never ran and `--json` emitted zero bytes.
1968
+ //
1969
+ // `null` means NOT READ, and each consumer says "not checked" rather than treating an
1970
+ // empty object as "nothing is configured". "I could not look" is not "there is nothing
1971
+ // there" — the same three-outcome rule the bash-hook and marketplace checks follow.
1972
+ let settings = null;
1973
+ try {
1974
+ settings = readSettings();
1975
+ } catch (e) {
1976
+ fail(`settings.json: unreadable — ${e.message}`);
1977
+ log(' The three checks that read it are skipped below; every other check still runs.');
1978
+ issues++;
1979
+ }
1980
+ const hasHooks = settings !== null && hasMemHooksConfigured(settings);
1981
+ const pluginDisabled = settings !== null && isPluginExplicitlyDisabled(settings);
1982
+ if (settings === null) {
1983
+ dwarn('Plugin lifecycle: not checked (settings.json unreadable)');
1984
+ } else if (pluginDisabled && hasHooks) {
1954
1985
  fail('Plugin lifecycle: plugin is disabled but claude-mem-lite hooks still remain in settings.json');
1955
1986
  issues++;
1956
1987
  } else if (pluginDisabled) {
@@ -1988,8 +2019,10 @@ async function doctor() {
1988
2019
  // with require-error noise every session. README's Uninstall section warns
1989
2020
  // about the right ordering; this check flags the broken state so it surfaces
1990
2021
  // even when the user skipped the README.
1991
- const orphanPaths = collectOrphanHookPaths(settings);
1992
- if (orphanPaths.length > 0) {
2022
+ const orphanPaths = settings === null ? null : collectOrphanHookPaths(settings);
2023
+ if (orphanPaths === null) {
2024
+ dwarn('Orphan hooks: not checked (settings.json unreadable)');
2025
+ } else if (orphanPaths.length > 0) {
1993
2026
  fail(
1994
2027
  `Orphan hooks: ${orphanPaths.length} settings.json entr${orphanPaths.length === 1 ? 'y references a missing file' : 'ies reference missing files'}`,
1995
2028
  );
@@ -2014,9 +2047,16 @@ async function doctor() {
2014
2047
  const bare = nonPluginMemRegistrations(list);
2015
2048
  // Registration, not directory — see pluginIsRegistered. Crediting a leftover cache dir
2016
2049
  // here told a working npm-channel install to delete its ONLY MCP registration.
2050
+ // Reuses the `settings` read above rather than calling readSettings() a second time:
2051
+ // the second call carried the same throw, so guarding only the first would have moved
2052
+ // the abort eleven checks later instead of removing it (P1-2).
2017
2053
  const viaPlugin =
2018
- !!shape?.activePluginVersion && pluginIsRegistered({ home: homedir(), settings: readSettings() });
2019
- if (viaPlugin && bare.length > 0) {
2054
+ !!shape?.activePluginVersion && settings !== null && pluginIsRegistered({ home: homedir(), settings });
2055
+ if (settings === null) {
2056
+ dwarn(
2057
+ `MCP registration: found ${bare.length} bare registration(s); whether the plugin also provides one is not checked (settings.json unreadable)`,
2058
+ );
2059
+ } else if (viaPlugin && bare.length > 0) {
2020
2060
  dwarn(
2021
2061
  `MCP registration: the plugin manifest provides the server AND a bare "${bare.join('", "')}" registration exists — the server is registered twice`,
2022
2062
  );
@@ -2106,7 +2146,17 @@ async function doctor() {
2106
2146
  // Every other ✗ on this screen carries a remedy; this one used to be the exception,
2107
2147
  // and a corrupt store is the failure a user is least able to diagnose unaided.
2108
2148
  // dbCheckRemedy returns null rather than invent one for an error it cannot classify.
2109
- const remedy = dbCheckRemedy(DB_PATH, e);
2149
+ // Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
2150
+ // its failure mode from load time into THIS catch — and an unhandled rejection here
2151
+ // aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
2152
+ // on the compound shape "a file is missing AND the database will not open". A remedy
2153
+ // is an extra sentence on a check that has already failed; never let it take the run.
2154
+ let remedy = null;
2155
+ try {
2156
+ remedy = await dbCheckRemedy(DB_PATH, e);
2157
+ } catch (remedyErr) {
2158
+ log(` (could not build a repair hint: ${remedyErr.message})`);
2159
+ }
2110
2160
  if (remedy) log(` ${remedy}`);
2111
2161
  issues++;
2112
2162
  }
@@ -189,8 +189,11 @@ export function countUnsavedBugfixShape(transcriptPath) {
189
189
  // ─── buildCiteRecallNudge (extracted from hook.mjs for unit-testability) ────
190
190
  // Reads `runtime/cite-recall-<project>.json` (written by handleStop) and
191
191
  // builds the SessionStart nudge surface. Two independent gates compose:
192
- // • cite-recall ratio gate: prior session's ratio < threshold (default 0.6)
193
- // AND injected count >= floor (default 5)
192
+ // • cite-recall ratio gate: prior session's HOOK-INJECTED cite-recall ratio <
193
+ // threshold (default 0.4) AND that same injected count >= floor (default 5).
194
+ // D#19: the wide computeCiteRecall ratio is the fallback for payloads written
195
+ // before v6.6.0, and the forced reading under
196
+ // CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1.
194
197
  // • B2 (v2.83.1) unsaved-bugfix gate: `unsaved > 0` (no min-volume floor —
195
198
  // the bugfix-shape heuristic already requires ≥3 entries)
196
199
  // Either gate can fire independently. Both off → empty string (no surface).
@@ -204,16 +207,66 @@ export function countUnsavedBugfixShape(transcriptPath) {
204
207
  // CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER (0 = never silence).
205
208
  export const CITE_NUDGE_SILENCE_AFTER = 3;
206
209
 
210
+ /**
211
+ * Which (injected, recalled, ratio) triple the gate judges, and whether it is the
212
+ * hook-injected one.
213
+ *
214
+ * D#19. `computeCiteRecall`'s denominator is deliberately wide — every `#NN`-shaped
215
+ * token in non-assistant text, which includes tool_result bodies, file contents, CLI
216
+ * output and pasted reports. That is the right caliber for "which ids has the model
217
+ * SEEN", and it is not the question this nag asks. The nag asks whether the model cited
218
+ * back the lessons the HOOKS put in front of it, and against the wide denominator that
219
+ * ratio is structurally small: measured 2026-09-08 over all 69 transcripts on this
220
+ * machine, wide median 0.125 vs hook-injected median 0.429, and the shipped 0.6
221
+ * threshold fired on 46 of 48 qualifying sessions (96%).
222
+ *
223
+ * A gate that is 96% true carries almost no information, and because `nextCiteLowStreak`
224
+ * only resets when the gate does NOT fire, the streak climbs to CITE_NUDGE_SILENCE_AFTER
225
+ * within the first few sessions and the surface goes quiet for the life of the project.
226
+ * That — not "the threshold is unsatisfiable", which was D#19's premise and is false
227
+ * (max ratio is 0.833 today among the sessions that clear the volume floor, which is the
228
+ * population the gate judges; over all 69 transcripts the hook-injected side reaches 1.000
229
+ * — state which rows, the two differ) — is the defect.
230
+ *
231
+ * Back-compat: a payload written before this release has no `gate*` keys, so the wide
232
+ * triple is used and the only change such a project sees is the threshold. Full revert:
233
+ * CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 plus CLAUDE_MEM_CITE_NUDGE_THRESHOLD=0.6.
234
+ */
235
+ function gateStats(data, env) {
236
+ const wide = {
237
+ injected: data?.injected,
238
+ recalled: data?.recalled,
239
+ ratio: data?.ratio,
240
+ hookScoped: false,
241
+ };
242
+ if (env.CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR === '1') return wide;
243
+ if (typeof data?.gateInjected === 'number' && typeof data?.gateRatio === 'number') {
244
+ return {
245
+ injected: data.gateInjected,
246
+ recalled: data.gateRecalled,
247
+ ratio: data.gateRatio,
248
+ hookScoped: true,
249
+ };
250
+ }
251
+ return wide;
252
+ }
253
+
207
254
  // True iff this session's stats satisfy the ratio-nag gate (low cite-recall with
208
255
  // enough injection volume to judge). Shared by buildCiteRecallNudge (decide to
209
256
  // nag) and nextCiteLowStreak (decide to keep silencing).
210
- function ratioGateFires(data, env) {
257
+ function ratioGateFires(rawData, env) {
258
+ const data = gateStats(rawData, env);
211
259
  // `Number(x) || d` was NaN-safe but swallowed an explicit 0, and 0 is meaningful
212
260
  // on BOTH knobs: threshold 0 means "never nag on ratio", min-injected 0 means "no
213
261
  // volume requirement". Neither was reachable through the env before.
262
+ // D#19: 0.6 → 0.4. Chosen off the hook-injected distribution measured 2026-09-08 on
263
+ // this machine's 69 transcripts (n=14 qualifying: p25 0.333, median 0.429, p75 0.5),
264
+ // where 0.4 fires on 6/14 = 43% — often enough to be a signal, rarely enough that
265
+ // lowStreak resets and the surface survives. Stamped rather than presented as
266
+ // calibrated: n=14 is small and the corpus grows every session.
214
267
  const threshold = envNumber(env.CLAUDE_MEM_CITE_NUDGE_THRESHOLD, {
215
268
  name: 'CLAUDE_MEM_CITE_NUDGE_THRESHOLD',
216
- defaultValue: 0.6,
269
+ defaultValue: 0.4,
217
270
  min: 0,
218
271
  max: 1,
219
272
  });
@@ -291,7 +344,9 @@ export function nextCiteStreakState(prev, ccSessionId, stats, env = process.env)
291
344
 
292
345
  // Env opt-outs:
293
346
  // • CLAUDE_MEM_NO_CITE_NUDGE=1 — disables BOTH gates (full silence)
294
- // • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.6)
347
+ // • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.4)
348
+ // • CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 — judge the wide computeCiteRecall
349
+ // ratio instead of the hook-injected one (pre-v6.6.0 behaviour)
295
350
  // • CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED — ratio gate min-volume (default 5)
296
351
  // • CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER — consecutive-low streak before the
297
352
  // ratio nag self-silences (default 3; 0 = never silence)
@@ -313,9 +368,15 @@ export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
313
368
  const silenced = silenceAfter > 0 && typeof data.lowStreak === 'number' && data.lowStreak >= silenceAfter;
314
369
  const lines = [];
315
370
  if (!silenced && ratioGateFires(data, env)) {
316
- const pct = Math.round(data.ratio * 100);
371
+ // Report the SAME triple the gate judged — printing the wide numbers next to a
372
+ // narrow verdict is how a surface stops being auditable.
373
+ const g = gateStats(data, env);
374
+ const pct = Math.round(g.ratio * 100);
375
+ // The `(recalled/injected)` parenthetical keeps its exact shape — two guards
376
+ // assert on it, and it is the part a reader scans for.
377
+ const scope = g.hookScoped ? 'of the lessons the hooks injected' : 'of the ids seen';
317
378
  lines.push(
318
- `[mem] Last session cite-recall ${pct}% (${data.recalled}/${data.injected}) — when injected lessons (#NN lines) inform your action, cite #NN explicitly so the contract loop stays observable.`,
379
+ `[mem] Last session cite-recall ${pct}% (${g.recalled}/${g.injected}) ${scope} — when injected lessons (#NN lines) inform your action, cite #NN explicitly so the contract loop stays observable.`,
319
380
  );
320
381
  }
321
382
  if (typeof data.unsaved === 'number' && data.unsaved > 0) {
@@ -230,7 +230,7 @@ export function saveObservation(db, params) {
230
230
  `
231
231
  SELECT id, title, text FROM observations
232
232
  WHERE project = ? AND created_at_epoch > ? AND ${liveObsFilterSql('')}
233
- ORDER BY created_at_epoch DESC LIMIT ?
233
+ ORDER BY created_at_epoch DESC, id DESC LIMIT ?
234
234
  `,
235
235
  )
236
236
  .all(project, dedupCutoff, DEDUP_RECENT_LIMIT);