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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -0
- package/README.zh-CN.md +12 -0
- package/adopt-content.mjs +3 -1
- package/bash-utils.mjs +123 -11
- package/hook-context.mjs +11 -4
- package/hook.mjs +7 -1
- package/lib/citation-tracker.mjs +70 -5
- package/lib/cite-back-hint.mjs +4 -2
- package/lib/events-injection.mjs +25 -0
- package/lib/injected-ids.mjs +33 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schema.mjs +7 -4
- package/scoring-sql.mjs +1 -1
- package/scripts/pre-tool-recall.js +3 -1
- package/scripts/user-prompt-search.js +2 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// `
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
72
|
-
if (SEARCH_VERBS.has(first)) return
|
|
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 {
|
|
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.
|
|
742
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/lib/events-injection.mjs
CHANGED
|
@@ -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.
|
package/lib/injected-ids.mjs
CHANGED
|
@@ -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/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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.
|
|
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;
|
|
62
|
-
//
|
|
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
|
-
//
|
|
367
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 = `
|