claude-mem-lite 6.12.2 → 6.13.2

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.12.2",
12
+ "version": "6.13.2",
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.12.2",
3
+ "version": "6.13.2",
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
@@ -237,6 +237,21 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
237
237
  repos/ # Shallow-cloned source repos
238
238
  ```
239
239
 
240
+ ## Upgrading to 6.13.0
241
+
242
+ **One default changes: SessionStart no longer injects `### Key Events`.** That section listed
243
+ the five newest importance ≥ 2 rows of the `events` table — activity the background summarizer
244
+ records — at the top of every session, chosen by recency rather than by what you were doing. A
245
+ check of 30 of them against git history and transcripts found 2 accurate and 16 wrong. Events are still stored, searchable with `mem_search`, and
246
+ injected when your prompt or the file being edited matches them. To restore the section, set
247
+ `CLAUDE_MEM_SESSION_EVENTS=1`. No schema change and no migration: an older build still opens
248
+ the database, so reverting is pinning `claude-mem-lite@6.12.2`.
249
+
250
+ **Citation readings drop at this version, and that is a measurement change.** A lesson the
251
+ agent answers with `#NN n/a` is no longer counted as cited, so `citation-stats` per-face rates
252
+ read lower from here on (`--sidechain` excepted: it counts an `n/a` as an answer). Do not compare
253
+ a reading taken before 6.13.0 with one taken after.
254
+
240
255
  ## Upgrading to 6.11.0
241
256
 
242
257
  **One default changes: re-enrich stops leaving part of its budget idle.** It reserves half of
@@ -900,6 +915,7 @@ claude-mem-lite.
900
915
  | `OPENROUTER_MODEL` | Overrides the OpenRouter model slug for **all** background calls (e.g. `openai/gpt-4o-mini`, `qwen/qwen-2.5-72b-instruct`). When unset, the `CLAUDE_MEM_MODEL` tier maps to `anthropic/claude-haiku-4.5` (haiku) or `anthropic/claude-sonnet-4.5` (sonnet). | _(tier default)_ |
901
916
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
902
917
  | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
918
+ | `CLAUDE_MEM_SESSION_EVENTS` | `1`/`on` restores the SessionStart `### Key Events` section (recent high-importance rows from the `events` table). **Off by default since v6.13.0**: an audit of 30 events read 2 accurate and 16 wrong. The UserPromptSubmit events block and PreToolUse recall are query-matched and stay on; `mem_search` still reaches every event. | _(off)_ |
903
919
  | `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the per-SessionStart auto-write of the `CLAUDE.md` managed block across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
904
920
  | `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. Since v2.82.1 auto-adopt runs on every SessionStart for any install path, so this hint typically surfaces only when you've explicitly opted out (`MEM_NO_AUTO_ADOPT=1` or `claude-mem-lite adopt --disable`). | _(disabled)_ |
905
921
 
package/README.zh-CN.md CHANGED
@@ -199,6 +199,17 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.13.0
203
+
204
+ **只有一个默认行为变化:SessionStart 不再注入 `### Key Events`。** 这一节在每个会话开头列出
205
+ `events` 表里最新的 5 条 importance ≥ 2 的记录(后台摘要器记下的活动),按时间挑选,与你当前在做
206
+ 什么无关。对照 git 历史和会话记录核验其中 30 条:2 条属实、16 条错误。event 仍然会存储、可以用 `mem_search` 检索,当你的提问或正在编辑的文件与之匹配时
207
+ 仍会注入。想恢复这一节,设置 `CLAUDE_MEM_SESSION_EVENTS=1`。没有 schema 变更、没有迁移:旧版本
208
+ 仍能打开数据库,回退就是固定到 `claude-mem-lite@6.12.2`。
209
+
210
+ **引用率读数会从这个版本起下降,这是口径变化。** agent 用 `#NN n/a` 回应的 lesson 不再计为引用,
211
+ 所以 `citation-stats` 各注入面的引用率从此偏低(`--sidechain` 除外:它把 `n/a` 算作已回应)。不要拿 6.13.0 之前和之后的读数直接比较。
212
+
202
213
  ## 升级到 6.11.0
203
214
 
204
215
  **只有一个默认行为变化:re-enrich 不再让一部分预算空着。** 它为两个回填任务预留每次运行一半的
@@ -690,6 +701,7 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
690
701
  | `OPENROUTER_MODEL` | 覆盖**所有**后台调用的 OpenRouter 模型 slug(如 `openai/gpt-4o-mini`、`qwen/qwen-2.5-72b-instruct`)。未设时按 `CLAUDE_MEM_MODEL` 分层映射到 `anthropic/claude-haiku-4.5`(haiku)或 `anthropic/claude-sonnet-4.5`(sonnet)。 | _(分层默认)_ |
691
702
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
692
703
  | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
704
+ | `CLAUDE_MEM_SESSION_EVENTS` | 设为 `1`/`on` 时恢复 SessionStart 的 `### Key Events` 一节(`events` 表中最近的高重要度条目)。**v6.13.0 起默认关闭**:对 30 条 event 的核验只有 2 条属实、16 条错误。UserPromptSubmit 的 events 块与 PreToolUse 召回按查询匹配,保持开启;`mem_search` 仍可检索全部 event。 | _(关闭)_ |
693
705
  | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止每次 SessionStart 在**所有**项目自动写入 `CLAUDE.md` 托管块。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
694
706
  | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径每次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
695
707
 
package/adopt-content.mjs CHANGED
@@ -89,7 +89,9 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
89
89
  - Read→Edit 同文件共享 cooldown(不重复注入正文),但 Read 注入后的首个 Edit 会把 lesson **ID**
90
90
  以一行 ack 指令重新浮出。看到 \`#NN [bugfix] …\` 这类行时:**下次产出用户可见文字时引用 \`#NN\`**
91
91
  (\`'#NN applied'\` 或 \`'#NN n/a — <理由>'\`)。纯工具回合不算;把 ID 记在工作记忆里,写回时引用。
92
- - 系统按会话追踪引用:未引用的 lesson 连续 3 个会话后 importance −1(地板 0),被引用的 +1(封顶 3)。
92
+ - 系统按会话追踪引用:被引用的 lesson 在召回排序里上浮,被注入却未引用的下沉(有界的排序乘数);
93
+ 反复注入却从未被引用的,后台维护会把它的 importance 降到 2(无 lesson 的降到 1)。
94
+ \`'#NN n/a'\` 算作已回应,但不算采纳:排序上与未引用相同,同样下沉。
93
95
  引用是给系统的反馈,不是合规仪式——注入池据此自调。
94
96
 
95
97
  ## 何时主动调用 MCP 工具
package/bash-utils.mjs CHANGED
@@ -28,6 +28,24 @@ const SEARCH_VERBS = new Set([
28
28
  'file',
29
29
  'which',
30
30
  'type',
31
+ // Print-a-file-or-listing verbs agents use to READ source (`sed -n 40,80p f`) — their
32
+ // output is file content, so an `Error:` in it is quoted text, not a failure.
33
+ 'sed',
34
+ 'awk',
35
+ 'ls',
36
+ 'jq',
37
+ 'nl',
38
+ 'stat',
39
+ 'diff',
40
+ 'code-graph-mcp',
41
+ // Pure filters, so `grep x f | sort | uniq -c` stays a read.
42
+ 'sort',
43
+ 'uniq',
44
+ 'cut',
45
+ 'tr',
46
+ 'column',
47
+ 'paste',
48
+ 'strings',
31
49
  ]);
32
50
  // Command prefixes that wrap the real command (env-assignments handled separately).
33
51
  const CMD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'command', 'nice', 'nohup', 'stdbuf', 'xargs']);
@@ -44,6 +62,7 @@ const GIT_READ_SUBCMDS = new Set([
44
62
  'shortlog',
45
63
  'reflog',
46
64
  'status',
65
+ 'rev-parse',
47
66
  ]);
48
67
 
49
68
  // Hard failure fingerprints — a real crash / thrown exception / non-zero-exit marker,
@@ -57,20 +76,450 @@ const GIT_READ_SUBCMDS = new Set([
57
76
  const HARD_ERROR_RE =
58
77
  /\bERR!|\bpanic\b|traceback|segfault|core dumped|\benoent\b|command not found|assertion\s?error|\n\s+at\s+\S|(?:type|reference|range|syntax|eval|uri)error:/i;
59
78
 
60
- // True when the command's PRIMARY operation (left of the first pipe, past any
61
- // env-assignments / wrapper like `sudo`/`env`/`time`) is a read/search — including
62
- // `git grep`/`git log`. Anchoring on the primary command (not "search verb appears
63
- // anywhere") is what lets `npm run build 2>&1 | tail` stay an error while `sudo grep`,
64
- // `git grep`, `cat f | head` are correctly exempt.
65
- function isReadOnlyCommand(cmd) {
66
- const primary = cmd.split('|')[0];
67
- const toks = primary.trim().split(/\s+/).filter(Boolean);
79
+ // Commands that only set the shell up for the next one. They neither make a command
80
+ // read-only nor stop it being one: `cd repo && grep …` is a grep. This matters because
81
+ // the host resets the cwd between Bash calls, so agents prefix `cd <repo> &&` to over
82
+ // half of their commands (5009 of 9096 in this repo's transcripts, 2026-09-25) — and
83
+ // while `cd` counted as the primary verb, every such grep/sed of source that mentions
84
+ // `TypeError:` fired error-recall on a command that had not failed.
85
+ const NEUTRAL_VERBS = new Set([
86
+ 'cd',
87
+ 'pushd',
88
+ 'popd',
89
+ 'echo',
90
+ 'printf',
91
+ 'true',
92
+ ':',
93
+ 'export',
94
+ 'set',
95
+ 'exit',
96
+ // Print a path or a date; used inside `$(…)` to build a read's arguments.
97
+ 'pwd',
98
+ 'date',
99
+ 'basename',
100
+ 'dirname',
101
+ 'realpath',
102
+ 'readlink',
103
+ ]);
104
+
105
+ // code-graph-mcp subcommands that only query the index (plus `snapshot inspect`, handled in
106
+ // WRITES_OR_RUNS). The rest (serve, the index rebuilds, doctor's repairs, benchmark,
107
+ // adopt / unadopt / uninstall, snapshot create) run work whose failures error-recall should see.
108
+ const CODE_GRAPH_READ_SUBCMDS = new Set([
109
+ 'grep',
110
+ 'search',
111
+ 'ast-search',
112
+ 'callgraph',
113
+ 'impact',
114
+ 'affected',
115
+ 'show',
116
+ 'map',
117
+ 'tour',
118
+ 'overview',
119
+ 'deps',
120
+ 'trace',
121
+ 'similar',
122
+ 'refs',
123
+ 'dead-code',
124
+ 'centrality',
125
+ 'cycles',
126
+ 'surprising',
127
+ 'report',
128
+ 'health-check',
129
+ 'stats',
130
+ 'outcome',
131
+ 'help',
132
+ '--help',
133
+ '--version',
134
+ ]);
135
+
136
+ // Forms of a SEARCH_VERBS verb that write a file or run a program, so their output is not
137
+ // file content (v6.13.0 defect review P3-5). Args are the whitespace tokens after the verb;
138
+ // quotes are not stripped, which only matters for a flag written inside quotes.
139
+ const WRITES_OR_RUNS = {
140
+ sed: (args) => args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith('--in-place')),
141
+ sort: (args) => args.some((a) => /^-[A-Za-z]*o/.test(a) || a.startsWith('--output')),
142
+ // `-f` reads the program from a file (or a heredoc on stdin) this check cannot see.
143
+ awk: (args, text) =>
144
+ args.some((a) => /^-f/.test(a) || a.startsWith('--file')) ||
145
+ /\bsystem\s*\(|\|\s*(?:getline\b|")|\|&/.test(text),
146
+ find: (args) => args.some((a) => /^-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/.test(a)),
147
+ 'code-graph-mcp': (args) =>
148
+ !CODE_GRAPH_READ_SUBCMDS.has(args[0]) && !(args[0] === 'snapshot' && args[1] === 'inspect'),
149
+ };
150
+
151
+ /** 'read' | 'neutral' | 'other' for one simple command (one element of a pipeline). */
152
+ function classifySimpleCommand(text) {
153
+ const toks = shellWords(text);
68
154
  let i = 0;
69
155
  while (i < toks.length && (/^\w+=/.test(toks[i]) || CMD_WRAPPERS.has(toks[i]))) i++;
70
156
  const first = toks[i];
71
- if (!first) return false;
72
- if (SEARCH_VERBS.has(first)) return true;
73
- return first === 'git' && GIT_READ_SUBCMDS.has(toks[i + 1]);
157
+ if (!first || NEUTRAL_VERBS.has(first)) return 'neutral';
158
+ if (SEARCH_VERBS.has(first)) return WRITES_OR_RUNS[first]?.(toks.slice(i + 1), text) ? 'other' : 'read';
159
+ return first === 'git' && GIT_READ_SUBCMDS.has(toks[i + 1]) ? 'read' : 'other';
160
+ }
161
+
162
+ /**
163
+ * Split one simple command into words on whitespace OUTSIDE quotes, so `x="a b" grep …`
164
+ * stays an assignment followed by grep (v6.13.2 delta review FALSE-2). Quotes are kept in
165
+ * the words; only the split point is quote-aware.
166
+ */
167
+ function shellWords(text) {
168
+ const words = [];
169
+ let cur = '';
170
+ let inWord = false;
171
+ let quote = null;
172
+ for (let k = 0; k < text.length; k++) {
173
+ const ch = text[k];
174
+ if (quote) {
175
+ if (ch === '\\' && quote !== "'") {
176
+ cur += ch + (text[k + 1] ?? '');
177
+ k++;
178
+ continue;
179
+ }
180
+ if (ch === quote[quote.length - 1]) quote = null;
181
+ cur += ch;
182
+ continue;
183
+ }
184
+ if (/\s/.test(ch)) {
185
+ if (inWord) words.push(cur);
186
+ cur = '';
187
+ inWord = false;
188
+ continue;
189
+ }
190
+ inWord = true;
191
+ if (ch === '\\') {
192
+ cur += ch + (text[k + 1] ?? '');
193
+ k++;
194
+ continue;
195
+ }
196
+ if (ch === '$' && text[k + 1] === "'") {
197
+ quote = ANSI_QUOTE;
198
+ cur += "$'";
199
+ k++;
200
+ continue;
201
+ }
202
+ if (ch === "'" || ch === '"') quote = ch;
203
+ cur += ch;
204
+ }
205
+ if (inWord) words.push(cur);
206
+ return words;
207
+ }
208
+
209
+ // `$'…'` (ANSI-C quoting): a backslash escapes the next character, including `'`.
210
+ const ANSI_QUOTE = "$'";
211
+ // Stands in for a cut-out substitution. No surrounding spaces: `f=$(…)` must stay one
212
+ // assignment word, or the placeholder becomes the verb.
213
+ const SUBST = '__SUBST__';
214
+ const MAX_SUBST_DEPTH = 32;
215
+
216
+ /**
217
+ * Remove what the shell does not run as a command: heredoc bodies (they are stdin) and
218
+ * `#` comments. Quote-aware. An apostrophe in either used to unbalance the quotes and send
219
+ * the whole line to a first-word fallback (v6.13.0 defect review P3-4).
220
+ *
221
+ * An UNQUOTED delimiter (`<<EOF`) is the exception: bash expands `$(…)` and backticks in
222
+ * that body, so each body line is kept as `: "<line>"`, a no-op whose substitutions
223
+ * splitStatements still cuts out and judges. `$((…))` and `((…))` are copied whole so a
224
+ * `<<` shift inside them is not read as a heredoc (v6.13.2 pre-ship defect review).
225
+ */
226
+ function stripNonCommands(cmd) {
227
+ let out = '';
228
+ let quote = null;
229
+ // Set once a `((` never closes: every later attempt would rescan to the end of the input,
230
+ // which made unclosed parentheses quadratic (v6.13.2 delta review P3-1).
231
+ let arithUnclosed = false;
232
+ const pending = []; // heredoc delimiters opened on the current line: { word, dash, expand }
233
+ for (let k = 0; k < cmd.length; k++) {
234
+ const ch = cmd[k];
235
+ if (quote === ANSI_QUOTE) {
236
+ if (ch === '\\') {
237
+ out += ch + (cmd[k + 1] ?? '');
238
+ k++;
239
+ continue;
240
+ }
241
+ if (ch === "'") quote = null;
242
+ out += ch;
243
+ continue;
244
+ }
245
+ if (quote) {
246
+ // Keep the backslash AND the character it escapes (the old `out += cmd[k++]; out += ch`
247
+ // wrote the backslash twice and dropped the escaped character).
248
+ if (ch === '\\' && quote === '"') {
249
+ out += ch + (cmd[k + 1] ?? '');
250
+ k++;
251
+ continue;
252
+ }
253
+ if (ch === quote) quote = null;
254
+ out += ch;
255
+ continue;
256
+ }
257
+ if (ch === '\\') {
258
+ out += ch + (cmd[k + 1] ?? '');
259
+ k++;
260
+ continue;
261
+ }
262
+ if (ch === '$' && cmd[k + 1] === "'") {
263
+ quote = ANSI_QUOTE;
264
+ out += "$'";
265
+ k++;
266
+ continue;
267
+ }
268
+ if (ch === "'" || ch === '"') {
269
+ quote = ch;
270
+ out += ch;
271
+ continue;
272
+ }
273
+ const opensArith =
274
+ (ch === '$' && cmd[k + 1] === '(' && cmd[k + 2] === '(') ||
275
+ (ch === '(' && cmd[k + 1] === '(' && cmd[k - 1] !== '$');
276
+ if (opensArith && !arithUnclosed) {
277
+ const end = arithmeticEnd(cmd, ch === '$' ? k + 1 : k);
278
+ if (end >= 0) {
279
+ out += cmd.slice(k, end);
280
+ k = end - 1;
281
+ continue;
282
+ }
283
+ if (end === ARITH_UNCLOSED) arithUnclosed = true;
284
+ }
285
+ if (ch === '#' && (k === 0 || /[\s;&|()]/.test(cmd[k - 1]))) {
286
+ while (k + 1 < cmd.length && cmd[k + 1] !== '\n') k++;
287
+ continue;
288
+ }
289
+ if (ch === '<' && cmd[k + 1] === '<' && cmd[k + 2] !== '<' && cmd[k - 1] !== '<') {
290
+ const m = /^<<(-?)[ \t]*((?:'[^'\n]*'|"[^"\n]*"|\\.|[^\s;&|<>()'"\\])+)/.exec(cmd.slice(k));
291
+ if (m) {
292
+ pending.push({
293
+ word: m[2].replace(/\\(.)/g, '$1').replace(/['"]/g, ''),
294
+ dash: m[1] === '-',
295
+ expand: !/['"\\]/.test(m[2]),
296
+ });
297
+ out += m[0];
298
+ k += m[0].length - 1;
299
+ continue;
300
+ }
301
+ }
302
+ if (ch === '\n' && pending.length > 0) {
303
+ out += ch;
304
+ // Consume each body in order, up to and including its delimiter line.
305
+ let pos = k + 1;
306
+ for (const { word, dash, expand } of pending.splice(0)) {
307
+ while (pos < cmd.length) {
308
+ let end = cmd.indexOf('\n', pos);
309
+ if (end === -1) end = cmd.length;
310
+ const line = cmd.slice(pos, end);
311
+ pos = end + 1;
312
+ if ((dash ? line.replace(/^\t+/, '') : line) === word) break;
313
+ if (expand) {
314
+ const esc = line.replace(/\\(?=")/g, '\\\\').replace(/"/g, '\\"');
315
+ // A trailing backslash would escape the wrapper's closing quote.
316
+ const odd = /\\*$/.exec(esc)[0].length % 2 === 1;
317
+ out += `: "${esc}${odd ? '\\' : ''}"\n`;
318
+ }
319
+ }
320
+ }
321
+ k = pos - 1;
322
+ continue;
323
+ }
324
+ out += ch;
325
+ }
326
+ return out;
327
+ }
328
+
329
+ const ARITH_UNCLOSED = -2;
330
+
331
+ /**
332
+ * For `((` whose first `(` is at `open` (after the `$` of `$((`, or a bare `((`): the index
333
+ * just past the closing `))` when bash reads it as arithmetic, -1 when it is a command form
334
+ * instead, ARITH_UNCLOSED when the inner `(` never closes. bash tries arithmetic first and
335
+ * falls back when the inner `(` does not close on `))`, so `$((npm test) | head)` is a
336
+ * command substitution holding a subshell (v6.13.2 delta review P3-3).
337
+ */
338
+ function arithmeticEnd(cmd, open) {
339
+ const inner = closingParen(cmd, open + 2);
340
+ if (inner === -1) return ARITH_UNCLOSED;
341
+ return cmd[inner] === ')' ? inner + 1 : -1;
342
+ }
343
+
344
+ /**
345
+ * Index just past the `)` that closes a substitution whose body starts at `start`, or -1
346
+ * when it never closes. Quote-aware; nested parentheses count.
347
+ */
348
+ function closingParen(cmd, start) {
349
+ let depth = 1;
350
+ let quote = null;
351
+ for (let k = start; k < cmd.length; k++) {
352
+ const ch = cmd[k];
353
+ if (quote) {
354
+ if (ch === '\\' && quote !== "'") k++;
355
+ else if (ch === quote[quote.length - 1]) quote = null;
356
+ continue;
357
+ }
358
+ if (ch === '\\') k++;
359
+ else if (ch === '$' && cmd[k + 1] === "'") {
360
+ quote = ANSI_QUOTE;
361
+ k++;
362
+ } else if (ch === "'" || ch === '"') quote = ch;
363
+ else if (ch === '(') depth++;
364
+ else if (ch === ')' && --depth === 0) return k + 1;
365
+ }
366
+ return -1;
367
+ }
368
+
369
+ /**
370
+ * Split a command line into statements (on `;`, newline, `&&`, `||`, `&`), each a list
371
+ * of its pipeline elements (on `|` and `|&`). Quote-aware, so the `;` in
372
+ * `grep -E "a;b"` separates nothing, and the `&` of a redirection (`2>&1`, `&>f`) is not
373
+ * a statement break. A backslash-newline continues the line. The bodies of `$(…)`,
374
+ * backticks, `<(…)` and `>(…)` — outside quotes or inside double quotes, where they run
375
+ * all the same — are cut out into `subs` for the caller to judge on their own. Returns
376
+ * null when the quotes or a substitution do not close.
377
+ */
378
+ function splitStatements(cmd) {
379
+ const statements = [];
380
+ const subs = [];
381
+ let pipeline = [];
382
+ let cur = '';
383
+ let quote = null;
384
+ // Cut a substitution body out at `k` (the index of `$`, `<`, `>` or the backtick);
385
+ // returns the index of its last character, or -1 when it never closes.
386
+ const takeSub = (k) => {
387
+ if (cmd[k] === '`') {
388
+ const end = cmd.indexOf('`', k + 1);
389
+ if (end === -1) return -1;
390
+ subs.push(cmd.slice(k + 1, end));
391
+ cur += SUBST;
392
+ return end;
393
+ }
394
+ if (cmd[k] === '$' && cmd[k + 2] === '(') {
395
+ const arithEnd = arithmeticEnd(cmd, k + 1);
396
+ if (arithEnd >= 0) {
397
+ // An arithmetic body is not a command, but a substitution inside it still runs.
398
+ const body = cmd.slice(k + 3, arithEnd - 2);
399
+ if (/\$\(|`/.test(body)) subs.push(`: ${body}`);
400
+ cur += SUBST;
401
+ return arithEnd - 1;
402
+ }
403
+ }
404
+ const end = closingParen(cmd, k + 2);
405
+ if (end === -1) return -1;
406
+ subs.push(cmd.slice(k + 2, end - 1));
407
+ cur += SUBST;
408
+ return end - 1;
409
+ };
410
+ const endElement = () => {
411
+ pipeline.push(cur);
412
+ cur = '';
413
+ };
414
+ const endStatement = () => {
415
+ endElement();
416
+ statements.push(pipeline);
417
+ pipeline = [];
418
+ };
419
+ for (let k = 0; k < cmd.length; k++) {
420
+ const ch = cmd[k];
421
+ const opensSub = ch === '`' || ((ch === '$' || ch === '<' || ch === '>') && cmd[k + 1] === '(');
422
+ if (quote === ANSI_QUOTE) {
423
+ if (ch === '\\') {
424
+ cur += ch + (cmd[k + 1] ?? '');
425
+ k++;
426
+ continue;
427
+ }
428
+ if (ch === "'") quote = null;
429
+ cur += ch;
430
+ continue;
431
+ }
432
+ if (quote) {
433
+ if (quote === '"' && (ch === '`' || (ch === '$' && cmd[k + 1] === '('))) {
434
+ k = takeSub(k);
435
+ if (k === -1) return null;
436
+ continue;
437
+ }
438
+ if (ch === '\\' && quote === '"') {
439
+ cur += ch + (cmd[k + 1] ?? '');
440
+ k++;
441
+ continue;
442
+ }
443
+ if (ch === quote) quote = null;
444
+ cur += ch;
445
+ continue;
446
+ }
447
+ if (ch === '\\') {
448
+ if (cmd[k + 1] !== '\n') cur += ch + (cmd[k + 1] ?? '');
449
+ k++;
450
+ continue;
451
+ }
452
+ if (ch === '$' && cmd[k + 1] === "'") {
453
+ quote = ANSI_QUOTE;
454
+ cur += "$'";
455
+ k++;
456
+ continue;
457
+ }
458
+ if (ch === "'" || ch === '"') {
459
+ quote = ch;
460
+ cur += ch;
461
+ continue;
462
+ }
463
+ if (opensSub) {
464
+ k = takeSub(k);
465
+ if (k === -1) return null;
466
+ continue;
467
+ }
468
+ const next = cmd[k + 1];
469
+ if (ch === '|' && next !== '|') {
470
+ endElement();
471
+ if (next === '&') k++;
472
+ continue;
473
+ }
474
+ const isRedirectAmp = ch === '&' && (cmd[k - 1] === '>' || cmd[k - 1] === '<' || next === '>');
475
+ if (ch === ';' || ch === '\n' || (ch === '&' && !isRedirectAmp) || (ch === '|' && next === '|')) {
476
+ endStatement();
477
+ if ((ch === '&' && next === '&') || ch === '|') k++;
478
+ continue;
479
+ }
480
+ cur += ch;
481
+ }
482
+ if (quote) return null;
483
+ endStatement();
484
+ return { statements, subs };
485
+ }
486
+
487
+ // True when the command only READS: every element of every pipeline is a read/search
488
+ // (including `git grep`/`git log`) or a neutral set-up like `cd`/`echo`, and at least one
489
+ // is a read. Anchoring on the verbs actually executed (not "search verb appears
490
+ // anywhere") is what lets `npm run build 2>&1 | tail` stay an error while `sudo grep`,
491
+ // `git grep`, `cat f | head` and `cd repo && sed -n 1,9p f` are exempt. Every statement
492
+ // and every pipe consumer is checked, so `grep x f; npm test | tail` and
493
+ // `printf '…' | node server.mjs` are not exempted on the strength of their first word.
494
+ // Substitution bodies are judged the same way, so `grep x $(npm test)` runs a program.
495
+ // A line that still does not parse once heredoc bodies and comments are gone is 'other':
496
+ // the old fallback judged it by its first word and silenced exactly the heredoc-then-run
497
+ // shape (v6.13.0 defect review P3-4).
498
+ function isReadOnlyCommand(cmd) {
499
+ return commandKind(stripNonCommands(cmd)) === 'read';
500
+ }
501
+
502
+ /** 'read' | 'neutral' | 'other' for a whole command line (see isReadOnlyCommand). */
503
+ function commandKind(cmd, depth = 0) {
504
+ // Each level rescans its body; past this depth the line is judged 'other' rather than
505
+ // recursing into a RangeError on the hot path of every Bash event.
506
+ if (depth > MAX_SUBST_DEPTH) return 'other';
507
+ const parsed = splitStatements(cmd);
508
+ if (!parsed) return 'other';
509
+ let sawRead = false;
510
+ for (const body of parsed.subs) {
511
+ const kind = commandKind(body, depth + 1);
512
+ if (kind === 'other') return 'other';
513
+ if (kind === 'read') sawRead = true;
514
+ }
515
+ for (const pipeline of parsed.statements) {
516
+ for (const element of pipeline) {
517
+ const kind = classifySimpleCommand(element);
518
+ if (kind === 'other') return 'other';
519
+ if (kind === 'read') sawRead = true;
520
+ }
521
+ }
522
+ return sawRead ? 'read' : 'neutral';
74
523
  }
75
524
 
76
525
  // Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
package/hook-context.mjs CHANGED
@@ -32,7 +32,11 @@ import {
32
32
  UNCONSUMED_HANDOFF_SQL,
33
33
  } from './hook-shared.mjs';
34
34
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
35
- import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
35
+ import {
36
+ recentInjectableEvents,
37
+ renderInjectableEvent,
38
+ sessionStartEventsEnabled,
39
+ } from './lib/events-injection.mjs';
36
40
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
37
41
  // The canonical one (v3.84.0): this file carried a byte-identical private copy, which is
38
42
  // the same one-home rule this release enforced for the cooldown path and the dashboard.
@@ -738,12 +742,15 @@ export function buildSessionContextLines(
738
742
  // canonical store for promoted bugfix/decision/lesson memories that
739
743
  // persistHaikuSummary upgrade-deletes out of observations. Without this section
740
744
  // SessionStart never shows them. E# prefix keeps citation extractors (bare-`#`
741
- // anchored) from reading an event id as an observation id. Gated on isQuietHooks()
742
- // ONLY (explicit low-noise opt-out), NOT effectiveQuiet: unlike Key Context, events
745
+ // anchored) from reading an event id as an observation id. Of the quiet switches it
746
+ // honours isQuietHooks() (explicit low-noise opt-out), NOT effectiveQuiet: unlike Key Context, events
743
747
  // never appear in the obs-only Recent table and are absent from the MEMORY.md
744
748
  // sentinel, so an adopted project (the default) would otherwise have zero
745
749
  // SessionStart surface for them. Never throws (recentInjectableEvents catches).
746
- if (!isQuietHooks()) {
750
+ //
751
+ // Since v6.13.0 the section is also opt-in (sessionStartEventsEnabled): audited at
752
+ // 2/30 accurate. The rationale lives on the predicate.
753
+ if (!isQuietHooks() && sessionStartEventsEnabled()) {
747
754
  const keyEvents = recentInjectableEvents(db, { project, limit: 5 });
748
755
  if (keyEvents.length > 0) {
749
756
  summaryLines.push('### Key Events');
package/hook-handoff.mjs CHANGED
@@ -165,7 +165,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
165
165
  WHERE project = ? AND ${liveObsFilterSql('')}
166
166
  AND COALESCE(importance, 1) >= 3
167
167
  AND ${notLowSignalTitleClause('')}
168
- ORDER BY created_at_epoch DESC LIMIT 1
168
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 1
169
169
  `,
170
170
  )
171
171
  .get(project);
@@ -248,7 +248,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
248
248
  `
249
249
  SELECT title, type, narrative FROM observations
250
250
  WHERE (memory_session_id = ? OR project = ?) AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
251
- ORDER BY created_at_epoch DESC LIMIT 15
251
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 15
252
252
  `,
253
253
  )
254
254
  .all(sessionId, project, ...obsWindowParams);
@@ -348,21 +348,36 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
348
348
  if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach((f) => fileSet.add(f));
349
349
  // Same namespace widening as `completed` above — see the reasoning there. Measured
350
350
  // 2026-09-21: 8 of the 17 live handoff rows stored key_files as the empty array.
351
- const obsFiles = db
351
+ //
352
+ // The cap counts rows that CONTRIBUTE a new file (D#67, the D#40 shape). This read was
353
+ // `LIMIT 10` → isValidFile, so rows the filter empties ('[]', directories, /tmp paths)
354
+ // could use up the window. A latent defect: the v6.13.2 pre-ship claims review replayed
355
+ // the live DB 2026-09-26 with this read's own WHERE and window, and 0 of 45 stored
356
+ // handoffs and 0 of 310 sessions lost a file (the largest window held 11 rows).
357
+ // Streaming keeps the filter exactly as it was.
358
+ let contributing = 0;
359
+ for (const row of db
352
360
  .prepare(
353
361
  `
354
362
  SELECT files_modified FROM observations
355
363
  WHERE (memory_session_id = ? OR project = ?) AND files_modified IS NOT NULL ${obsWindowClause}
356
- ORDER BY created_at_epoch DESC LIMIT 10
364
+ ORDER BY created_at_epoch DESC, id DESC
357
365
  `,
358
366
  )
359
- .all(sessionId, project, ...obsWindowParams);
360
- for (const row of obsFiles) {
367
+ .iterate(sessionId, project, ...obsWindowParams)) {
368
+ let files;
361
369
  try {
362
- JSON.parse(row.files_modified)
363
- .filter(isValidFile)
364
- .forEach((f) => fileSet.add(f));
365
- } catch {}
370
+ files = JSON.parse(row.files_modified);
371
+ } catch {
372
+ continue;
373
+ }
374
+ const valid = Array.isArray(files) ? files.filter(isValidFile) : [];
375
+ const before = fileSet.size;
376
+ valid.forEach((f) => fileSet.add(f));
377
+ // A row that only repeats a listed file (the common case: one file edited again and
378
+ // again) contributes nothing either (v6.13.2 pre-ship defect review P3-7).
379
+ if (fileSet.size === before) continue;
380
+ if (++contributing === 10) break;
366
381
  }
367
382
 
368
383
  // 5. Key decisions — high importance observations (skip low-signal degraded titles).
package/hook.mjs CHANGED
@@ -1427,7 +1427,13 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1427
1427
  let gate = { gateInjected: null, gateRecalled: null, gateRatio: null };
1428
1428
  try {
1429
1429
  const gateInjectedIds = unionSurfaces(extractInjectedBySurface(transcriptPath, { mainOnly: true }));
1430
- const gateCited = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
1430
+ // The nudge asks whether the agent ANSWERED what the hooks showed it, and
1431
+ // `#NN n/a — <reason>` is a complete answer: counting it as silence would nag
1432
+ // an agent for following the convention to the letter.
1433
+ const gateCited = extractCitationsFromTranscript(transcriptPath, {
1434
+ mainOnly: true,
1435
+ includeDismissed: true,
1436
+ });
1431
1437
  let hit = 0;
1432
1438
  for (const id of gateInjectedIds) if (gateCited.has(id)) hit++;
1433
1439
  gate = {
@@ -101,17 +101,88 @@ export function unanchoredInjectedIdRe() {
101
101
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
102
102
  const CITATION_RE = citationIdRe();
103
103
 
104
+ // ─── Dismissals ──────────────────────────────────────────────────────────────
105
+ // The adoption doc asks the agent to answer every surfaced lesson with `'#NN applied'`
106
+ // or `'#NN n/a — <reason>'`. The second form is the agent saying the lesson did NOT
107
+ // apply, yet `#NN` alone was the whole citation test, so a dismissal promoted the row
108
+ // exactly like an application: cited_count + 1, uncited_streak reset, demoted_at
109
+ // cleared, access_count bumped towards boostAccessed. Measured 2026-09-25T21:40Z over every
110
+ // top-level transcript on the authoring machine with this rule: 328 of ~1,880 `#NN` mentions (1,881–1,884 by method; the corpus grows)
111
+ // in assistant text are dismissals, and the most-"cited" row of this repo (#54, 9x) was
112
+ // among them. This is the discriminator D#179 said no signal supplied — the product's
113
+ // own convention supplies it for the case the agent states.
114
+ //
115
+ // Deliberately NARROW. A missed dismissal leaves the pre-fix behaviour (credited), while
116
+ // a false one withholds credit from a lesson that was used, so the marker must follow
117
+ // the id directly: past the rest of an id list (`#137 / #260 / #54 n/a`, `#1758、E#3520
118
+ // 与本次无关`) and at most one short parenthetical gloss, never "somewhere nearby".
119
+ // The English adjectives must not be followed by a noun: "#12 irrelevant rows are now
120
+ // filtered" describes the change (defect review P3-6). Only a word from ADJ_VERDICT_NEXT may
121
+ // follow them. Over 1,900 `#NN` mentions (citationIdRe) in 218 top-level transcripts across
122
+ // every project (2026-09-26T06:46Z) this changed exactly one verdict, the review's own
123
+ // example quoted back.
124
+ const DISMISSAL_LIST_TAIL = /^(?:\*\*|`|\s|[,,、/]|and\b|和|[A-Z]?#\d{1,7}\b)*/;
125
+ const DISMISSAL_GLOSS = /^\s*(?:([^()\n]{1,80})|\([^()\n]{1,80}\))/;
126
+ const ADJ_VERDICT_NEXT =
127
+ 'here|to|for|in|on|now|this|since|because|as|so|and|but|at|with|anyway|anymore|either|too|though|today';
128
+ const DISMISSAL_MARKER = new RegExp(
129
+ "^[\\s*`::\\-—–(([]*(?:(?:本轮|本次|这次|此次|此处)\\s*)?(?:n\\/a\\b|not applicable|does(?:n't| not) apply|did(?:n't| not) apply|" +
130
+ `(?:irrelevant|not relevant|unrelated)(?![ \\t]+(?!(?:${ADJ_VERDICT_NEXT})\\b)[A-Za-z])|` +
131
+ '不适用|不相关|无关(?!紧要)|与(?:本|这|此)[^。\\n;;]{0,16}无关)',
132
+ 'i',
133
+ );
134
+
135
+ /**
136
+ * True when the `#NN` that ENDS at `end` in `text` is answered as not applying.
137
+ * @param {string} text
138
+ * @param {number} end index just past the matched `#NN`
139
+ * @returns {boolean}
140
+ */
141
+ export function isDismissalAt(text, end) {
142
+ let rest = text.slice(end, end + 240);
143
+ rest = rest.slice(rest.match(DISMISSAL_LIST_TAIL)[0].length);
144
+ if (markerNotRetracted(rest)) return true;
145
+ const gloss = rest.match(DISMISSAL_GLOSS);
146
+ return !!gloss && markerNotRetracted(rest.slice(gloss[0].length));
147
+ }
148
+
149
+ // The marker opens the clause; an application stated later in the SAME clause
150
+ // ("#12 n/a for a.mjs; applied to b.mjs", "#12 does not apply to tests but I applied it
151
+ // to lib") means the lesson WAS used, and a miss here keeps the old crediting, which is
152
+ // the cheap direction (pre-ship defect review P3-6). The clause ends at a newline, a
153
+ // sentence stop, or the next id, so "#1 n/a; #2 applied" still dismisses #1.
154
+ const DISMISSAL_CLAUSE_END = /[\n。]|\.\s|[A-Z]?#\d/;
155
+ // Completed, un-negated application only: a present-tense "applies" is how a dismissal
156
+ // explains itself ("n/a — it applies to the server path, not this file"), and "does not
157
+ // apply" / 不采纳 negate (delta review P3-2).
158
+ const APPLIED_IN_CLAUSE =
159
+ /(?<!\b(?:not|never|n't)\s)\b(?:applied|adopted)\b|已采纳|采纳了|已按|按它修|已应用|用上了/i;
160
+ function markerNotRetracted(s) {
161
+ const m = s.match(DISMISSAL_MARKER);
162
+ if (!m) return false;
163
+ const clause = s.slice(m[0].length).split(DISMISSAL_CLAUSE_END)[0];
164
+ return !APPLIED_IN_CLAUSE.test(clause);
165
+ }
166
+
104
167
  /**
105
168
  * Parse a Claude Code transcript .jsonl and extract unique observation IDs
106
169
  * cited inside assistant text blocks.
107
170
  *
171
+ * An id every one of whose mentions is a dismissal (`#NN n/a — …`, see isDismissalAt)
172
+ * is NOT a citation by default: the callers that credit a row (decay promotion, the
173
+ * access bump, the funnel and per-face cite rates) must not reward "this did not
174
+ * apply". One non-dismissing mention anywhere in the session is enough to cite it.
175
+ * Pass `includeDismissed: true` where the question is COMPLIANCE — "did the agent
176
+ * answer the lesson it was shown" — for which a dismissal is a complete answer.
177
+ *
108
178
  * @param {string} transcriptPath Path to transcript file (.jsonl)
109
179
  * @param {object} [opts] Options
110
180
  * @param {boolean} [opts.mainOnly=false] If true, skip transcript records where isSidechain === true
181
+ * @param {boolean} [opts.includeDismissed=false] If true, count `#NN n/a` mentions too
111
182
  * @returns {Set<number>} unique IDs referenced as `#NN` in assistant text
112
183
  */
113
184
  export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
114
- const { mainOnly = false } = opts;
185
+ const { mainOnly = false, includeDismissed = false } = opts;
115
186
  const ids = new Set();
116
187
  for (const entry of readTranscriptEntries(transcriptPath)) {
117
188
  // Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
@@ -129,7 +200,8 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
129
200
  let m;
130
201
  while ((m = CITATION_RE.exec(block.text))) {
131
202
  const id = Number(m[1]);
132
- if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
203
+ if (!(Number.isInteger(id) && id > 0 && id < 1e7)) continue;
204
+ if (includeDismissed || !isDismissalAt(block.text, m.index + m[0].length)) ids.add(id);
133
205
  }
134
206
  }
135
207
  }
@@ -140,7 +212,8 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
140
212
  * D#179 prerequisite measurement: split each cited id into the responses that
141
213
  * ACTED while naming it and the responses that only TALKED about it.
142
214
  *
143
- * `applyCitationDecay` promotes on any `#NN` in assistant text, so writing a release
215
+ * `applyCitationDecay` promotes on any `#NN` in assistant text that is not a stated
216
+ * dismissal (`#NN n/a`, see isDismissalAt), so writing a release
144
217
  * note, an audit, or a review that discusses a memory promotes that memory — including,
145
218
  * self-referentially, a note observing that the memory is about to be evicted. Nothing
146
219
  * downstream distinguishes "changed the code this lesson describes" from "mentioned the
@@ -199,8 +272,11 @@ export function classifyCitationContext(transcriptPath, opts = {}) {
199
272
  continue;
200
273
  }
201
274
  // Only `text` blocks count as citing. `thinking` is deliberately excluded:
202
- // extractCitationsFromTranscript scores text only, and the whole point is to
203
- // classify the same numerator the decay loop acts on, not a wider one.
275
+ // extractCitationsFromTranscript scores text only. The numerator is still WIDER than
276
+ // the decay loop's by one class: a `#NN n/a` dismissal counts here, and the decay loop
277
+ // (extractCitationsFromTranscript's default) does not. Left as is because
278
+ // benchmark/citation-live-replay.mjs reads this split, and changing it would move
279
+ // that ruler's caliber (v6.13.0 defect review P3-7).
204
280
  if (block.type !== 'text' || typeof block.text !== 'string') continue;
205
281
  CITATION_RE.lastIndex = 0;
206
282
  let m;
@@ -1254,7 +1330,8 @@ export function computeThreadCiteRecall(transcriptPath) {
1254
1330
  // the PROMPT (updatedInput). Fold that in so sidechain recall isn't a false 0. On a
1255
1331
  // main transcript this marker is absent → no-op.
1256
1332
  for (const id of extractInjectedFromSubagentPrompt(transcriptPath)) injected.add(id);
1257
- const cited = extractCitationsFromTranscript(transcriptPath);
1333
+ // A compliance ratio — "did the thread answer what it was handed" — so `#NN n/a` counts.
1334
+ const cited = extractCitationsFromTranscript(transcriptPath, { includeDismissed: true });
1258
1335
  let recalled = 0;
1259
1336
  for (const id of injected) if (cited.has(id)) recalled++;
1260
1337
  return {
@@ -1465,7 +1542,9 @@ export function redirectSupersededIds(db, project, ids) {
1465
1542
  * this lesson" from "wrote about this lesson" (D#179; a release-note session
1466
1543
  * promotes exactly the rows it discusses, and the mention/application split
1467
1544
  * measured on the live corpus is not a bound in either direction), a bounded rank
1468
- * shift is the right cost for a mis-read citation. An eviction is not.
1545
+ * shift is the right cost for a mis-read citation. An eviction is not. (One case now
1546
+ * has a signal: the agent's own `#NN n/a` — see isDismissalAt — is no longer read as
1547
+ * a citation. A mention that merely discusses a lesson still is.)
1469
1548
  *
1470
1549
  * NOT covered by this change, and stated so nobody reads it as "citations can no
1471
1550
  * longer move importance": `bumpCitationAccess` credits `access_count`, and the
@@ -26,6 +26,7 @@ import { citeRecallPathFor } from './cite-recall-path.mjs';
26
26
  // One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
27
27
  // is acyclic.
28
28
  import { citationIdRe } from './citation-tracker.mjs';
29
+ import { lessonIdTokens } from './injected-ids.mjs';
29
30
  import { envNumber } from './env-number.mjs';
30
31
 
31
32
  const MAX_FILES = 2;
@@ -51,7 +52,7 @@ export function buildCiteBackHint(episode, cooldown) {
51
52
  const ids = Array.isArray(entry.lessonIds) ? entry.lessonIds : null;
52
53
  if (!ids || ids.length === 0) continue;
53
54
  seen.add(file);
54
- matches.push({ file, ids });
55
+ matches.push({ file, ids, obsIds: entry.obsIds });
55
56
  if (matches.length >= MAX_FILES) break;
56
57
  }
57
58
  if (matches.length >= MAX_FILES) break;
@@ -70,7 +71,8 @@ export function buildCiteBackHint(episode, cooldown) {
70
71
  ];
71
72
  for (const m of matches) {
72
73
  const fname = neutralizeContextDelimiters(basename(m.file));
73
- const idList = m.ids.map((id) => `#${id}`).join(', ');
74
+ // lessonIds mixes obs and event ids; lessonIdTokens keeps the E# namespace.
75
+ const idList = lessonIdTokens(m.ids, m.obsIds).join(', ');
74
76
  lines.push(` • ${fname} ← ${idList} — /lesson --file ${fname} "<root cause + fix>"`);
75
77
  }
76
78
  return lines.join('\n');
@@ -85,6 +85,31 @@ export function searchInjectableEvents(
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * Whether SessionStart renders `### Key Events`. OFF unless `CLAUDE_MEM_SESSION_EVENTS`
90
+ * is `1`/`on`.
91
+ *
92
+ * The section is recency-selected, not query-conditioned, and nothing checks an event
93
+ * against the work it describes. A 30-row audit of this repo's own events (2026-09-25,
94
+ * docs/audits/20260925-200912-session-history-analysis.md §4.4.1) read 2 ACCURATE /
95
+ * 11 PARTLY / 16 WRONG / 1 GENERIC; the two most frequent failures (6 each) are the
96
+ * summarizer reading mutation-probe vocabulary as product bugs and lessons generalised past
97
+ * their evidence. The section had been injected 50 times (250 rows; the analysis session
98
+ * itself excluded) into this project's
99
+ * main sessions. Lines that are wrong half the time, stated with the authority of
100
+ * "Key Events", are a net cost at the top of every session. (`events.accessed_count` is
101
+ * NOT evidence either way: only `activity show` (getEvent) bumps it, while `mem_get` / `get E#N` go
102
+ * through fetchEventDetail, which does not.)
103
+ *
104
+ * Only this face is paused. The UserPromptSubmit leg (`searchInjectableEvents`) and the
105
+ * PreToolUse rows are matched against the prompt or the file being touched, and
106
+ * mem_search still reaches every event.
107
+ */
108
+ export function sessionStartEventsEnabled(env = process.env) {
109
+ const v = env.CLAUDE_MEM_SESSION_EVENTS;
110
+ return v === '1' || v === 'on';
111
+ }
112
+
88
113
  /**
89
114
  * Recency + importance events (SessionStart context — no FTS query available).
90
115
  * Excludes superseded events. Never throws.
@@ -268,6 +268,39 @@ export function injectedIdKey(id, src = 'obs') {
268
268
  */
269
269
  export const EVENT_ID_PREFIX = 'E#';
270
270
 
271
+ /**
272
+ * Render a pre-tool-recall cooldown entry's ids as citable tokens: `#N` for an
273
+ * observation, `E#N` for an event.
274
+ *
275
+ * `lessonIds` is the mixed list (obs + events, in injection order) and `obsIds` the
276
+ * observation subset (recorded since D#78). Two re-renderers — the Read→Edit ack line
277
+ * and the cite-back hint — printed every lessonId as a bare `#N`, so an injected
278
+ * `E#116` came back as "cite #116", which is a DIFFERENT memory: a bare `#` is the
279
+ * observation namespace (citationIdRe). Same shape as D#202, one layer later.
280
+ *
281
+ * `obsIds` is a multiset match, because one obs and one event may share a number in the
282
+ * same entry. An entry without `obsIds` (written before D#78) cannot be split and keeps
283
+ * the old bare rendering.
284
+ *
285
+ * @param {Array<number|string>} lessonIds
286
+ * @param {Array<number|string>|undefined} obsIds
287
+ * @returns {string[]}
288
+ */
289
+ export function lessonIdTokens(lessonIds, obsIds) {
290
+ if (!Array.isArray(lessonIds)) return [];
291
+ if (!Array.isArray(obsIds)) return lessonIds.map((id) => `#${id}`);
292
+ const obsLeft = new Map();
293
+ for (const id of obsIds) obsLeft.set(String(id), (obsLeft.get(String(id)) || 0) + 1);
294
+ return lessonIds.map((id) => {
295
+ const n = obsLeft.get(String(id)) || 0;
296
+ if (n > 0) {
297
+ obsLeft.set(String(id), n - 1);
298
+ return `#${id}`;
299
+ }
300
+ return `${EVENT_ID_PREFIX}${id}`;
301
+ });
302
+ }
303
+
271
304
  /**
272
305
  * Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
273
306
  * ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
package/mem-cli.mjs CHANGED
@@ -2794,7 +2794,10 @@ function cmdMemdirAudit(args) {
2794
2794
  // main decay loop excludes (it runs mainOnly). aggregateProjectCiteRecall scans THIS
2795
2795
  // project's transcripts: top-level <session>.jsonl = main, and
2796
2796
  // <session>/subagents/agent-*.jsonl = sidechain (descends ONE level into the literal
2797
- // subagents/ dir only, no unbounded recursion). Same methodology, so comparable.
2797
+ // subagents/ dir only, no unbounded recursion). Main and sidechain use the same
2798
+ // methodology here, so they compare with each other. It is a compliance ratio that counts
2799
+ // a `#NN n/a` dismissal as an answer, so it does NOT compare with the per-face rates,
2800
+ // including the `subagent` face, which exclude dismissals since v6.13.0.
2798
2801
  function _reportSidechainCiteRecall({ days, json }) {
2799
2802
  const cutoff = Date.now() - days * 86400 * 1000;
2800
2803
  // memdir = ~/.claude/projects/<encoded>/memory; transcripts are its siblings.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.12.2",
3
+ "version": "6.13.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.12.2",
9
+ "version": "6.13.2",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.12.2",
3
+ "version": "6.13.2",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -58,8 +58,10 @@ export { DB_DIR, DB_PATH, CODE_DIR };
58
58
  // closed_by_obs_id FK with ON DELETE SET NULL (audit trail preserved).
59
59
  // v32 (v2.73.2): citation-decay columns on observations — uncited_streak,
60
60
  // cited_count, last_decided_session_id. Stop hook resolves injected obs as
61
- // cited|uncited; 3 consecutive uncited → importance -1 (floor 0); 1 cited → +1
62
- // (cap 3). last_decided_session_id makes Stop idempotent across multi-fire.
61
+ // cited|uncited; they feed citeFactorClause's bounded rank multiplier. (As shipped,
62
+ // 3 consecutive uncited → importance -1 and 1 cited → +1; D#179/D#198 removed both
63
+ // importance writes — the 3-session rollover now only resets the streak and stamps
64
+ // demoted_at.) last_decided_session_id makes Stop idempotent across multi-fire.
63
65
  // v35 (v2.87.0): no DDL — version bumped only to force one full migration pass on
64
66
  // existing DBs, which runs the one-shot observation_files orphan cleanup (and
65
67
  // re-runs the v28 observation_vectors cleanup) to clear the backlog leaked while
@@ -363,8 +365,9 @@ const MIGRATIONS = [
363
365
  'ALTER TABLE observations ADD COLUMN last_injected_at INTEGER DEFAULT NULL',
364
366
  // v32 (citation-decay): per-obs feedback loop for pre-tool-recall injection
365
367
  // pool. Stop hook resolves each session's injected IDs as cited|uncited.
366
- // 3 consecutive uncited sessions → importance -1 (floor 0). 1 cited session →
367
- // importance +1 (cap 3). last_decided_session_id makes Stop idempotent across
368
+ // cited_count / uncited_streak feed citeFactorClause (a bounded rank multiplier);
369
+ // since D#179/D#198 the loop never writes importance (see applyCitationDecay).
370
+ // last_decided_session_id makes Stop idempotent across
368
371
  // multi-fire scenarios (Claude may fire Stop more than once per session).
369
372
  'ALTER TABLE observations ADD COLUMN uncited_streak INTEGER NOT NULL DEFAULT 0',
370
373
  'ALTER TABLE observations ADD COLUMN cited_count INTEGER NOT NULL DEFAULT 0',
package/scoring-sql.mjs CHANGED
@@ -248,7 +248,7 @@ export function notLowSignalTitleClause(alias = 'o') {
248
248
  // cited≥10, streak=0 → 3.0 (capped — one viral obs can't dominate)
249
249
  // cited=0, streak=2 → 0.5
250
250
  // cited=0, streak=3+ → 0.4 (floored; citation-decay resets streak at 3
251
- // after demoting importance, so steady-state
251
+ // and stamps demoted_at, so steady-state
252
252
  // streak is bounded by [0,2])
253
253
  //
254
254
  // Disjoint from noisePenaltyClause: noise penalty uses
@@ -12,6 +12,7 @@ import {
12
12
  injectedIdsFileName,
13
13
  injectedIdKey,
14
14
  EVENT_ID_PREFIX,
15
+ lessonIdTokens,
15
16
  readInjectedMarker,
16
17
  mergeInjectedMarker,
17
18
  } from '../lib/injected-ids.mjs';
@@ -485,7 +486,8 @@ try {
485
486
  const seenIds = typeof entry === 'object' && Array.isArray(entry.lessonIds) ? entry.lessonIds : [];
486
487
  const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
487
488
  if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
488
- const idList = seenIds.map((id) => `#${id}`).join(', ');
489
+ // Namespaced per table: a bare `#N` for an event id names a different memory.
490
+ const idList = lessonIdTokens(seenIds, entry.obsIds).join(', ');
489
491
  queueHookContext(
490
492
  'PreToolUse',
491
493
  [
@@ -429,7 +429,8 @@ export function searchByFts(
429
429
  // docs/p0-injection-noise-baseline.txt.
430
430
  // A1 (v2.83): cite_factor closes the citation-decay → ranking loop. Obs the
431
431
  // assistant cited in past sessions (cited_count > 0) get boosted; obs with
432
- // accumulating uncited_streak get dampened upstream of importance-decay.
432
+ // accumulating uncited_streak get dampened (citation-decay no longer writes importance,
433
+ // D#179/D#198, so this multiplier is the loop's only ranking effect).
433
434
  // Disjoint signal from noise_penalty (which uses injection_count vs
434
435
  // access_count) — see scoring-sql.mjs::citeFactorClause for the math.
435
436
  const sql = `