claude-mem-lite 6.5.0 → 6.7.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.7.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.7.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
@@ -549,7 +549,8 @@ node install.mjs install # Install and configure
549
549
  node install.mjs uninstall # Remove (keep data)
550
550
  node install.mjs uninstall --purge # Remove and delete all data
551
551
  node install.mjs status # Show current status
552
- node install.mjs doctor # Diagnose issues
552
+ node cli.mjs doctor # Diagnose issues (cli.mjs, not install.mjs — see note)
553
+ node cli.mjs repair # Recover a broken install from the latest signed release
553
554
  node install.mjs cleanup-hooks # Remove only stale claude-mem-lite hooks from settings.json
554
555
  node install.mjs update # Force-check for updates and install them (direct install / npx mode)
555
556
 
@@ -559,6 +560,13 @@ npx claude-mem-lite uninstall # Remove (keep data)
559
560
  npx claude-mem-lite doctor # Diagnose issues
560
561
  ```
561
562
 
563
+ > `doctor` and `repair` are spelled `cli.mjs`, not `install.mjs`, on purpose. Those two are
564
+ > the commands you reach for when the install is already broken, and `install.mjs` resolves
565
+ > around a dozen static imports before its first line runs — one missing file and it exits
566
+ > with a Node stack instead of telling you which file. `cli.mjs` has no static local imports
567
+ > and catches that, naming the file and a repair command. Everything else in the list is
568
+ > unaffected either way.
569
+
562
570
  Notes:
563
571
  - Plugin mode only reports available updates; it does not self-update plugin files.
564
572
  To upgrade an installed plugin to the latest published version, run **inside Claude Code**:
@@ -594,7 +602,7 @@ git fetch --tags && git checkout v3.62.0
594
602
  # 4. To leave the pin later: git checkout main, then the normal update flow.
595
603
  ```
596
604
 
597
- Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node install.mjs doctor` before trusting search results.
605
+ Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node cli.mjs doctor` before trusting search results.
598
606
 
599
607
  ### doctor
600
608
 
@@ -896,7 +904,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
896
904
  | `MEM_DISABLE_CITATION_DECAY` | `1` disables only the decay writes, keeping access-count bumps. | _(enabled)_ |
897
905
  | `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
906
  | `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` |
907
+ | `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.4` |
908
+ | `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
909
  | `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
901
910
  | `CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER` | Consecutive low-cite sessions before the nudge goes quiet; `0` = never silence. | `3` |
902
911
  | `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/README.zh-CN.md CHANGED
@@ -461,7 +461,8 @@ node install.mjs install # 安装并配置
461
461
  node install.mjs uninstall # 移除(保留数据)
462
462
  node install.mjs uninstall --purge # 移除并删除所有数据
463
463
  node install.mjs status # 显示当前状态
464
- node install.mjs doctor # 诊断问题
464
+ node cli.mjs doctor # 诊断问题(用 cli.mjs 而非 install.mjs,见下方说明)
465
+ node cli.mjs repair # 从最新签名发布恢复损坏的安装
465
466
  node install.mjs cleanup-hooks # 只清理 settings.json 中残留的 claude-mem-lite hooks
466
467
  node install.mjs update # 强制检查并安装更新(direct install / npx 模式)
467
468
 
@@ -472,6 +473,11 @@ npx claude-mem-lite doctor # 诊断问题
472
473
  ```
473
474
 
474
475
  说明:
476
+ - `doctor` 与 `repair` 写成 `cli.mjs` 而不是 `install.mjs`,是有意的。这两条恰恰是安装
477
+ 已经坏掉时才会用到的命令,而 `install.mjs` 在执行第一行代码之前要解析十几个静态
478
+ import——少一个文件就直接吐一段 Node 栈,而不是告诉你少了哪个文件。`cli.mjs` 没有
479
+ 任何本地静态 import,会捕获这种失败并说出缺失的文件和修复命令。列表中其余命令两种
480
+ 写法都一样。
475
481
  - 插件模式只提示可用更新,不会自更新插件文件。
476
482
  - direct install / npx 模式保留自动更新,并使用 staged replacement;若依赖安装失败会回滚。
477
483
  - 如果你禁用了插件,但 `~/.claude/settings.json` 里还有旧的 mem hooks,可运行 `node install.mjs cleanup-hooks`。
package/cli.mjs CHANGED
@@ -30,6 +30,77 @@ const CLI_COMMANDS = new Set([
30
30
  // Kept as a named set so a stale script or muscle-memory invocation gets the reason rather
31
31
  // than a bare "Unknown command" plus a misleading edit-distance suggestion.
32
32
  const REMOVED_COMMANDS = new Set(['registry', 'import', 'enrich']);
33
+
34
+ // D#26 / R12 P1-1, second half. `doctor` and `repair` exist to tell a user which
35
+ // file their install is missing. Until now, on exactly that install, they did not
36
+ // run: install.mjs's ~13 static imports resolve BEFORE its first line executes, so
37
+ // one absent module killed the command with a bare ERR_MODULE_NOT_FOUND and zero
38
+ // bytes of stdout. A half-finished update, a trimmed tarball (this repo has
39
+ // shipped three) or a hand-deleted file all land there — CLAUDE.md's "a recovery
40
+ // path must not import the thing it recovers", on the startup edge.
41
+ //
42
+ // A static import cannot be caught inside the module that declares it, so the
43
+ // catch lives one entry up. THIS file is the host because it is the published
44
+ // `bin` and because its own static closure is one file — itself; every route
45
+ // below is an `await import()`. Whatever this prints must therefore rely on
46
+ // nothing but the language, the same charter scripts/hook-launcher.mjs follows:
47
+ // no local import may appear here, or the fallback shares the fate it reports on.
48
+ //
49
+ // The remedy is deliberately NOT `install.mjs repair` — that is the file that
50
+ // would not load. It has to come from outside the broken tree.
51
+ // THREE shapes, not one. The first cut caught only ERR_MODULE_NOT_FOUND, and pre-ship
52
+ // review pointed out that an interrupted write leaves a file PRESENT and truncated far
53
+ // more often than it leaves it absent: that arrives as a SyntaxError, and a truncated
54
+ // install.mjs arrives as neither — the module loads and simply has no `main`, which
55
+ // died at the call site with "main is not a function". All three are the same fact
56
+ // about the world (this install's files are not intact) and get the same answer.
57
+ // Anything else is rethrown: this is a classifier, not a swallow.
58
+ function explainBrokenInstall(what) {
59
+ const w = (s) => process.stderr.write(`[claude-mem-lite] ${s}\n`);
60
+ w(`This install is incomplete — ${what}`);
61
+ w('That is why this command cannot run: these files load before any of their code executes.');
62
+ w('Repair: npm install -g claude-mem-lite@latest --force');
63
+ w('Or, in Claude Code: /plugin uninstall claude-mem-lite && /plugin install claude-mem-lite@sdsrss');
64
+ process.exit(1);
65
+ }
66
+
67
+ /**
68
+ * Absolute path out of an error that carries one, or null.
69
+ *
70
+ * ERR_MODULE_NOT_FOUND carries `url`. An ESM SyntaxError carries NOTHING —
71
+ * measured on Node 26: `url` and `code` both undefined, message a bare
72
+ * "Unexpected end of input", every stack frame a node-internal loader. So the
73
+ * caller must be able to say its piece without a filename rather than printing
74
+ * "undefined", and naming the damaged file would take a `node --check` scan of
75
+ * the install, which is a bigger thing than this line.
76
+ */
77
+ function fileFromError(e) {
78
+ const m = String(e?.url || e?.stack || e?.message || '').match(/file:\/\/(\/[^\s:)'"]+)/);
79
+ if (m) return m[1];
80
+ const quoted = String(e?.message || '').match(/'([^']+\.mjs)'/);
81
+ return quoted ? quoted[1] : null;
82
+ }
83
+
84
+ async function loadInstaller() {
85
+ let mod;
86
+ try {
87
+ mod = await import('./install.mjs');
88
+ } catch (e) {
89
+ if (e?.code === 'ERR_MODULE_NOT_FOUND') {
90
+ explainBrokenInstall(`it is missing: ${fileFromError(e) || 'a module'}`);
91
+ }
92
+ // Never reprint the parser's own output: the stack is what this exists to replace.
93
+ if (e instanceof SyntaxError) {
94
+ const at = fileFromError(e);
95
+ explainBrokenInstall(`this file is damaged or truncated: ${at || 'one of its modules'}`);
96
+ }
97
+ throw e;
98
+ }
99
+ if (typeof mod?.main !== 'function') {
100
+ explainBrokenInstall('install.mjs loaded but exports no `main` — it is truncated');
101
+ }
102
+ return mod;
103
+ }
33
104
  const INSTALL_COMMANDS = new Set([
34
105
  'install',
35
106
  'uninstall',
@@ -90,11 +161,11 @@ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
90
161
  const { run } = await import('./mem-cli.mjs');
91
162
  await run(['help']);
92
163
  } else {
93
- const { main } = await import('./install.mjs');
164
+ const { main } = await loadInstaller();
94
165
  await main([]);
95
166
  }
96
167
  } else if (INSTALL_COMMANDS.has(cmd)) {
97
- const { main } = await import('./install.mjs');
168
+ const { main } = await loadInstaller();
98
169
  await main(process.argv.slice(2));
99
170
  } else if (REMOVED_COMMANDS.has(cmd)) {
100
171
  // Released-artifact discoverability signal for the skill-registry removal. Deliberately
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) {