claude-mem-lite 6.4.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.4.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.4.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;
@@ -585,7 +659,12 @@ export function buildSessionContextLines(
585
659
  } else if (!latestSummary && !effectiveQuiet()) {
586
660
  // Fallback: no summary AND no key observations — show recent activity.
587
661
  // Skipped under QUIET_HOOKS since the Recent table already carries titles.
588
- const recentObs = (observations.length >= 3 ? observations : fallbackObs).slice(0, 3);
662
+ // Slice FIRST, then sort: the slice is the selection (top 3 by value density) and must
663
+ // stay that way; only the order they are printed in is corrected, same as the Recent
664
+ // table below. Sorting before the slice would silently change WHICH three are injected.
665
+ const recentObs = (observations.length >= 3 ? observations : fallbackObs)
666
+ .slice(0, 3)
667
+ .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id);
589
668
  if (recentObs.length > 0) {
590
669
  summaryLines.push('### Recent Activity');
591
670
  for (const o of recentObs) {
@@ -695,8 +774,18 @@ export function buildSessionContextLines(
695
774
  }
696
775
 
697
776
  // 6. Recent observations table
777
+ //
778
+ // SELECTION order (greedy knapsack, value density) is not DISPLAY order. This block used
779
+ // to render the picks in the order the knapsack happened to take them, under a heading
780
+ // that says "Recent" next to a Time column — so row 1 was not the newest row, and both a
781
+ // human and the model read it as if it were. Sorting here is display-only: `obsToShow` is
782
+ // already chosen, so the token budget and the row set are untouched (pinned by a case in
783
+ // tests/hook-context.test.mjs). Tiebroken on id for the same reason D#9 gives — an
784
+ // untiebroken tie flips direction, and two saves in one millisecond are common.
698
785
  const obsLines = [];
699
- const obsToShow = observations.length >= 3 ? observations : fallbackObs;
786
+ const obsToShow = [...(observations.length >= 3 ? observations : fallbackObs)].sort(
787
+ (a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id,
788
+ );
700
789
  if (obsToShow.length > 0) {
701
790
  const today = now.toISOString().slice(0, 10);
702
791
  obsLines.push(`### Recent (${today})`);
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-shared.mjs CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  shouldRecordSkew,
29
29
  SKEW_MARKER_PREFIX,
30
30
  } from './lib/schema-skew.mjs';
31
+ import { isDbUnusableError, DB_UNUSABLE_MARKER_PREFIX } from './lib/db-unusable.mjs';
32
+ import { shouldRecordOnce } from './lib/record-once.mjs';
31
33
  // Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
32
34
  // predicates and the handoff constants moved into `lib/` because two lib modules
33
35
  // imported them from here and dragged this file's whole import graph — haiku-client,
@@ -244,6 +246,9 @@ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
244
246
  // v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
245
247
  'last-mark-compressible-', // per-project auto-compress 24h gate
246
248
  SKEW_MARKER_PREFIX, // per-project schema-skew log dedup; regenerated on the next skewed open
249
+ // Same shape, same reason, and it was missed here first time round — the note above is
250
+ // about exactly this defect, two entries up.
251
+ DB_UNUSABLE_MARKER_PREFIX, // per-project unopenable-DB log dedup; regenerated on the next failing open
247
252
  ]);
248
253
 
249
254
  // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
@@ -405,12 +410,28 @@ export function lastSchemaSkew() {
405
410
  return lastSkew;
406
411
  }
407
412
 
413
+ // Same idea, other unhealable family: the file exists and SQLite will not open it. Held as a
414
+ // boolean rather than the error, because the only thing SessionStart needs is "which notice",
415
+ // and keeping an Error alive here would tempt a caller into rendering a stack trace at a user.
416
+ let lastUnusable = false;
417
+
418
+ /**
419
+ * True when the most recent openDb() returned null because the database file is not a usable
420
+ * database. Cleared by any successful open, so a repair mid-session stops the notice.
421
+ *
422
+ * @returns {boolean}
423
+ */
424
+ export function lastDbUnusable() {
425
+ return lastUnusable;
426
+ }
427
+
408
428
  export function openDb() {
409
429
  try {
410
430
  // WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
411
431
  // silently dead (null DB) on a corrupt WAL until the next MCP server start.
412
432
  const db = ensureDbWithWalRecovery();
413
433
  lastSkew = null;
434
+ lastUnusable = false;
414
435
  return db;
415
436
  } catch (e) {
416
437
  // Forward-incompat is its own family: it cannot be healed by anything this process can
@@ -423,10 +444,11 @@ export function openDb() {
423
444
  //
424
445
  // shouldRecordSkew is TOTAL by contract. Nothing in this catch may throw: the first cut
425
446
  // called getSessionId() here, which MINTS and writes a session id, so an unwritable
426
- // runtime dir turned openDb() itself into a thrower. All 13 call sites are written to
447
+ // runtime dir turned openDb() itself into a thrower. All 12 openDb() call sites in hook.mjs are written to
427
448
  // no-op on null and none of them expects an exception.
428
449
  if (isSchemaSkewError(e)) {
429
450
  lastSkew = schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
451
+ lastUnusable = false;
430
452
  // Guarded even though inferProject() reads env and cwd: "the only statement in this
431
453
  // catch cannot throw" was true of the original one-line body and stopped being true
432
454
  // the moment anything was added. An unscoped marker is a worse dedup, not a crash.
@@ -441,8 +463,31 @@ export function openDb() {
441
463
  }
442
464
  return null;
443
465
  }
444
- // Still null, still no throw a hook must never crash the host session, and all
445
- // eight call sites in hook.mjs are written to no-op on null. But "returned null"
466
+ // The OTHER unhealable family, and it was the silent one. A file that is not a database
467
+ // repeats on every fire exactly like a skew does, and until now took the generic branch
468
+ // below: one full stack trace per SessionStart fire (measured: 20 fires → 20 records, ~860 B
469
+ // each), no dedup, and no user-visible word anywhere in the session. Same treatment as skew
470
+ // — record once per project per hour, and hand SessionStart a flag to speak with.
471
+ //
472
+ // Nothing in this branch may throw: `isDbUnusableError` is a regex over a string and
473
+ // `shouldRecordOnce` is total by contract, which is exactly the property the first cut of
474
+ // the skew dedup lost by calling a function that WRITES.
475
+ if (isDbUnusableError(e)) {
476
+ lastUnusable = true;
477
+ lastSkew = null; // the two flags are a set: whichever family fired last is the true one
478
+ let project = '';
479
+ try {
480
+ project = inferProject();
481
+ } catch {
482
+ /* total: the marker degrades to one shared file */
483
+ }
484
+ if (shouldRecordOnce(RUNTIME_DIR, DB_UNUSABLE_MARKER_PREFIX, project, 'unusable')) {
485
+ recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
486
+ }
487
+ return null;
488
+ }
489
+ // Still null, still no throw — a hook must never crash the host session, and all 12
490
+ // openDb() call sites in hook.mjs are written to no-op on null. But "returned null"
446
491
  // used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
447
492
  // reported 0 and doctor printed "no recent silent hook breakage" while every
448
493
  // capture path was dead (audit B1, 2026-08-14 — the same blindness that hid the
package/hook.mjs CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  } from './hook-episode.mjs';
63
63
  // CODE_DIR, not DB_DIR: the schema-skew notice asks which CODE homes exist, and those are
64
64
  // always homedir-rooted even when CLAUDE_MEM_DIR relocates the data.
65
- import { DB_DIR, CODE_DIR } from './schema.mjs';
65
+ import { DB_DIR, DB_PATH, CODE_DIR } from './schema.mjs';
66
66
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
67
67
  import { entry as preCompactEntry } from './hook-precompact.mjs';
68
68
  import {
@@ -85,6 +85,7 @@ import {
85
85
  sweepOrphanEpisodeFiles,
86
86
  sweepStaleProjectMarkers,
87
87
  lastSchemaSkew,
88
+ lastDbUnusable,
88
89
  } from './hook-shared.mjs';
89
90
  import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
90
91
  import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
@@ -1405,6 +1406,34 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1405
1406
  // which was ~all of the cost.)
1406
1407
  try {
1407
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
+ }
1408
1437
  // B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
1409
1438
  // the next SessionStart can surface "N unsaved bugfix-shape edits"
1410
1439
  // alongside cite-recall. Same scan target (transcript already in OS
@@ -1421,11 +1450,13 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1421
1450
  // always said. Stop fires once per assistant TURN, so incrementing here silenced
1422
1451
  // the nudge inside the first session — this machine read lowStreak 58 against 26
1423
1452
  // transcripts before the fix.
1424
- const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(
1425
- prevPayload,
1426
- ccSessionId,
1427
- stats,
1428
- );
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
+ });
1429
1460
  // G3: finalized-in-conversation + zero deliberate persistence →
1430
1461
  // decisionSignal rides the payload; next SessionStart reminds once.
1431
1462
  let decisionSignal = null;
@@ -1448,6 +1479,7 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1448
1479
  }
1449
1480
  const payload = {
1450
1481
  ...stats,
1482
+ ...gate,
1451
1483
  ...bugfixStats,
1452
1484
  lowStreak,
1453
1485
  streakBase,
@@ -1592,8 +1624,9 @@ async function handleStop() {
1592
1624
 
1593
1625
  // Build the SessionStart nudge line shown when the prior session's cite-recall
1594
1626
  // fell below threshold. Empty string = no surface (insufficient signal, recall
1595
- // already healthy, or feature opted-out via env). Default threshold 0.6,
1596
- // 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.
1597
1630
  // Thin wrapper: lib/cite-back-hint.mjs owns the logic so it stays unit-tested.
1598
1631
  // Passing module-level RUNTIME_DIR keeps the call site identical to pre-v2.83.1.
1599
1632
  function buildCiteRecallNudge(project) {
@@ -2354,6 +2387,33 @@ async function emitSchemaSkewNotice() {
2354
2387
  }
2355
2388
  }
2356
2389
 
2390
+ /**
2391
+ * The corruption twin of emitSchemaSkewNotice. Same channels, same reason they are BOTH used:
2392
+ * queueHookContext reaches the model, queueHookSystemMessage reaches the human, and a notice
2393
+ * whose whole job is handing the user a command must not depend on the assistant volunteering
2394
+ * it (lib/hook-stdout.mjs names v3.70.0 for exactly that mistake).
2395
+ *
2396
+ * Dynamically imported like its twin: a cold path must not cost the healthy SessionStart a
2397
+ * directory scan for backup snapshots.
2398
+ */
2399
+ async function emitDbUnusableNotice() {
2400
+ try {
2401
+ if (!lastDbUnusable()) return;
2402
+ const mod = await import('./lib/db-unusable.mjs');
2403
+ // TWO DIFFERENT STRINGS, unlike the skew twin, and the asymmetry is the point: the
2404
+ // human channel carries the repair command, the model channel carries only the fact.
2405
+ // See formatDbUnusableModelNotice — this family's remedy overwrites the database.
2406
+ queueHookSystemMessage(
2407
+ mod.formatDbUnusableNotice({ dbPath: DB_PATH, remedy: mod.dbUnusableRemedy(DB_PATH) }),
2408
+ );
2409
+ queueHookContext('SessionStart', mod.formatDbUnusableModelNotice());
2410
+ } catch (e) {
2411
+ // A hook must never crash the host session, and a notice that cannot render is better
2412
+ // handled by staying quiet than by taking SessionStart down with it.
2413
+ debugCatch(e, 'session-start-db-unusable');
2414
+ }
2415
+ }
2416
+
2357
2417
  async function handleSessionStart() {
2358
2418
  // GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
2359
2419
  // moved here from pre-tool-recall.js's hot path.
@@ -2542,7 +2602,13 @@ async function handleSessionStart() {
2542
2602
  // only other signal it produces is a `-32000 Connection closed` from the MCP host, which
2543
2603
  // names nothing. Measured 2026-09-08: a whole day of it, >=648 log lines, zero words to
2544
2604
  // the user. This is the surface the user actually reads.
2605
+ //
2606
+ // A database file that is not a database is the same shape and got the same treatment:
2607
+ // unhealable without the user, disables every path, and every OTHER surface (CLI exit 1,
2608
+ // `status`, `doctor` with an exact repair command) already reports it — leaving the
2609
+ // in-session one as the only silent one.
2545
2610
  await emitSchemaSkewNotice();
2611
+ await emitDbUnusableNotice();
2546
2612
  return;
2547
2613
  }
2548
2614