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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -0
- package/README.zh-CN.md +12 -0
- package/adopt-content.mjs +3 -1
- package/bash-utils.mjs +460 -11
- package/hook-context.mjs +11 -4
- package/hook-handoff.mjs +25 -10
- package/hook.mjs +7 -1
- package/lib/citation-tracker.mjs +86 -7
- package/lib/cite-back-hint.mjs +4 -2
- package/lib/events-injection.mjs +25 -0
- package/lib/injected-ids.mjs +33 -0
- package/mem-cli.mjs +4 -1
- 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.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.
|
|
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
|
-
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// `
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
72
|
-
if (SEARCH_VERBS.has(first)) return
|
|
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 {
|
|
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-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
|
-
|
|
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
|
|
364
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
357
365
|
`,
|
|
358
366
|
)
|
|
359
|
-
.
|
|
360
|
-
|
|
367
|
+
.iterate(sessionId, project, ...obsWindowParams)) {
|
|
368
|
+
let files;
|
|
361
369
|
try {
|
|
362
|
-
JSON.parse(row.files_modified)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
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
|
-
|
|
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,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)
|
|
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
|
|
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
|
|
203
|
-
//
|
|
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
|
-
|
|
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
|
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/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).
|
|
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.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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.
|
|
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;
|
|
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 = `
|