claude-mem-lite 6.12.2 → 6.13.1

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.1",
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.1",
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,20 @@ 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. Do not compare a reading taken before 6.13.0 with one taken after.
253
+
240
254
  ## Upgrading to 6.11.0
241
255
 
242
256
  **One default changes: re-enrich stops leaving part of its budget idle.** It reserves half of
@@ -900,6 +914,7 @@ claude-mem-lite.
900
914
  | `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
915
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
902
916
  | `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)_ |
917
+ | `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
918
  | `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
919
  | `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
920
 
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` 各注入面的引用率从此偏低。不要拿 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']);
@@ -57,20 +75,114 @@ const GIT_READ_SUBCMDS = new Set([
57
75
  const HARD_ERROR_RE =
58
76
  /\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
77
 
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);
78
+ // Commands that only set the shell up for the next one. They neither make a command
79
+ // read-only nor stop it being one: `cd repo && grep …` is a grep. This matters because
80
+ // the host resets the cwd between Bash calls, so agents prefix `cd <repo> &&` to over
81
+ // half of their commands (5009 of 9096 in this repo's transcripts, 2026-09-25) — and
82
+ // while `cd` counted as the primary verb, every such grep/sed of source that mentions
83
+ // `TypeError:` fired error-recall on a command that had not failed.
84
+ const NEUTRAL_VERBS = new Set([
85
+ 'cd',
86
+ 'pushd',
87
+ 'popd',
88
+ 'echo',
89
+ 'printf',
90
+ 'true',
91
+ ':',
92
+ 'export',
93
+ 'set',
94
+ 'exit',
95
+ ]);
96
+
97
+ /** 'read' | 'neutral' | 'other' for one simple command (one element of a pipeline). */
98
+ function classifySimpleCommand(text) {
99
+ const toks = text.trim().split(/\s+/).filter(Boolean);
68
100
  let i = 0;
69
101
  while (i < toks.length && (/^\w+=/.test(toks[i]) || CMD_WRAPPERS.has(toks[i]))) i++;
70
102
  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]);
103
+ if (!first || NEUTRAL_VERBS.has(first)) return 'neutral';
104
+ if (SEARCH_VERBS.has(first)) return 'read';
105
+ return first === 'git' && GIT_READ_SUBCMDS.has(toks[i + 1]) ? 'read' : 'other';
106
+ }
107
+
108
+ /**
109
+ * Split a command line into statements (on `;`, newline, `&&`, `||`, `&`), each a list
110
+ * of its pipeline elements (on `|` and `|&`). Quote-aware, so the `;` in
111
+ * `grep -E "a;b"` separates nothing, and the `&` of a redirection (`2>&1`, `&>f`) is not
112
+ * a statement break. A backslash-newline continues the line. Returns null when the
113
+ * quotes do not balance (a heredoc body with an apostrophe, say) — the caller then falls
114
+ * back to the one-verb rule rather than guess.
115
+ */
116
+ function splitStatements(cmd) {
117
+ const statements = [];
118
+ let pipeline = [];
119
+ let cur = '';
120
+ let quote = null;
121
+ const endElement = () => {
122
+ pipeline.push(cur);
123
+ cur = '';
124
+ };
125
+ const endStatement = () => {
126
+ endElement();
127
+ statements.push(pipeline);
128
+ pipeline = [];
129
+ };
130
+ for (let k = 0; k < cmd.length; k++) {
131
+ const ch = cmd[k];
132
+ if (quote) {
133
+ if (ch === quote) quote = null;
134
+ else if (ch === '\\' && quote === '"') cur += cmd[k++];
135
+ cur += ch;
136
+ continue;
137
+ }
138
+ if (ch === '\\') {
139
+ if (cmd[k + 1] !== '\n') cur += ch + (cmd[k + 1] ?? '');
140
+ k++;
141
+ continue;
142
+ }
143
+ if (ch === "'" || ch === '"') {
144
+ quote = ch;
145
+ cur += ch;
146
+ continue;
147
+ }
148
+ const next = cmd[k + 1];
149
+ if (ch === '|' && next !== '|') {
150
+ endElement();
151
+ if (next === '&') k++;
152
+ continue;
153
+ }
154
+ const isRedirectAmp = ch === '&' && (cmd[k - 1] === '>' || cmd[k - 1] === '<' || next === '>');
155
+ if (ch === ';' || ch === '\n' || (ch === '&' && !isRedirectAmp) || (ch === '|' && next === '|')) {
156
+ endStatement();
157
+ if ((ch === '&' && next === '&') || ch === '|') k++;
158
+ continue;
159
+ }
160
+ cur += ch;
161
+ }
162
+ if (quote) return null;
163
+ endStatement();
164
+ return statements;
165
+ }
166
+
167
+ // True when the command only READS: every element of every pipeline is a read/search
168
+ // (including `git grep`/`git log`) or a neutral set-up like `cd`/`echo`, and at least one
169
+ // is a read. Anchoring on the verbs actually executed (not "search verb appears
170
+ // anywhere") is what lets `npm run build 2>&1 | tail` stay an error while `sudo grep`,
171
+ // `git grep`, `cat f | head` and `cd repo && sed -n 1,9p f` are exempt. Every statement
172
+ // and every pipe consumer is checked, so `grep x f; npm test | tail` and
173
+ // `printf '…' | node server.mjs` are not exempted on the strength of their first word.
174
+ function isReadOnlyCommand(cmd) {
175
+ const statements = splitStatements(cmd);
176
+ if (!statements) return classifySimpleCommand(cmd.split('|')[0]) === 'read';
177
+ let sawRead = false;
178
+ for (const pipeline of statements) {
179
+ for (const element of pipeline) {
180
+ const kind = classifySimpleCommand(element);
181
+ if (kind === 'other') return false;
182
+ if (kind === 'read') sawRead = true;
183
+ }
184
+ }
185
+ return sawRead;
74
186
  }
75
187
 
76
188
  // 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.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,77 @@ 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
+ const DISMISSAL_LIST_TAIL = /^(?:\*\*|`|\s|[,,、/]|and\b|和|[A-Z]?#\d{1,7}\b)*/;
120
+ const DISMISSAL_GLOSS = /^\s*(?:([^()\n]{1,80})|\([^()\n]{1,80}\))/;
121
+ const DISMISSAL_MARKER =
122
+ /^[\s*`::\-—–(([]*(?:(?:本轮|本次|这次|此次|此处)\s*)?(?:n\/a\b|not applicable|does(?:n't| not) apply|did(?:n't| not) apply|irrelevant|not relevant|unrelated|不适用|不相关|无关(?!紧要)|与(?:本|这|此)[^。\n;;]{0,16}无关)/i;
123
+
124
+ /**
125
+ * True when the `#NN` that ENDS at `end` in `text` is answered as not applying.
126
+ * @param {string} text
127
+ * @param {number} end index just past the matched `#NN`
128
+ * @returns {boolean}
129
+ */
130
+ export function isDismissalAt(text, end) {
131
+ let rest = text.slice(end, end + 240);
132
+ rest = rest.slice(rest.match(DISMISSAL_LIST_TAIL)[0].length);
133
+ if (markerNotRetracted(rest)) return true;
134
+ const gloss = rest.match(DISMISSAL_GLOSS);
135
+ return !!gloss && markerNotRetracted(rest.slice(gloss[0].length));
136
+ }
137
+
138
+ // The marker opens the clause; an application stated later in the SAME clause
139
+ // ("#12 n/a for a.mjs; applied to b.mjs", "#12 does not apply to tests but I applied it
140
+ // to lib") means the lesson WAS used, and a miss here keeps the old crediting, which is
141
+ // the cheap direction (pre-ship defect review P3-6). The clause ends at a newline, a
142
+ // sentence stop, or the next id, so "#1 n/a; #2 applied" still dismisses #1.
143
+ const DISMISSAL_CLAUSE_END = /[\n。]|\.\s|[A-Z]?#\d/;
144
+ // Completed, un-negated application only: a present-tense "applies" is how a dismissal
145
+ // explains itself ("n/a — it applies to the server path, not this file"), and "does not
146
+ // apply" / 不采纳 negate (delta review P3-2).
147
+ const APPLIED_IN_CLAUSE =
148
+ /(?<!\b(?:not|never|n't)\s)\b(?:applied|adopted)\b|已采纳|采纳了|已按|按它修|已应用|用上了/i;
149
+ function markerNotRetracted(s) {
150
+ const m = s.match(DISMISSAL_MARKER);
151
+ if (!m) return false;
152
+ const clause = s.slice(m[0].length).split(DISMISSAL_CLAUSE_END)[0];
153
+ return !APPLIED_IN_CLAUSE.test(clause);
154
+ }
155
+
104
156
  /**
105
157
  * Parse a Claude Code transcript .jsonl and extract unique observation IDs
106
158
  * cited inside assistant text blocks.
107
159
  *
160
+ * An id every one of whose mentions is a dismissal (`#NN n/a — …`, see isDismissalAt)
161
+ * is NOT a citation by default: the callers that credit a row (decay promotion, the
162
+ * access bump, the funnel and per-face cite rates) must not reward "this did not
163
+ * apply". One non-dismissing mention anywhere in the session is enough to cite it.
164
+ * Pass `includeDismissed: true` where the question is COMPLIANCE — "did the agent
165
+ * answer the lesson it was shown" — for which a dismissal is a complete answer.
166
+ *
108
167
  * @param {string} transcriptPath Path to transcript file (.jsonl)
109
168
  * @param {object} [opts] Options
110
169
  * @param {boolean} [opts.mainOnly=false] If true, skip transcript records where isSidechain === true
170
+ * @param {boolean} [opts.includeDismissed=false] If true, count `#NN n/a` mentions too
111
171
  * @returns {Set<number>} unique IDs referenced as `#NN` in assistant text
112
172
  */
113
173
  export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
114
- const { mainOnly = false } = opts;
174
+ const { mainOnly = false, includeDismissed = false } = opts;
115
175
  const ids = new Set();
116
176
  for (const entry of readTranscriptEntries(transcriptPath)) {
117
177
  // Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
@@ -129,7 +189,8 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
129
189
  let m;
130
190
  while ((m = CITATION_RE.exec(block.text))) {
131
191
  const id = Number(m[1]);
132
- if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
192
+ if (!(Number.isInteger(id) && id > 0 && id < 1e7)) continue;
193
+ if (includeDismissed || !isDismissalAt(block.text, m.index + m[0].length)) ids.add(id);
133
194
  }
134
195
  }
135
196
  }
@@ -140,7 +201,8 @@ export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
140
201
  * D#179 prerequisite measurement: split each cited id into the responses that
141
202
  * ACTED while naming it and the responses that only TALKED about it.
142
203
  *
143
- * `applyCitationDecay` promotes on any `#NN` in assistant text, so writing a release
204
+ * `applyCitationDecay` promotes on any `#NN` in assistant text that is not a stated
205
+ * dismissal (`#NN n/a`, see isDismissalAt), so writing a release
144
206
  * note, an audit, or a review that discusses a memory promotes that memory — including,
145
207
  * self-referentially, a note observing that the memory is about to be evicted. Nothing
146
208
  * downstream distinguishes "changed the code this lesson describes" from "mentioned the
@@ -1254,7 +1316,8 @@ export function computeThreadCiteRecall(transcriptPath) {
1254
1316
  // the PROMPT (updatedInput). Fold that in so sidechain recall isn't a false 0. On a
1255
1317
  // main transcript this marker is absent → no-op.
1256
1318
  for (const id of extractInjectedFromSubagentPrompt(transcriptPath)) injected.add(id);
1257
- const cited = extractCitationsFromTranscript(transcriptPath);
1319
+ // A compliance ratio — "did the thread answer what it was handed" — so `#NN n/a` counts.
1320
+ const cited = extractCitationsFromTranscript(transcriptPath, { includeDismissed: true });
1258
1321
  let recalled = 0;
1259
1322
  for (const id of injected) if (cited.has(id)) recalled++;
1260
1323
  return {
@@ -1465,7 +1528,9 @@ export function redirectSupersededIds(db, project, ids) {
1465
1528
  * this lesson" from "wrote about this lesson" (D#179; a release-note session
1466
1529
  * promotes exactly the rows it discusses, and the mention/application split
1467
1530
  * 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.
1531
+ * shift is the right cost for a mis-read citation. An eviction is not. (One case now
1532
+ * has a signal: the agent's own `#NN n/a` — see isDismissalAt — is no longer read as
1533
+ * a citation. A mention that merely discusses a lesson still is.)
1469
1534
  *
1470
1535
  * NOT covered by this change, and stated so nobody reads it as "citations can no
1471
1536
  * 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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.12.2",
3
+ "version": "6.13.1",
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.1",
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.1",
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 = `