claude-mem-lite 6.14.0 → 6.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,10 +9,10 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.14.0",
12
+ "version": "6.16.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
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)."
15
+ "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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)."
16
16
  }
17
17
  ]
18
18
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.14.0",
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).",
3
+ "version": "6.16.0",
4
+ "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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"
7
7
  },
package/README.md CHANGED
@@ -237,6 +237,37 @@ 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.16.0
241
+
242
+ **Three defaults change; one has an off switch.** No schema change and no migration, so
243
+ reverting everything is pinning `claude-mem-lite@6.15.0`.
244
+
245
+ - **Each session gets one of two first lines on a file-recall block.** Half of sessions keep
246
+ "system-injected context, continue your planned action"; the other half get a plain
247
+ statement of where the notes come from, as Claude Code's hooks guide recommends. The two
248
+ are compared by cite-rate before one becomes the default. Off (old line everywhere):
249
+ `CLAUDE_MEM_RECALL_FRAMING=legacy`.
250
+ - **Memory text longer than the host's 10,000-character hook limit is trimmed by whole
251
+ lines**, with a closing line naming the ids left out, instead of the host replacing it with
252
+ a 2,000-character preview. No switch; pin 6.15.0 to revert.
253
+ - **Lessons shown after a failed Bash command now count in citation decay**, like every other
254
+ surface: ones never cited are ranked down over time. No switch; pin 6.15.0 to revert.
255
+
256
+ ## Upgrading to 6.15.0
257
+
258
+ **Two defaults change; one has an off switch.** No schema change and no migration, so
259
+ reverting everything is pinning `claude-mem-lite@6.14.0`.
260
+
261
+ - **An auto-captured lesson that repeats text a tool printed is kept out of automatic
262
+ injection.** Whoever controls a command's output (a repository's test, a fetched page, an
263
+ MCP server) could otherwise get a sentence of their choosing stored as a lesson that later
264
+ sessions are shown. Such an event stays searchable at importance 1; a `change` observation
265
+ loses the lesson. Four shared words are enough, filler included, so an occasional lesson
266
+ of your own is demoted too. Off: `CLAUDE_MEM_LESSON_OUTPUT_CAP=off`.
267
+ - **Last Session reads your own Done / Not done report when it uses markdown headings**
268
+ (`## Done`, `**Not done**`) instead of falling back to the model's summary. No switch; pin
269
+ 6.14.0 to revert.
270
+
240
271
  ## Upgrading to 6.14.0
241
272
 
242
273
  **Five defaults change; three have an off switch.** No schema change and no migration: an
@@ -942,6 +973,7 @@ claude-mem-lite.
942
973
  | `CLAUDE_MEM_EPISODE_INPUT_FILTER` | What the episode summarizer may learn from. A subagent's tool calls stay out of the episode buffer unless they edit a file inside the project, and mutation probes (mutate → RED run → restore) and the agent's own failing inline scripts (a patch's `anchor not found`) are dropped before a window is saved or summarized — replayed over the 30 audited events, this removes 3 of the 16 that were wrong outright; with `CLAUDE_MEM_LESSON_GROUNDING` none of the 16 lessons is injected. `off` restores the unfiltered input. | _(on)_ |
943
974
  | `CLAUDE_MEM_BASH_RECALL` | File recall before a Bash command that views (`cat`, `sed -n`, `head`…) or writes (`sed -i`, `cat > f`, a python patch…) a file, like the Read / Edit recall. A bash prefilter keeps Node from starting for other commands. `off` disables this leg only. | _(on)_ |
944
975
  | `CLAUDE_MEM_LESSON_GROUNDING` | An auto-captured event keeps its lesson only when the lesson quotes the window's own diagnosis (a failing output line, a comment the edit added, or the commit message); otherwise the row is kept without it at importance 1, below every injection face. `off` keeps unquoted lessons. | _(on)_ |
976
+ | `CLAUDE_MEM_LESSON_OUTPUT_CAP` | An auto-captured lesson that shares four consecutive words with TOOL OUTPUT (a command's printed text or a tool's response, which whoever controls that output can write) is kept off the injection faces: an event keeps its row and lesson, searchable, at importance 1; a `change` observation, whose importance later reads can raise, loses the lesson, and the lesson-less row is then dropped like any other (kept only with `CLAUDE_MEM_KEEP_LOW_SIGNAL=1`). Four shared words of filler ("is not in the") count too, so a lesson quoting your own comment or commit message is demoted when it also happens to share such a run with output in the same window. The row's title is not checked. `off` restores the model's importance and the lesson. | _(on)_ |
945
977
  | `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)_ |
946
978
  | `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)_ |
947
979
 
@@ -1039,6 +1071,7 @@ and names can change between releases.
1039
1071
  |----------|-------------|---------|
1040
1072
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1041
1073
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1074
+ | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. `ab` gives each session one of two wordings, the older "system-injected context, continue your planned action" or a plain statement of source, so their cite-rates can be compared in one run (`benchmark/citation-live-replay.mjs --by-framing`); `legacy` / `factual` pin one. | `ab` |
1042
1075
  | `CLAUDE_MEM_SALIENCE` | Selects a comprehension-bridge arm (`bridge`, `bind`); unset = current default behavior. | _(unset)_ |
1043
1076
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1044
1077
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
package/README.zh-CN.md CHANGED
@@ -199,6 +199,32 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.16.0
203
+
204
+ **三处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
205
+ `claude-mem-lite@6.15.0`。
206
+
207
+ - **文件召回块的第一行,每个会话分到两种写法之一。** 一半会话保留原来的 "system-injected context,
208
+ continue your planned action",另一半改成说明这些笔记来自哪里的事实陈述(Claude Code 的 hooks
209
+ 指南建议这样写)。两种写法先比较引用率,再决定默认用哪个。关闭(全部用旧写法):
210
+ `CLAUDE_MEM_RECALL_FRAMING=legacy`。
211
+ - **超过宿主 1 万字符 hook 上限的记忆文本会按整行裁剪**,末尾一行列出被略去的 id;以前宿主会把它换成
212
+ 2,000 字符的预览。没有开关,回退请固定 6.15.0。
213
+ - **Bash 命令失败后展示的教训,现在也计入引用衰减**,和其他注入面一致:一直没被引用的会逐渐排到后面。
214
+ 没有开关,回退请固定 6.15.0。
215
+
216
+ ## 升级到 6.15.0
217
+
218
+ **两处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
219
+ `claude-mem-lite@6.14.0`。
220
+
221
+ - **自动捕获的教训如果复述了工具打印的文字,不再自动注入。** 否则能控制命令输出的一方(仓库里的测试、
222
+ 抓取的网页、MCP 服务)就能让自己写的一句话被存成教训,之后的会话都会看到。这样的 event 仍可搜索,
223
+ importance 降为 1;`change` 类 observation 则去掉教训。连续 4 个词相同就算,虚词也算,所以偶尔也会
224
+ 误降你自己的教训。关闭:`CLAUDE_MEM_LESSON_OUTPUT_CAP=off`。
225
+ - **Last Session 能读到你用 markdown 标题写的 Done / Not done 报告**(`## Done`、`**Not done**`),
226
+ 不再退回模型写的摘要。没有开关,回退请固定 6.14.0。
227
+
202
228
  ## 升级到 6.14.0
203
229
 
204
230
  **五个默认行为变化,其中三个有关闭开关。** 没有 schema 变更、没有迁移:旧版本仍能打开数据库,
@@ -722,6 +748,7 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
722
748
  | `CLAUDE_MEM_EPISODE_INPUT_FILTER` | 决定 episode 摘要器能从哪些输入里学。子代理的工具调用不进入 episode 缓冲(修改项目内文件的除外);变异探针(改文件 → 跑红 → 还原)和 agent 自己失败的内联脚本(补丁的 `anchor not found`)在保存或摘要前被剔除——在 30 条已核验 event 上重放,这一项直接去掉 16 条错误中的 3 条;配合 `CLAUDE_MEM_LESSON_GROUNDING`,16 条错误教训一条都不会被注入。设为 `off` 恢复未过滤的输入。 | _(开启)_ |
723
749
  | `CLAUDE_MEM_BASH_RECALL` | 在查看(`cat`、`sed -n`、`head`…)或写入(`sed -i`、`cat > f`、python 补丁…)文件的 Bash 命令执行前做文件召回,与 Read / Edit 召回相同。bash 预过滤让其他命令不启动 Node。设为 `off` 只关闭这一路。 | _(开启)_ |
724
750
  | `CLAUDE_MEM_LESSON_GROUNDING` | 自动捕获的 event 只有在教训引用了本窗口自己的诊断文字(失败输出行、编辑新增的注释或提交信息)时才保留教训;否则保留这一行但去掉教训,importance 降为 1,低于所有注入面的门槛。设为 `off` 保留未引用原文的教训。 | _(开启)_ |
751
+ | `CLAUDE_MEM_LESSON_OUTPUT_CAP` | 自动捕获的教训如果和**工具输出**(命令打印的文字或工具返回的内容,能控制这段输出的人就能写它)有连续 4 个词相同,就不会进入注入面:event 保留这一行和教训、仍可搜索,importance 降为 1;`change` 类 observation 的 importance 之后会被读取次数抬高,所以改为去掉教训,没有教训的这一行随后会像其他同类行一样被丢弃(只有设了 `CLAUDE_MEM_KEEP_LOW_SIGNAL=1` 才保留)。连续 4 个虚词(例如 "is not in the")也算,所以引用你自己的注释或提交信息的教训,只要碰巧和同一窗口的输出共有这样一串词,也会被降级。这一行的标题不在检查范围内。设为 `off` 恢复模型给出的 importance 和教训。 | _(开启)_ |
725
752
  | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止每次 SessionStart 在**所有**项目自动写入 `CLAUDE.md` 托管块。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
726
753
  | `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`)的项目才会出现。 | _(禁用)_ |
727
754
 
package/cli/common.mjs CHANGED
@@ -326,6 +326,7 @@ export const KNOWN_CLI_FLAGS = new Set([
326
326
  'benchmark',
327
327
  'body',
328
328
  'branch',
329
+ 'chars',
329
330
  'closes-deferred',
330
331
  'concepts',
331
332
  'confirm',
@@ -427,6 +428,7 @@ export const KNOWN_CLI_FLAGS = new Set([
427
428
  */
428
429
  export const COMMAND_SCOPED_FLAGS = new Map([
429
430
  ['apply', 'verify-apply'],
431
+ ['chars', 'context'],
430
432
  ['digest', 'verify-apply'],
431
433
  ['print-project', 'verify-apply'],
432
434
  ['undo', 'verify-apply'],
package/hook-llm.mjs CHANGED
@@ -56,7 +56,12 @@ import { DAY_MS } from './lib/time-constants.mjs';
56
56
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
57
57
  import { recoverChildrenOf } from './lib/maintain-core.mjs';
58
58
  import { MEMORY_INPUT_GUARD } from './lib/memory-input-guard.mjs';
59
- import { isLessonGrounded, lessonGroundingEnabled } from './lib/episode-input-filter.mjs';
59
+ import {
60
+ isLessonGrounded,
61
+ lessonGroundingEnabled,
62
+ lessonOutputCapEnabled,
63
+ quotedLines,
64
+ } from './lib/episode-input-filter.mjs';
60
65
 
61
66
  /**
62
67
  * Retract a pre-saved observation this worker created moments ago, after the Haiku
@@ -118,6 +123,35 @@ export function episodeDiagnosis(episode) {
118
123
  return out;
119
124
  }
120
125
 
126
+ /**
127
+ * The text that reached the episode ONLY as tool output, never as text the agent authored in
128
+ * any entry: an entry's \`diagOut\` lines, and the response snippet \`makeEntryDesc\` puts in
129
+ * a desc — after " → " for Bash and Grep, after "<tool>: " for its default arm (MCP servers,
130
+ * Skill, SendMessage, MultiEdit, anything unlisted) — which the prompt shows as the action.
131
+ * The form is chosen by TOOL, never by what the desc contains: a response is attacker text,
132
+ * so an arrow inside an MCP snippet must not decide where the snippet starts (delta reviews:
133
+ * the first repair read only the arrow form, the second read the arrow first for every
134
+ * tool). The other named cases (Edit, Write, NotebookEdit, Agent / Task, LSP, WebSearch,
135
+ * WebFetch) describe the agent's own input and match neither form. A Bash entry buffered
136
+ * before \`diagOut\` existed counts all its lines as output — over-capping one flush after an
137
+ * upgrade, never under-capping. Exported for tests.
138
+ */
139
+ export function episodeOutputDiagnosis(episode) {
140
+ const output = new Set();
141
+ const authored = new Set();
142
+ for (const e of Array.isArray(episode?.entries) ? episode.entries : []) {
143
+ const diag = Array.isArray(e?.diag) ? e.diag : [];
144
+ const out = new Set(Array.isArray(e?.diagOut) ? e.diagOut : e?.tool === 'Bash' ? diag : []);
145
+ for (const l of diag) (out.has(l) ? output : authored).add(l);
146
+ if (typeof e?.desc !== 'string' || typeof e?.tool !== 'string') continue;
147
+ if (e.tool === 'Bash' || e.tool === 'Grep') {
148
+ const arrow = e.desc.indexOf(' → ');
149
+ if (arrow !== -1) output.add(e.desc.slice(arrow + 3).replace(/^ERROR: /, ''));
150
+ } else if (e.desc.startsWith(`${e.tool}: `)) output.add(e.desc.slice(e.tool.length + 2));
151
+ }
152
+ return [...output].filter((l) => l && !authored.has(l));
153
+ }
154
+
121
155
  function diagnosisBlock(diag) {
122
156
  return diag.length
123
157
  ? `DIAGNOSIS (verbatim from this window — the only text a lesson may rest on):\n${diag.map((l, i) => `D${i + 1}. ${l}`).join('\n')}`
@@ -1163,6 +1197,20 @@ ${diagnosisBlock(diag)}`;
1163
1197
  }
1164
1198
  }
1165
1199
 
1200
+ const quotesToolOutput =
1201
+ Boolean(lessonLearned) &&
1202
+ lessonOutputCapEnabled() &&
1203
+ quotedLines(lessonLearned, episodeOutputDiagnosis(episode), 1, { anyWord: true }).length > 0;
1204
+ if (quotesToolOutput)
1205
+ debugLog('DEBUG', 'llm-episode', 'lesson quotes tool output: importance capped at 1');
1206
+ // An observation's importance does not stay where this worker puts it — two mem_get
1207
+ // reads (autoBoostIfNeeded) or four accesses (boostAccessed) lift 1 to 2 (pre-ship
1208
+ // review P2-1). Only \`change\` lands in \`observations\` (no writer raises an event's
1209
+ // importance), so there the lesson itself is dropped; the row then meets the
1210
+ // lesson-less-change rule like any other.
1211
+ if (quotesToolOutput && (validTypes.has(parsed.type) ? parsed.type : 'change') === 'change')
1212
+ lessonLearned = null;
1213
+
1166
1214
  const searchAliases = Array.isArray(parsed.search_aliases)
1167
1215
  ? parsed.search_aliases.slice(0, 6).join(' ')
1168
1216
  : null;
@@ -1203,8 +1251,12 @@ ${diagnosisBlock(diag)}`;
1203
1251
  // two are equal with grounding off). A lesson the grounding check dropped caps
1204
1252
  // `decision` too: its body falls back to the model's narrative, which is exactly
1205
1253
  // as unanchored as the lesson it replaces.
1254
+ // D#100(3): a lesson quoting a line that reached the window only as tool OUTPUT
1255
+ // carries text whoever controls that output wrote — reproduced on real Haiku,
1256
+ // 3 of 6 hostile windows stored the directive at importance 2. The row and
1257
+ // lesson stay searchable; only the automatic injection faces lose them.
1206
1258
  importance:
1207
- !lessonLearned && (groundingDropped || parsed.type !== 'decision')
1259
+ (!lessonLearned && (groundingDropped || parsed.type !== 'decision')) || quotesToolOutput
1208
1260
  ? Math.min(ruleImportance, 1)
1209
1261
  : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
1210
1262
  lessonLearned,
@@ -9,6 +9,7 @@ import { buildSessionContextLines } from './hook-context.mjs';
9
9
  import { inferProject, debugCatch, debugLog } from './utils.mjs';
10
10
  import { RUNTIME_DIR } from './hook-shared.mjs';
11
11
  import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
12
+ import { writeCappedHookText } from './lib/hook-text-cap.mjs';
12
13
 
13
14
  /**
14
15
  * Build + emit the memory context block on stdout. Writes the Key Context ids
@@ -27,7 +28,7 @@ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_
27
28
  const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
28
29
  const rendered = body && String(body).trim() !== '';
29
30
  if (rendered) {
30
- process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
31
+ writeCappedHookText(`<claude-mem-context>\n${body}\n</claude-mem-context>`);
31
32
  }
32
33
  // Recorded even when NOTHING was re-rendered, matching handleSessionStart — the two
33
34
  // callers must describe the same set (keyctx-marker.mjs header), and the marker is an
package/hook.mjs CHANGED
@@ -99,10 +99,11 @@ import {
99
99
  import { formatHookError } from './lib/native-binding-hint.mjs';
100
100
  import { recordHookError } from './lib/hook-telemetry.mjs';
101
101
  import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
102
+ import { writePlainHookText, resetPlainHookText } from './lib/hook-text-cap.mjs';
102
103
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
103
104
  import {
104
105
  entryInputTags,
105
- extractDiagnosisLines,
106
+ extractDiagnosis,
106
107
  filterSummaryInput,
107
108
  episodeInputFilterEnabled,
108
109
  } from './lib/episode-input-filter.mjs';
@@ -725,16 +726,22 @@ async function handlePostToolUse() {
725
726
  // regex-scanned on every PostToolUse.
726
727
  const respWindow = resp.length > 65536 ? resp.slice(0, 32768) + '\n' + resp.slice(-32768) : resp;
727
728
 
729
+ // `diagOut` = the diagnosis lines read from tool OUTPUT: a lesson quoting one stays
730
+ // under every injection floor (D#100(3)). Always present on a Bash entry, [] included, so
731
+ // the worker can tell a new entry with no output lines from one buffered before the field.
732
+ const diagnosis = extractDiagnosis(tool_name, toolInput, respWindow, {
733
+ isError: bashSig?.isError || false,
734
+ writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
735
+ scrub: scrubSecrets,
736
+ });
737
+
728
738
  // Build episode entry
729
739
  const entry = {
730
740
  tool: tool_name,
731
741
  desc: scrubSecrets(makeEntryDesc(tool_name, toolInput, resp, bashSig)),
732
742
  inputTags: entryInputTags(tool_name, toolInput, respWindow),
733
- diag: extractDiagnosisLines(tool_name, toolInput, respWindow, {
734
- isError: bashSig?.isError || false,
735
- writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
736
- scrub: scrubSecrets,
737
- }),
743
+ diag: diagnosis.lines,
744
+ ...(tool_name === 'Bash' ? { diagOut: diagnosis.output } : {}),
738
745
  files,
739
746
  ...(tool_name === 'Bash' && bashWrites.length ? { bashWrites } : {}),
740
747
  ts: Date.now(),
@@ -3034,7 +3041,7 @@ function injectHandoffIfEarly(db, { project, promptText, promptNumber, ccSession
3034
3041
  const picked = pickHandoffToInject(db, project, ccSessionId);
3035
3042
  if (picked) {
3036
3043
  const injection = renderHandoffInjection(db, project, ccSessionId);
3037
- if (injection) process.stdout.write(injection + '\n');
3044
+ if (injection) writePlainHookText(injection);
3038
3045
  // Consume ONLY the row we just injected — leave other projects' exit
3039
3046
  // handoffs intact so future sessions can still resume from them.
3040
3047
  // Pre-v2.46 wiped every exit handoff for the project on any continuation
@@ -3245,7 +3252,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3245
3252
  const lines = ['<memory-context relevance="high">'];
3246
3253
  for (const m of memories) lines.push(formatMemoryLine(m));
3247
3254
  lines.push('</memory-context>');
3248
- process.stdout.write(lines.join('\n') + '\n');
3255
+ writePlainHookText(lines.join('\n'));
3249
3256
  }
3250
3257
  // HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
3251
3258
  // store for promoted bugfix/decision/lesson memories that persistHaikuSummary
@@ -3266,7 +3273,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3266
3273
  const elines = ['<memory-context relevance="events">'];
3267
3274
  for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
3268
3275
  elines.push('</memory-context>');
3269
- process.stdout.write(elines.join('\n') + '\n');
3276
+ writePlainHookText(elines.join('\n'));
3270
3277
  }
3271
3278
  } catch (e) {
3272
3279
  debugCatch(e, 'handleUserPrompt-events');
@@ -3275,7 +3282,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3275
3282
  // Guard the write on a non-empty return — formatTaskImperative yields '' for a
3276
3283
  // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
3277
3284
  const imperativeLine = formatTaskImperative(imperativePick.lesson_learned, imperativePick.id);
3278
- if (imperativeLine) process.stdout.write(imperativeLine + '\n');
3285
+ if (imperativeLine) writePlainHookText(imperativeLine);
3279
3286
  }
3280
3287
 
3281
3288
  // D#214's ruler, second half: arm B was computed above, before anything was
@@ -3306,6 +3313,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3306
3313
  }
3307
3314
 
3308
3315
  async function handleUserPrompt() {
3316
+ resetPlainHookText();
3309
3317
  const input = await readUserPromptInput();
3310
3318
  if (!input) return;
3311
3319
  const { promptText, hookData } = input;
@@ -429,10 +429,11 @@ function elementHeads(body) {
429
429
  * The body of a helper defined at \`at\`: python by indentation, JS by brackets (a block
430
430
  * \`{…}\`, or an expression up to the first \`;\` / newline at bracket depth 0).
431
431
  */
432
- function helperBody(text, at, isPython) {
432
+ function helperBody(text, at, isPython, headerAt = at) {
433
433
  if (isPython) {
434
- // `at` sits just past the `:`, often ON the newline that ends the def line.
435
- const lineStart = text.lastIndexOf('\n', at - 1) + 1;
434
+ // `at` sits just past the `:`, often ON the newline that ends the def line. The suite's
435
+ // indent is the HEADER's, which starts on an earlier line when a loop's list wraps.
436
+ const lineStart = text.lastIndexOf('\n', headerAt - 1) + 1;
436
437
  const indent = /^[ \t]*/.exec(text.slice(lineStart))[0].length;
437
438
  const bodyStart = text.indexOf('\n', at);
438
439
  if (bodyStart === -1) return text.slice(at);
@@ -443,7 +444,9 @@ function helperBody(text, at, isPython) {
443
444
  let eol = text.indexOf('\n', pos);
444
445
  if (eol === -1) eol = text.length;
445
446
  const line = text.slice(pos, eol);
446
- if (line.trim() && /^[ \t]*/.exec(line)[0].length <= indent) break;
447
+ // A comment at any indent does not end a python suite.
448
+ const t = line.trim();
449
+ if (t && !t.startsWith('#') && /^[ \t]*/.exec(line)[0].length <= indent) break;
447
450
  pos = eol + 1;
448
451
  }
449
452
  return text.slice(bodyStart + 1, pos);
@@ -469,6 +472,35 @@ function helperBody(text, at, isPython) {
469
472
  return text.slice(i);
470
473
  }
471
474
 
475
+ /**
476
+ * The body of a loop whose header starts at \`start\` and whose iterated token sits at
477
+ * \`tokAt\` — a python suite (the rest of the header line, then the indented block) or a
478
+ * JS statement / block after \`for (…)\`. Null when the header does not close.
479
+ */
480
+ function loopBody(text, start, tokAt, tok, pythonish) {
481
+ let after = tokAt + 1;
482
+ if (tok === '[' || tok === '(' || tok === '{') {
483
+ after = closeBracket(text, tokAt);
484
+ if (after === -1) return null;
485
+ }
486
+ let eol = text.indexOf('\n', after);
487
+ if (eol === -1) eol = text.length;
488
+ const colon = pythonish ? text.slice(after, eol).search(/:(?!\w)/) : -1;
489
+ if (colon !== -1) {
490
+ const at = after + colon + 1;
491
+ return text.slice(at, eol) + helperBody(text, at, true, start);
492
+ }
493
+ // A python comprehension (\`[open(f, 'w') for f in files]\`) has no suite: its body is the
494
+ // line around it.
495
+ if (pythonish && !/^for\s*\(/.test(text.slice(start, start + 8))) {
496
+ return text.slice(text.lastIndexOf('\n', start) + 1, eol);
497
+ }
498
+ const open = text.indexOf('(', start);
499
+ if (open === -1 || open > tokAt) return null;
500
+ const close = closeBracket(text, open);
501
+ return close === -1 ? null : helperBody(text, close, false);
502
+ }
503
+
472
504
  /** Identifiers (resolved or not) that a write call in \`text\` targets, plus literal targets. */
473
505
  function writeTargetsIn(text) {
474
506
  const idents = new Set();
@@ -530,8 +562,12 @@ function scriptTargets(text, cwd) {
530
562
  const helperRe =
531
563
  /\bdef\s+([A-Za-z_]\w*)\s*\(\s*([A-Za-z_]\w*)[^)\n]{0,200}\)\s*:|\bfunction\s+([A-Za-z_$][\w$]*)\s*\(\s*([A-Za-z_$][\w$]*)[^)\n]{0,200}\)\s*|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\(\s*([A-Za-z_$][\w$]*)[^)\n]{0,200}\)|([A-Za-z_$][\w$]*))\s*=>\s*/g;
532
564
  // Bounded: a real patch script defines a handful of helpers; a pathological program
533
- // defining thousands must not hold the hook past its timeout.
565
+ // defining thousands must not hold the hook past its timeout. The price is deliberate:
566
+ // writes through a 65th distinct helper are dropped (round-3 P3-2: 200 KB of distinct
567
+ // defs records 64 writes, not 2710). The most any of 10,389 distinct Bash commands in
568
+ // this repo's transcripts defines is 12 (2026-09-26, upper bound: every def counted).
534
569
  const seenHelpers = new Set();
570
+ const writers = [];
535
571
  while ((m = helperRe.exec(text)) && seenHelpers.size < 64) {
536
572
  const name = m[1] || m[3] || m[5];
537
573
  const param = m[2] || m[4] || m[6] || m[7];
@@ -539,6 +575,7 @@ function scriptTargets(text, cwd) {
539
575
  seenHelpers.add(name);
540
576
  const body = helperBody(text, m.index + m[0].length, Boolean(m[1]));
541
577
  if (!writeTargetsIn(body).idents.has(param)) continue;
578
+ writers.push(name.replace(/\$/g, '\\$'));
542
579
  const callRe = new RegExp(
543
580
  String.raw`(?<![\w$.])${name.replace(/\$/g, '\\$')}\(\s*(['"])([^'"\n]+)\1`,
544
581
  'g',
@@ -561,19 +598,38 @@ function scriptTargets(text, cwd) {
561
598
  }
562
599
  return lists.get(tok) ?? null;
563
600
  };
564
- for (const re of loopRes) {
601
+ // The variable must be written inside the loop's OWN body — matching it by name alone
602
+ // made a read-only loop reusing a written variable's name a write (round-3 P3-1) — by a
603
+ // write call, or by a call to a helper found above to write its first parameter
604
+ // (\`for p, a, b in edits: patch(p, a, b)\`; pre-ship review P3-4).
605
+ const writerCallRe = writers.length
606
+ ? new RegExp(String.raw`(?<![\w$.])(?:${writers.join('|')})\(\s*([A-Za-z_$][\w$]*)`, 'g')
607
+ : null;
608
+ const writesVar = (body, v) => {
609
+ if (body === null) return false;
610
+ if (writeTargetsIn(body).idents.has(v)) return true;
611
+ if (!writerCallRe) return false;
612
+ writerCallRe.lastIndex = 0;
613
+ let c;
614
+ while ((c = writerCallRe.exec(body))) if (c[1] === v) return true;
615
+ return false;
616
+ };
617
+ const candidate = (v) => unresolved.has(v) || writers.length > 0;
618
+ for (const [k, re] of loopRes.entries()) {
565
619
  re.lastIndex = 0;
566
620
  let sites = 0;
567
621
  while (sites++ < MAX_INDIRECT && (m = re.exec(text))) {
568
- if (!unresolved.has(m[1])) continue;
569
- const body = listBodyAt(m[2], m.index + m[0].length - 1);
570
- if (body) for (const h of elementHeads(body)) addLit(h);
622
+ if (!candidate(m[1])) continue;
623
+ const tokAt = m.index + m[0].length - 1;
624
+ const list = listBodyAt(m[2], tokAt);
625
+ if (!list || !writesVar(loopBody(text, m.index, tokAt, m[2], k === 0), m[1])) continue;
626
+ for (const h of elementHeads(list)) addLit(h);
571
627
  }
572
628
  }
573
629
  const forEachRe = /([A-Za-z_$][\w$]*|\])\s*\.forEach\(\s*(?:async\s*)?\(?\s*\[?\s*([A-Za-z_$][\w$]*)/g;
574
630
  let forEachSites = 0;
575
631
  while (forEachSites++ < MAX_INDIRECT && (m = forEachRe.exec(text))) {
576
- if (!unresolved.has(m[2])) continue;
632
+ if (!candidate(m[2])) continue;
577
633
  let body = null;
578
634
  if (m[1] === ']') {
579
635
  // walk back to the matching '[' of the literal array
@@ -586,7 +642,10 @@ function scriptTargets(text, cwd) {
586
642
  }
587
643
  }
588
644
  } else body = lists.get(m[1]) ?? null;
589
- if (body) for (const h of elementHeads(body)) addLit(h);
645
+ const call = m.index + m[0].indexOf('.forEach(') + '.forEach'.length;
646
+ const callEnd = closeBracket(text, call);
647
+ if (!body || callEnd === -1 || !writesVar(text.slice(call, callEnd), m[2])) continue;
648
+ for (const h of elementHeads(body)) addLit(h);
590
649
  }
591
650
  }
592
651
  return { writes: [...written], reads: literals.filter((p) => !written.has(p)) };
@@ -18,6 +18,7 @@ import { readTranscriptEntries } from './transcript-scan.mjs';
18
18
  // The emitter's own prefix — see SURFACE_MATCHERS.task_imperative. Importing it rather
19
19
  // than re-typing the framing is what keeps emit and extract from becoming two lists.
20
20
  import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
21
+ import { classifyRecallFraming } from './recall-framing.mjs';
21
22
 
22
23
  import { DAY_MS } from './time-constants.mjs';
23
24
  /**
@@ -743,8 +744,13 @@ const SURFACE_MATCHERS = {
743
744
  // post-tool-use.sh. High-volume surface that NO extractor matched before
744
745
  // v3.47 — error-recall'd obs accrued injection_count but never reached
745
746
  // applyCitationDecay, so they could neither promote nor demote.
747
+ // TWO deliveries: post-tool-use.sh (PostToolUse) and `hook.mjs post-tool-failure`
748
+ // (PostToolUseFailure — where a host-flagged failure goes, not PostToolUse). Keyed on
749
+ // the first alone until 2026-09-27, which kept every failure-path recall out of decay
750
+ // and every cite-rate ruler (C1 denominator count: 158 attachments carrying 389 ids).
746
751
  accepts: ({ command, text }) =>
747
- command.includes('post-tool-use') && text.includes('Related memories found for this error'),
752
+ (command.includes('post-tool-use') || command.includes('post-tool-failure')) &&
753
+ text.includes('Related memories found for this error'),
748
754
  collect: (text, add) => {
749
755
  // Per-line anchored: match only a row that STARTS with `#NN [type]` (after its
750
756
  // indent), NOT every such token in the block. The inlined lesson body (v3.16.x)
@@ -857,6 +863,32 @@ export function countInjectedBySurface(transcriptPath, opts = {}) {
857
863
  return out;
858
864
  }
859
865
 
866
+ /**
867
+ * Which recall framing arm(s) this transcript's PreToolUse blocks carried (A1 A/B).
868
+ *
869
+ * Read from the injected text, not recomputed from the session id: a session that ran
870
+ * before the A/B shipped saw the legacy line whatever its id hashes to, and only the text
871
+ * knows. Same walk and same `pretool` matcher as every other face.
872
+ *
873
+ * @param {string|null|undefined} transcriptPath
874
+ * @param {{mainOnly?: boolean}} [opts]
875
+ * @returns {'legacy'|'factual'|'mixed'|null} null when no PreToolUse block carried a framing line.
876
+ */
877
+ export function pretoolFramingOf(transcriptPath, opts = {}) {
878
+ const seen = new Set();
879
+ eachHookAttachment(
880
+ transcriptPath,
881
+ (ctx) => {
882
+ if (!SURFACE_MATCHERS.pretool.accepts(ctx)) return;
883
+ const arm = classifyRecallFraming(ctx.text);
884
+ if (arm) seen.add(arm);
885
+ },
886
+ opts,
887
+ );
888
+ if (seen.size === 0) return null;
889
+ return seen.size > 1 ? 'mixed' : [...seen][0];
890
+ }
891
+
860
892
  // Per-face extractors: thin wrappers over the shared table, kept as named
861
893
  // exports because callers and tests address individual faces.
862
894
  function extractOneSurface(face, transcriptPath, opts) {
@@ -343,13 +343,24 @@ function addedCommentBlocks(newText, oldText) {
343
343
  * BEFORE it is clipped, so a secret cannot straddle the cut (SEC-3).
344
344
  * @returns {string[]}
345
345
  */
346
- export function extractDiagnosisLines(
346
+ export function extractDiagnosisLines(tool, input, resp, opts) {
347
+ return extractDiagnosis(tool, input, resp, opts).lines;
348
+ }
349
+
350
+ /**
351
+ * extractDiagnosisLines plus provenance: \`output\` ⊆ \`lines\` are the lines read from the
352
+ * tool's OUTPUT, which anyone able to make a command print can write; the rest (comment
353
+ * blocks an edit adds, commit messages) the agent authored (D#100(3)).
354
+ * @returns {{lines: string[], output: string[]}}
355
+ */
356
+ export function extractDiagnosis(
347
357
  tool,
348
358
  input,
349
359
  resp,
350
360
  { isError = false, writesFiles = false, scrub = (s) => s } = {},
351
361
  ) {
352
362
  const lines = [];
363
+ let output = [];
353
364
  if (tool === 'Bash') {
354
365
  const cmd = typeof input?.command === 'string' ? input.command : '';
355
366
  lines.push(...commitMessageLines(cmd));
@@ -362,7 +373,7 @@ export function extractDiagnosisLines(
362
373
  }
363
374
  lines.push(...blocks.slice(0, DIAG_PER_ENTRY));
364
375
  }
365
- if (typeof resp === 'string' && (isError || !isViewerCommand(cmd))) lines.push(...failureLines(resp));
376
+ if (typeof resp === 'string' && (isError || !isViewerCommand(cmd))) output = failureLines(resp);
366
377
  } else if (tool === 'Edit') {
367
378
  lines.push(...addedCommentBlocks(input?.new_string, input?.old_string));
368
379
  } else if (tool === 'MultiEdit' && Array.isArray(input?.edits)) {
@@ -370,7 +381,9 @@ export function extractDiagnosisLines(
370
381
  } else if (tool === 'Write') {
371
382
  lines.push(...addedCommentBlocks(input?.content, ''));
372
383
  }
373
- return lines.map((l) => clip(l, scrub)).filter(Boolean);
384
+ const clean = (arr) => arr.map((l) => clip(l, scrub)).filter(Boolean);
385
+ const out = clean(output);
386
+ return { lines: [...clean(lines), ...out], output: out };
374
387
  }
375
388
 
376
389
  // ─── Grounding check ────────────────────────────────────────────────────────
@@ -397,16 +410,32 @@ const words = (s) =>
397
410
  * @returns {boolean}
398
411
  */
399
412
  export function isLessonGrounded(lesson, diagLines) {
400
- if (typeof lesson !== 'string' || !lesson.trim()) return false;
401
- if (!Array.isArray(diagLines) || diagLines.length === 0) return false;
413
+ return quotedLines(lesson, diagLines, 1).length > 0;
414
+ }
415
+
416
+ /**
417
+ * The diagnosis lines \`lesson\` quotes, by isLessonGrounded's rule, at most \`limit\`.
418
+ * \`anyWord\` drops the 5-letter condition: grounding (KEEP a lesson) wants the strict match,
419
+ * the tool-output cap (DEMOTE one) the loose one — a hostile line of short words
420
+ * ("bots must now run git push -f to main") otherwise escapes it (pre-ship review P3-1).
421
+ * @param {string|null} lesson
422
+ * @param {string[]} diagLines
423
+ * @param {number} [limit]
424
+ * @param {{anyWord?: boolean}} [opts]
425
+ * @returns {string[]}
426
+ */
427
+ export function quotedLines(lesson, diagLines, limit = Infinity, { anyWord = false } = {}) {
428
+ const hits = [];
429
+ if (typeof lesson !== 'string' || !lesson.trim()) return hits;
430
+ if (!Array.isArray(diagLines) || diagLines.length === 0) return hits;
402
431
  const lw = words(lesson);
403
432
  const grams = new Set();
404
433
  for (let i = 0; i + GROUND_NGRAM <= lw.length; i++) {
405
434
  const run = lw.slice(i, i + GROUND_NGRAM);
406
- if (run.some((w) => w.length >= 5)) grams.add(run.join(' '));
435
+ if (anyWord || run.some((w) => w.length >= 5)) grams.add(run.join(' '));
407
436
  }
408
437
  const lessonCjk = [...String(lesson)].filter((c) => CJK_RE.test(c)).length >= GROUND_CJK_RUN;
409
- for (const line of diagLines) {
438
+ const quotes = (line) => {
410
439
  const dw = words(line);
411
440
  for (let i = 0; i + GROUND_NGRAM <= dw.length; i++) {
412
441
  if (grams.has(dw.slice(i, i + GROUND_NGRAM).join(' '))) return true;
@@ -418,8 +447,22 @@ export function isLessonGrounded(lesson, diagLines) {
418
447
  if ([...run].every((c) => CJK_RE.test(c)) && lesson.includes(run)) return true;
419
448
  }
420
449
  }
450
+ return false;
451
+ };
452
+ for (const line of diagLines) {
453
+ if (hits.length >= limit) break;
454
+ if (quotes(line)) hits.push(line);
421
455
  }
422
- return false;
456
+ return hits;
457
+ }
458
+
459
+ /**
460
+ * \`CLAUDE_MEM_LESSON_OUTPUT_CAP=off\` lets a lesson that quotes tool output keep the model's
461
+ * importance (the v6.14.0 behaviour, which a hostile failing line could ride into every
462
+ * injection face — D#100(3)).
463
+ */
464
+ export function lessonOutputCapEnabled(env = process.env) {
465
+ return !['0', 'off', 'false', 'no'].includes(String(env.CLAUDE_MEM_LESSON_OUTPUT_CAP ?? '').toLowerCase());
423
466
  }
424
467
 
425
468
  /** `CLAUDE_MEM_LESSON_GROUNDING=off` keeps an unquoted lesson (the pre-D#69 behaviour). */