claude-mem-lite 6.13.6 → 6.14.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,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.13.6",
12
+ "version": "6.14.0",
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.13.6",
3
+ "version": "6.14.0",
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
@@ -138,7 +138,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
138
138
  |----------|--------|-------|
139
139
  | **Linux** | Supported | Primary development and testing platform; the whole CI matrix runs here |
140
140
  | **macOS** | Supported | Fully compatible (Intel and Apple Silicon) |
141
- | **Windows** | Installs, not CI-covered | The MCP server, the CLI and the `node` hooks work (`better-sqlite3` ships `win32-x64` and `win32-arm64` prebuilds, so nothing is compiled). **Three hook commands run under `bash`** — `setup.sh`, `post-tool-use.sh`, `pre-agent-inject.sh` — and need Git for Windows or WSL on `PATH`; `claude-mem-lite doctor` reports it when `bash` cannot be found. No GitHub Actions runner exercises Windows, so this rests on user reports ([#28](https://github.com/sdsrss/claude-mem-lite/issues/28)), not on a green pipeline |
141
+ | **Windows** | Installs, not CI-covered | The MCP server, the CLI and the `node` hooks work (`better-sqlite3` ships `win32-x64` and `win32-arm64` prebuilds, so nothing is compiled). **Four hook commands run under `bash`** — `setup.sh`, `post-tool-use.sh`, `pre-agent-inject.sh`, `pre-tool-recall-bash.sh` — and need Git for Windows or WSL on `PATH`; `claude-mem-lite doctor` reports it when `bash` cannot be found. No GitHub Actions runner exercises Windows, so this rests on user reports ([#28](https://github.com/sdsrss/claude-mem-lite/issues/28)), not on a green pipeline |
142
142
  | **WSL2** | Untested | Linux under the hood, so it should behave as the Linux row; nobody has reported either way |
143
143
 
144
144
  From v5.1.0 through v6.1.0, `package.json` declared `os: ["darwin", "linux"]`. That is an npm *install*
@@ -152,7 +152,7 @@ is still outside it gets a message naming both sides of the mismatch instead of
152
152
  - **Node.js** >= 22
153
153
  - **Claude Code** CLI installed and configured (`claude` command available)
154
154
  - **SQLite3** support (provided by `better-sqlite3` 13, which ships prebuilt binaries for 8 platforms — no compiler needed on any of them; a platform it has no prebuild for falls back to building from source)
155
- - **Platform**: Linux or macOS; Windows installs and runs but is not CI-covered and needs Git Bash or WSL for three hooks (see [Platform Support](#platform-support))
155
+ - **Platform**: Linux or macOS; Windows installs and runs but is not CI-covered and needs Git Bash or WSL for four hooks (see [Platform Support](#platform-support))
156
156
 
157
157
  ## Installation
158
158
 
@@ -237,6 +237,29 @@ 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.14.0
241
+
242
+ **Five defaults change; three have an off switch.** No schema change and no migration: an
243
+ older build still opens the database, so reverting everything is pinning
244
+ `claude-mem-lite@6.13.6`.
245
+
246
+ - **File recall now also fires before Bash commands that view or write a file** (`cat`,
247
+ `sed -n`, `head`; `sed -i`, `cat > f`, a python patch) — on recent models most reads and
248
+ edits go through Bash, and the recall face with the highest measured cite rate had almost
249
+ stopped firing. A **fourth hook command runs under `bash`**, `pre-tool-recall-bash.sh`; on
250
+ Windows it needs Git Bash or WSL like the other three. Off: `CLAUDE_MEM_BASH_RECALL=off`.
251
+ - **Error recall stays quiet on a failure you meant to cause** (running a test file you just
252
+ wrote or edited) and on commands that only print data and exit 0. No switch; pin 6.13.6 to
253
+ revert. `error_recall` counts in `citation-stats` drop from here on — do not compare them
254
+ across this version.
255
+ - **Auto-captured lessons must quote what happened.** The episode summarizer ignores
256
+ mutation-test probes, the agent's own failing inline scripts and subagent calls that do not
257
+ edit the project, and a lesson that quotes nothing from its window is dropped (the event is
258
+ kept, at importance 1). Off: `CLAUDE_MEM_EPISODE_INPUT_FILTER=off` /
259
+ `CLAUDE_MEM_LESSON_GROUNDING=off`.
260
+ - **The `[mem] episode flushed: N entries` line is no longer injected.** The hints that
261
+ followed it are unchanged.
262
+
240
263
  ## Upgrading to 6.13.0
241
264
 
242
265
  **One default changes: SessionStart no longer injects `### Key Events`.** That section listed
@@ -916,6 +939,9 @@ claude-mem-lite.
916
939
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
917
940
  | `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
941
  | `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)_ |
942
+ | `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
+ | `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
+ | `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)_ |
919
945
  | `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)_ |
920
946
  | `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)_ |
921
947
 
package/README.zh-CN.md CHANGED
@@ -103,7 +103,7 @@
103
103
  |------|------|------|
104
104
  | **Linux** | 支持 | 主要开发和测试平台;整个 CI 矩阵都跑在这里 |
105
105
  | **macOS** | 支持 | 完全兼容(Intel 和 Apple Silicon) |
106
- | **Windows** | 可安装,但无 CI 覆盖 | MCP server、CLI 和 `node` 类 hook 均可用(`better-sqlite3` 自带 `win32-x64` / `win32-arm64` 预编译产物,无需编译)。**有三个 hook 命令走 `bash`** —— `setup.sh`、`post-tool-use.sh`、`pre-agent-inject.sh` —— 需要 PATH 上有 Git for Windows 或 WSL;`bash` 找不到时 `claude-mem-lite doctor` 会报出来。GitHub Actions 没有 Windows runner,所以这一行依据的是用户报告([#28](https://github.com/sdsrss/claude-mem-lite/issues/28))而不是绿色流水线 |
106
+ | **Windows** | 可安装,但无 CI 覆盖 | MCP server、CLI 和 `node` 类 hook 均可用(`better-sqlite3` 自带 `win32-x64` / `win32-arm64` 预编译产物,无需编译)。**有四个 hook 命令走 `bash`** —— `setup.sh`、`post-tool-use.sh`、`pre-agent-inject.sh`、`pre-tool-recall-bash.sh` —— 需要 PATH 上有 Git for Windows 或 WSL;`bash` 找不到时 `claude-mem-lite doctor` 会报出来。GitHub Actions 没有 Windows runner,所以这一行依据的是用户报告([#28](https://github.com/sdsrss/claude-mem-lite/issues/28))而不是绿色流水线 |
107
107
  | **WSL2** | 未测试 | 底层就是 Linux,预期与 Linux 行一致;但无人报告过实际结果 |
108
108
 
109
109
  v5.1.0 到 v6.1.0 之间,`package.json` 声明的是 `os: ["darwin", "linux"]`。那是 npm 的**安装**门禁,不是运行时检查:
@@ -116,7 +116,7 @@ v5.1.0 到 v6.1.0 之间,`package.json` 声明的是 `os: ["darwin", "linux"]`
116
116
  - **Node.js** >= 22(v4.0.0 起:better-sqlite3 13 要求 >=22,Node 20 已于 2026-04 EOL;`package.json` 的 `engines` 是唯一事实来源)
117
117
  - **Claude Code** CLI 已安装并配置(`claude` 命令可用)
118
118
  - **SQLite3** 支持(由 `better-sqlite3` 13 提供,它自带 8 个平台的预编译产物,这些平台上都不需要编译器;没有对应预编译产物的平台才会回退到源码编译)
119
- - **平台**:Linux 或 macOS;Windows 可安装运行,但无 CI 覆盖,且三个 hook 需要 Git Bash 或 WSL(参见[平台支持](#平台支持))
119
+ - **平台**:Linux 或 macOS;Windows 可安装运行,但无 CI 覆盖,且四个 hook 需要 Git Bash 或 WSL(参见[平台支持](#平台支持))
120
120
 
121
121
  ## 安装
122
122
 
@@ -199,6 +199,23 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.14.0
203
+
204
+ **五个默认行为变化,其中三个有关闭开关。** 没有 schema 变更、没有迁移:旧版本仍能打开数据库,
205
+ 全部回退就是固定到 `claude-mem-lite@6.13.6`。
206
+
207
+ - **查看或写入文件的 Bash 命令执行前,也会做文件召回**(`cat`、`sed -n`、`head`;`sed -i`、
208
+ `cat > f`、python 补丁)。新模型的读写大多走 Bash,实测引用率最高的召回面几乎不再触发。
209
+ **第四个 hook 命令走 `bash`**:`pre-tool-recall-bash.sh`;在 Windows 上与另外三个一样需要
210
+ Git Bash 或 WSL。关闭:`CLAUDE_MEM_BASH_RECALL=off`。
211
+ - **error recall 不再回应你故意制造的失败**(刚写好或刚改过的测试文件跑红),也不回应只打印数据、
212
+ 退出码为 0 的命令。没有开关,回退请固定 6.13.6。从这个版本起 `citation-stats` 里的
213
+ `error_recall` 计数会下降,不要跨版本直接比较。
214
+ - **自动捕获的教训必须引用原文。** episode 摘要器忽略变异测试探针、agent 自己失败的内联脚本,以及
215
+ 不修改项目的子代理调用;不引用窗口内任何原文的教训会被丢弃(event 保留,importance 降为 1)。
216
+ 关闭:`CLAUDE_MEM_EPISODE_INPUT_FILTER=off` / `CLAUDE_MEM_LESSON_GROUNDING=off`。
217
+ - **不再注入 `[mem] episode flushed: N entries` 这一行。** 它后面的提示不变。
218
+
202
219
  ## 升级到 6.13.0
203
220
 
204
221
  **只有一个默认行为变化:SessionStart 不再注入 `### Key Events`。** 这一节在每个会话开头列出
@@ -702,6 +719,9 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
702
719
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
703
720
  | `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
721
  | `CLAUDE_MEM_SESSION_EVENTS` | 设为 `1`/`on` 时恢复 SessionStart 的 `### Key Events` 一节(`events` 表中最近的高重要度条目)。**v6.13.0 起默认关闭**:对 30 条 event 的核验只有 2 条属实、16 条错误。UserPromptSubmit 的 events 块与 PreToolUse 召回按查询匹配,保持开启;`mem_search` 仍可检索全部 event。 | _(关闭)_ |
722
+ | `CLAUDE_MEM_EPISODE_INPUT_FILTER` | 决定 episode 摘要器能从哪些输入里学。子代理的工具调用不进入 episode 缓冲(修改项目内文件的除外);变异探针(改文件 → 跑红 → 还原)和 agent 自己失败的内联脚本(补丁的 `anchor not found`)在保存或摘要前被剔除——在 30 条已核验 event 上重放,这一项直接去掉 16 条错误中的 3 条;配合 `CLAUDE_MEM_LESSON_GROUNDING`,16 条错误教训一条都不会被注入。设为 `off` 恢复未过滤的输入。 | _(开启)_ |
723
+ | `CLAUDE_MEM_BASH_RECALL` | 在查看(`cat`、`sed -n`、`head`…)或写入(`sed -i`、`cat > f`、python 补丁…)文件的 Bash 命令执行前做文件召回,与 Read / Edit 召回相同。bash 预过滤让其他命令不启动 Node。设为 `off` 只关闭这一路。 | _(开启)_ |
724
+ | `CLAUDE_MEM_LESSON_GROUNDING` | 自动捕获的 event 只有在教训引用了本窗口自己的诊断文字(失败输出行、编辑新增的注释或提交信息)时才保留教训;否则保留这一行但去掉教训,importance 降为 1,低于所有注入面的门槛。设为 `off` 保留未引用原文的教训。 | _(开启)_ |
705
725
  | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止每次 SessionStart 在**所有**项目自动写入 `CLAUDE.md` 托管块。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
706
726
  | `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`)的项目才会出现。 | _(禁用)_ |
707
727
 
package/bash-utils.mjs CHANGED
@@ -9,6 +9,8 @@ import { basename } from 'path';
9
9
  // import this file. Cold-start scripts are unaffected — scripts/pre-tool-recall.js
10
10
  // deliberately imports nothing from the utils.mjs barrel that re-exports this.
11
11
  import { toolEditPath } from './lib/file-edge-match.mjs';
12
+ import { bashFileTargets, isTransientPath, isScratchCommandPath } from './lib/bash-file-targets.mjs';
13
+ export { isTransientPath };
12
14
 
13
15
  // Read/search commands whose output legitimately contains "error"-like keywords without
14
16
  // being a failure. Matched against the PRIMARY command (see isReadOnlyCommand).
@@ -522,11 +524,89 @@ function commandKind(cmd, depth = 0) {
522
524
  return sawRead ? 'read' : 'neutral';
523
525
  }
524
526
 
527
+ // ─── Data printers and writers (error-recall N2) ─────────────────────────────
528
+
529
+ // Interpreters whose INLINE program (`-e`/`-c`/a heredoc on stdin) is the agent's own
530
+ // throwaway code. Run by a script FILE they are a program like any other.
531
+ const INLINE_INTERPRETER_RE = /^(?:node|nodejs|bun|python(?:\d+(?:\.\d+)?)?)$/;
532
+
533
+ // A redirection operator token (`>`, `2>>`, `&>`, `<`, `<<-`), optionally glued to its target.
534
+ const REDIRECT_TOKEN_RE = /^(?:\d*|&)(?:>>?\|?|<<?-?)(.*)$/;
535
+
536
+ /**
537
+ * True when one simple command runs an INLINE program or prints a CI log — the shapes
538
+ * whose output at exit 0 is data (a transcript line, a log excerpt, a script's own
539
+ * `assert`), not a failure of anything the corpus could explain.
540
+ */
541
+ function isPrinterElement(text) {
542
+ const toks = shellWords(text);
543
+ let i = 0;
544
+ while (i < toks.length && (/^\w+=/.test(toks[i]) || CMD_WRAPPERS.has(toks[i]))) i++;
545
+ const prog = toks[i];
546
+ if (!prog) return false;
547
+ const args = toks.slice(i + 1);
548
+ // `gh run view … --log[-failed]`: a CI log is somebody else's failure, reprinted. `--json`
549
+ // is gh's structured READ (list/view/status only), typically feeding a run id to the former.
550
+ if (prog === 'gh')
551
+ return args.some((a) => a === '--log' || a === '--log-failed' || /^--json(?:=|$)/.test(a));
552
+ if (!INLINE_INTERPRETER_RE.test(prog)) return false;
553
+ const py = prog.startsWith('python');
554
+ for (let k = 0; k < args.length; k++) {
555
+ const a = args[k];
556
+ if (py ? a === '-c' || a === '-' : /^-(?:e|p|pe|ep)$|^--(?:eval|print)(?:=|$)/.test(a)) return true;
557
+ const redirect = REDIRECT_TOKEN_RE.exec(a);
558
+ if (redirect) {
559
+ if (!redirect[1]) k++; // `<< EOF` / `> f`: the next word is the operand, not a script
560
+ continue;
561
+ }
562
+ // A positional is a script file (or `-m`'s module): a program, not an inline print.
563
+ if (!a.startsWith('-') || (py && a === '-m')) return false;
564
+ }
565
+ // No positional at all: the program comes from stdin, i.e. a heredoc.
566
+ return /<</.test(text);
567
+ }
568
+
569
+ /** 'print' | 'read' | 'neutral' | 'other' — commandKind with printers admitted. */
570
+ function printKind(cmd, depth = 0) {
571
+ if (depth > MAX_SUBST_DEPTH) return 'other';
572
+ const parsed = splitStatements(cmd);
573
+ if (!parsed) return 'other';
574
+ let sawPrint = false;
575
+ for (const body of parsed.subs) {
576
+ const kind = printKind(body, depth + 1);
577
+ if (kind === 'other') return 'other';
578
+ if (kind === 'print') sawPrint = true;
579
+ }
580
+ for (const pipeline of parsed.statements) {
581
+ for (const element of pipeline) {
582
+ if (isPrinterElement(element)) {
583
+ sawPrint = true;
584
+ continue;
585
+ }
586
+ if (classifySimpleCommand(element) === 'other') return 'other';
587
+ }
588
+ }
589
+ return sawPrint ? 'print' : 'read';
590
+ }
591
+
592
+ /**
593
+ * True when the command's only programs are inline scripts (`node -e/-p`, `python3 -c`,
594
+ * `python3 - <<EOF`, a heredoc-fed interpreter) or `gh … --log[-failed]`, plus reads
595
+ * (isReadOnlyCommand's verbs) and neutral set-up. Parsed with the same statement/pipeline
596
+ * splitter, so `node -e '…'; npm test` and `python3 - <<PY … PY` followed by a vitest run
597
+ * are NOT printers: the real run is judged on its own.
598
+ *
599
+ * Deliberately a SEPARATE predicate from isReadOnlyCommand, not a new verb in it: that one
600
+ * also decides `isError` for the episode narrative and the bugfix save-nudge, and an inline
601
+ * script that crashes is a real error there. Only error-recall's injection is narrowed.
602
+ */
603
+ export function isDataPrintingCommand(cmd) {
604
+ if (typeof cmd !== 'string' || !cmd) return false;
605
+ return printKind(stripNonCommands(cmd)) === 'print';
606
+ }
607
+
525
608
  // Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
526
609
  // uniformly to both command-parsed paths and direct file_path/path/filePath fields.
527
- function isExcludedPath(p) {
528
- return p.startsWith('/dev/') || p.startsWith('/proc/') || p.startsWith('/tmp/');
529
- }
530
610
 
531
611
  /**
532
612
  * Detect significance signals in a Bash command and its response.
@@ -887,16 +967,24 @@ export function planErrorRecall(cmd, response) {
887
967
  // ─── File Paths ──────────────────────────────────────────────────────────────
888
968
 
889
969
  /**
890
- * Extract file paths from tool input (file_path, path, filePath, or command args).
891
- * Deduplicates and excludes /dev/, /proc/, and /tmp/ paths.
970
+ * Files a tool call touched, split so callers can tell an edit from a read.
971
+ * `files`: every file edge (direct path fields + all Bash targets);
972
+ * `writes`: the subset a Bash command WROTE (`sed -i`, `cat > f`, a python patch, …) —
973
+ * the Bash counterpart of an Edit/Write `file_path`, consumed via `entryEditedFiles`.
892
974
  * @param {object} input Tool input object
893
- * @returns {string[]} Unique array of file paths
975
+ * @param {{cwd?: string|null, projectDir?: string|null}} [opts] the hook's `cwd`, to resolve
976
+ * relative Bash paths; the project root, whose files count even when it lives under /tmp
977
+ * @returns {{files: string[], writes: string[]}}
894
978
  */
895
- export function extractFilePaths(input) {
979
+ export function extractFileTargets(input, opts = {}) {
896
980
  const paths = [];
897
- // Direct fields (Edit/Write file_path) are kept unconditionally — an explicit edit to a
981
+ const writes = [];
982
+ if (!input || typeof input !== 'object') return { files: [], writes: [] };
983
+ // Direct fields (Edit/Write file_path) are kept even under /tmp — an explicit edit to a
898
984
  // /tmp path is real work the user chose to make, unlike a /tmp path that merely appears as
899
985
  // a transient argument inside a Bash command (excluded as noise in the command branch below).
986
+ // Session-scoped paths (isTransientPath) are the exception: they are the agent's own
987
+ // scratch, not the user's work.
900
988
  //
901
989
  // `toolEditPath`, not a fourth hand-spelling of the same rule: this function knew
902
990
  // file_path/path/filePath and not `notebook_path`, while hooks.json matches PostToolUse
@@ -905,27 +993,39 @@ export function extractFilePaths(input) {
905
993
  // files, so the observation built from it got no observation_files edge and no file-keyed
906
994
  // recall could reach it. Same root cause as R12 B-2; the fourth site, and the one its
907
995
  // own follow-up note did not name.
908
- const editedPath = toolEditPath(input);
909
- if (editedPath) paths.push(editedPath);
910
- if (input.path) paths.push(input.path);
911
- if (input.filePath) paths.push(input.filePath);
912
- if (input.command) {
913
- // Match absolute paths; extension optional to support Makefile, Dockerfile etc.
914
- const match = input.command.match(/(?:^|\s)(\/[\w./-]+\w)/g);
915
- if (match) {
916
- for (const m of match) {
917
- const p = m.trim();
918
- if (
919
- !isExcludedPath(p) &&
920
- // Skip single-component paths like /exit, /clear — likely slash commands, not files
921
- (p.indexOf('/', 1) !== -1 || /\.\w+$/.test(p))
922
- ) {
923
- paths.push(p);
924
- }
925
- }
996
+ for (const p of [toolEditPath(input), input.path, input.filePath]) {
997
+ if (typeof p === 'string' && p && !isTransientPath(p)) paths.push(p);
998
+ }
999
+ if (typeof input.command === 'string' && input.command) {
1000
+ // Shell-aware: cwd prefixes, relative and quoted paths, write-verb targets and
1001
+ // interpreter-script literals (lib/bash-file-targets.mjs). The `cd` target itself is
1002
+ // never an edge — it made every `cd <repo> && …` command look like it touched the
1003
+ // repo root and nothing else.
1004
+ const keep = (p) =>
1005
+ !isScratchCommandPath(p, opts.projectDir) &&
1006
+ !isTransientPath(p) &&
1007
+ // Skip single-component paths like /exit, /clear — likely slash commands, not files
1008
+ (p.indexOf('/', 1) !== -1 || /\.\w+$/.test(p));
1009
+ const t = bashFileTargets(input.command, { cwd: opts.cwd || null });
1010
+ for (const p of t.writes) {
1011
+ if (!keep(p)) continue;
1012
+ writes.push(p);
1013
+ paths.push(p);
926
1014
  }
1015
+ for (const p of [...t.reads, ...t.mentions]) if (keep(p)) paths.push(p);
927
1016
  }
928
- return [...new Set(paths)];
1017
+ return { files: [...new Set(paths)], writes: [...new Set(writes)] };
1018
+ }
1019
+
1020
+ /**
1021
+ * Extract file paths from tool input (file_path, path, filePath, or command args).
1022
+ * Deduplicates and excludes /dev/, /proc/, /tmp/ command paths and session-scoped paths.
1023
+ * @param {object} input Tool input object
1024
+ * @param {{cwd?: string|null}} [opts] the hook's `cwd`, to resolve relative Bash paths
1025
+ * @returns {string[]} Unique array of file paths
1026
+ */
1027
+ export function extractFilePaths(input, opts = {}) {
1028
+ return extractFileTargets(input, opts).files;
929
1029
  }
930
1030
 
931
1031
  // ─── Episode Logic ───────────────────────────────────────────────────────────
package/cli/doctor.mjs CHANGED
@@ -34,7 +34,7 @@ export async function cmdDoctor(db, args) {
34
34
  WHERE s.project = ?
35
35
  AND p.prompt_text IS NOT NULL
36
36
  AND length(p.prompt_text) >= 15
37
- ORDER BY p.created_at_epoch DESC
37
+ ORDER BY p.created_at_epoch DESC, p.id DESC
38
38
  LIMIT ?
39
39
  `,
40
40
  )
package/hook-context.mjs CHANGED
@@ -850,11 +850,11 @@ export function buildSessionContextLines(
850
850
  `
851
851
  SELECT id, title, priority,
852
852
  ROW_NUMBER() OVER (
853
- ORDER BY priority DESC, created_at_epoch ASC
853
+ ORDER BY priority DESC, created_at_epoch ASC, id ASC
854
854
  ) AS ordinal
855
855
  FROM deferred_work
856
856
  WHERE project = ? AND status = 'open'
857
- ORDER BY priority DESC, created_at_epoch ASC
857
+ ORDER BY priority DESC, created_at_epoch ASC, id ASC
858
858
  LIMIT 5
859
859
  `,
860
860
  )
package/hook-episode.mjs CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  statSync,
15
15
  constants as fsConstants,
16
16
  } from 'fs';
17
- import { inferProject, EDIT_TOOLS } from './utils.mjs';
17
+ import { inferProject, isEditEntry } from './utils.mjs';
18
18
  import { RUNTIME_DIR } from './hook-shared.mjs';
19
19
 
20
20
  /**
@@ -454,7 +454,7 @@ export function explainSignificance(episode) {
454
454
  const base = { readCount, grepCount, grepDecisive: false };
455
455
 
456
456
  // 1. File edits → always significant (code changes matter)
457
- if (entries.some((e) => EDIT_TOOLS.has(e.tool))) return { ...base, significant: true, rule: 1 };
457
+ if (entries.some(isEditEntry)) return { ...base, significant: true, rule: 1 };
458
458
 
459
459
  // 2. Test/build errors → significant (actionable failures)
460
460
  // Plain bash errors without edits are noise (e.g. typos, exploration errors)
package/hook-handoff.mjs CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  isSpecificTerm,
11
11
  scrubSecrets,
12
12
  LOW_SIGNAL_TITLE,
13
- EDIT_TOOLS,
13
+ isEditEntry,
14
14
  isMetaTriggerPrompt,
15
15
  notLowSignalTitleClause,
16
16
  safeText,
@@ -262,7 +262,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
262
262
  if (episodeSnapshot?.entries) {
263
263
  const seenDescs = new Set();
264
264
  const pendingDescs = episodeSnapshot.entries
265
- .filter((e) => e.isError || EDIT_TOOLS.has(e.tool))
265
+ .filter((e) => e.isError || isEditEntry(e))
266
266
  .map((e) => e.desc)
267
267
  .filter((d) => {
268
268
  if (seenDescs.has(d)) return false;
package/hook-llm.mjs CHANGED
@@ -14,7 +14,8 @@ import {
14
14
  computeMinHash,
15
15
  estimateJaccardFromMinHash,
16
16
  cjkBigrams,
17
- EDIT_TOOLS,
17
+ isEditEntry,
18
+ entryEditedFiles,
18
19
  LOW_SIGNAL_TITLE,
19
20
  debugCatch,
20
21
  debugLog,
@@ -55,6 +56,7 @@ import { DAY_MS } from './lib/time-constants.mjs';
55
56
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
56
57
  import { recoverChildrenOf } from './lib/maintain-core.mjs';
57
58
  import { MEMORY_INPUT_GUARD } from './lib/memory-input-guard.mjs';
59
+ import { isLessonGrounded, lessonGroundingEnabled } from './lib/episode-input-filter.mjs';
58
60
 
59
61
  /**
60
62
  * Retract a pre-saved observation this worker created moments ago, after the Haiku
@@ -97,6 +99,31 @@ export function retractPreSavedObs(db, obsId, where) {
97
99
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
98
100
  const EVENT_TYPE_SET = new Set(EVENT_TYPES);
99
101
 
102
+ // ─── The window's own diagnosis (D#69) ──────────────────────────────────────
103
+ //
104
+ // Episode entries carry `diag`: failing output lines, comment blocks an edit added, a
105
+ // commit message — captured at PostToolUse by lib/episode-input-filter.mjs. The prompt
106
+ // shows them verbatim and asks the lesson to quote one; isLessonGrounded then CHECKS it.
107
+ const DIAGNOSIS_MAX_LINES = 12;
108
+
109
+ /** Distinct diagnosis lines of an episode, in order, capped. Exported for tests. */
110
+ export function episodeDiagnosis(episode) {
111
+ const out = [];
112
+ for (const e of Array.isArray(episode?.entries) ? episode.entries : []) {
113
+ for (const l of Array.isArray(e?.diag) ? e.diag : []) {
114
+ if (typeof l === 'string' && l && !out.includes(l)) out.push(l);
115
+ if (out.length >= DIAGNOSIS_MAX_LINES) return out;
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function diagnosisBlock(diag) {
122
+ return diag.length
123
+ ? `DIAGNOSIS (verbatim from this window — the only text a lesson may rest on):\n${diag.map((l, i) => `D${i + 1}. ${l}`).join('\n')}`
124
+ : 'DIAGNOSIS: (none — no failing output, added comment or commit message in this window; lesson_learned must be null)';
125
+ }
126
+
100
127
  // ─── Memory-input injection guard (cso F#4 follow-up, EverAlgo-validated) ────
101
128
  //
102
129
  // Defense-in-depth against memory-poisoning: episode/summary prompts ingest
@@ -630,7 +657,7 @@ function linkRelatedObservations(db, savedId, obs, episode) {
630
657
  export function buildDegradedTitle(episode) {
631
658
  const files = (episode.files || []).filter(Boolean);
632
659
  const hasError = episode.entries.some((e) => e.isError);
633
- const hasEdit = episode.entries.some((e) => EDIT_TOOLS.has(e.tool));
660
+ const hasEdit = episode.entries.some(isEditEntry);
634
661
 
635
662
  // Extract a short error hint from the first error entry's desc
636
663
  let errorHint = '';
@@ -708,7 +735,7 @@ export function saveEpisodeImmediate(episode, externalDb, scope = 'saveEpisodeIm
708
735
  */
709
736
  export function buildImmediateObservation(episode) {
710
737
  const hasError = episode.entries.some((e) => e.isError);
711
- const hasEdit = episode.entries.some((e) => EDIT_TOOLS.has(e.tool));
738
+ const hasEdit = episode.entries.some(isEditEntry);
712
739
  const readCount = episode.entries.filter((e) => e.tool === 'Read' || e.tool === 'Grep').length;
713
740
  const isReviewPattern = !hasEdit && !hasError && readCount >= 5;
714
741
  const inferredType = hasError ? 'bugfix' : hasEdit ? 'change' : 'discovery';
@@ -761,11 +788,9 @@ export function buildImmediateObservation(episode) {
761
788
  const searchedFiles = new Set();
762
789
  for (const entry of episode.entries) {
763
790
  if (!entry.files) continue;
764
- if (EDIT_TOOLS.has(entry.tool)) {
765
- for (const f of entry.files) modifiedFiles.add(f);
766
- } else {
767
- for (const f of entry.files) searchedFiles.add(f);
768
- }
791
+ // A Bash entry can do both: `cp a b` reads a and writes b.
792
+ const edited = new Set(entryEditedFiles(entry));
793
+ for (const f of entry.files) (edited.has(f) ? modifiedFiles : searchedFiles).add(f);
769
794
  }
770
795
  // Merge bash-tracked reads and search tool files into filesRead
771
796
  const allReads = new Set([...(episode.filesRead || []), ...searchedFiles]);
@@ -855,7 +880,7 @@ export function hasEnrichmentContent(parsed) {
855
880
  // (MEMORY_INPUT_GUARD used to be the other example here; it is now exported from
856
881
  // lib/memory-input-guard.mjs and imported by two modules and two tests, so it no longer
857
882
  // illustrates the point.)
858
- function buildLessonRetryPrompt(episode, firstPass) {
883
+ function buildLessonRetryPrompt(episode, firstPass, diag = []) {
859
884
  const actionList = episode.entries
860
885
  .map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
861
886
  .join('\n');
@@ -868,12 +893,20 @@ function buildLessonRetryPrompt(episode, firstPass) {
868
893
 
869
894
  If the work was purely mechanical with no insight worth remembering, reply {"lesson":null}.
870
895
  Otherwise reply in 12-280 chars. Do NOT invent a fake lesson, do NOT write the string "none".
896
+ The lesson MUST copy at least 4 consecutive words verbatim, in double quotes, from one DIAGNOSIS line, and claim nothing that line does not state. No DIAGNOSIS line states a cause → {"lesson":null}.
897
+
898
+ Reply ONLY valid JSON, no markdown fences: {"lesson":"..."} or {"lesson":null}
871
899
 
872
- Reply ONLY valid JSON, no markdown fences: {"lesson":"..."} or {"lesson":null}`;
900
+ ${MEMORY_INPUT_GUARD}`;
901
+ // The guard, because this user message carries the DIAGNOSIS block — verbatim tool
902
+ // output — and grounding rewards copying from it (pre-ship defect review P3-4). The
903
+ // first pass has carried it since cso F#4; the retry did not.
873
904
  const user = `A ${firstPass.type} episode just completed. First-pass title: "${firstPass.title || 'untitled'}".
874
905
 
875
906
  Actions:
876
- ${actionList}`;
907
+ ${actionList}
908
+
909
+ ${diagnosisBlock(diag)}`;
877
910
  return { system, user };
878
911
  }
879
912
 
@@ -927,10 +960,11 @@ export async function handleLLMEpisode() {
927
960
  type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
928
961
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
929
962
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
930
- lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5's default tokenizer doesn't split CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". Look hard before giving up — most coding episodes contain at least one micro-lesson (an undocumented flag, a surprising default, a debugging shortcut, an unexpected interaction). If literally no insight worth teaching (e.g. version bump, whitespace fix, file rename), output JSON null. Do NOT invent a lesson, do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
963
+ lesson_learned: The non-obvious insight a future session would benefit from, resting ONLY on the DIAGNOSIS lines: copy at least 4 consecutive words verbatim, in double quotes, from one DIAGNOSIS line, and claim no mechanism that line does not state. Example: FTS5's "default tokenizer doesn't split CJK" — index bigrams instead. No DIAGNOSIS line, or none that states a cause → output JSON null; a lesson without such a quote is discarded. Do NOT invent a lesson. A mutation/probe run (a file changed on purpose, a checksum, a restore) and the agent's own script errors are not product bugs. Do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
931
964
  scope: ${SCOPE_PROMPT_LEGEND}
932
965
  search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
933
966
 
967
+ const diag = episodeDiagnosis(episode);
934
968
  let prompt;
935
969
  if (episode.entries.length === 1) {
936
970
  const e = episode.entries[0];
@@ -941,7 +975,9 @@ ${SHARED_OBS_SCHEMA_TAIL}`;
941
975
  const user = `Tool: ${e.tool}
942
976
  File: ${episodeFiles.join(', ') || 'unknown'}
943
977
  Action: ${e.desc}
944
- Error: ${e.isError ? 'yes' : 'no'}`;
978
+ Error: ${e.isError ? 'yes' : 'no'}
979
+
980
+ ${diagnosisBlock(diag)}`;
945
981
  prompt = { system, user };
946
982
  } else {
947
983
  const actionList = episode.entries
@@ -955,7 +991,9 @@ ${SHARED_OBS_SCHEMA_TAIL}`;
955
991
  const user = `Project: ${episode.project}
956
992
  Files: ${fileList}
957
993
  Actions (${episode.entries.length} total):
958
- ${actionList}`;
994
+ ${actionList}
995
+
996
+ ${diagnosisBlock(diag)}`;
959
997
  prompt = { system, user };
960
998
  }
961
999
 
@@ -1037,6 +1075,25 @@ ${actionList}`;
1037
1075
  const isLessonLowSignal = isLowSignalLesson(rawLesson);
1038
1076
  let lessonLearned = isLessonLowSignal ? null : rawLesson.slice(0, 500);
1039
1077
 
1078
+ // D#69 grounding post-check. An event's lesson is kept only when it quotes the
1079
+ // window's own DIAGNOSIS (isLessonGrounded: a shared 4-word run with one diag line).
1080
+ // The 2026-09-25 audit read 16 of 30 events WRONG and found lessons accurate only
1081
+ // where the window itself stated the cause; the prompt now asks for the quote, and
1082
+ // this is the mechanism — prompt wording alone barely moves Haiku (#8605).
1083
+ // Least destructive outcome: the row is still saved (title, narrative, files — all
1084
+ // searchable), the lesson is dropped, and importance is capped at 1 below, which is
1085
+ // under every automatic injection face's floor (SessionStart / UserPromptSubmit /
1086
+ // PreToolUse all read importance >= 2). Event types only: `change` rows go to
1087
+ // `observations`, were not in the audit, and a lesson-less `change` is DELETED by
1088
+ // isLowYieldChangeObs — demotion there would be a deletion.
1089
+ const groundingOn = lessonGroundingEnabled() && EVENT_TYPE_SET.has(parsed.type);
1090
+ let groundingDropped = false;
1091
+ if (groundingOn && lessonLearned && !isLessonGrounded(lessonLearned, diag)) {
1092
+ debugLog('DEBUG', 'llm-episode', `ungrounded lesson dropped: "${truncate(lessonLearned, 60)}"`);
1093
+ lessonLearned = null;
1094
+ groundingDropped = true;
1095
+ }
1096
+
1040
1097
  // P3: for bugfix/decision, retry once with a lesson-focused prompt.
1041
1098
  // These types have the highest reuse value (~72.7% hit-rate vs change
1042
1099
  // ~16.5%), and Haiku's first pass writes NULL ~70% of the time for
@@ -1044,10 +1101,13 @@ ${actionList}`;
1044
1101
  // episode. Opt-out: CLAUDE_MEM_NO_LESSON_RETRY=1.
1045
1102
  let retryAttempted = false;
1046
1103
  let retryRecovered = false;
1104
+ // With grounding on, a window with no diagnosis cannot yield a keepable lesson, so
1105
+ // the retry (an extra LLM call) is skipped there rather than paid for and discarded.
1047
1106
  if (
1048
- isLessonLowSignal &&
1107
+ !lessonLearned &&
1049
1108
  (parsed.type === 'bugfix' || parsed.type === 'decision') &&
1050
- !process.env.CLAUDE_MEM_NO_LESSON_RETRY
1109
+ !process.env.CLAUDE_MEM_NO_LESSON_RETRY &&
1110
+ (!groundingOn || diag.length > 0)
1051
1111
  ) {
1052
1112
  retryAttempted = true;
1053
1113
  // The first callLLM released its slot in the finally above; this lesson
@@ -1057,13 +1117,15 @@ ${actionList}`;
1057
1117
  // rather than exceed the limit (the lesson is an optional enhancement).
1058
1118
  const retrySlot = await acquireLLMSlot();
1059
1119
  try {
1060
- const retryPrompt = buildLessonRetryPrompt(episode, parsed);
1120
+ const retryPrompt = buildLessonRetryPrompt(episode, parsed, diag);
1061
1121
  const retryRaw = retrySlot ? await callLLM(retryPrompt, BG_LLM_TIMEOUT_MS) : null;
1062
1122
  if (retryRaw) {
1063
1123
  const retry = parseJsonFromLLM(retryRaw);
1064
1124
  const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
1065
1125
  const retryIsLow = isLowSignalLesson(retryLesson);
1066
- if (!retryIsLow) {
1126
+ const retryUngrounded = !retryIsLow && groundingOn && !isLessonGrounded(retryLesson, diag);
1127
+ if (retryUngrounded) groundingDropped = true;
1128
+ if (!retryIsLow && !retryUngrounded) {
1067
1129
  lessonLearned = retryLesson.slice(0, 500);
1068
1130
  retryRecovered = true;
1069
1131
  debugLog(
@@ -1137,8 +1199,12 @@ ${actionList}`;
1137
1199
  // to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
1138
1200
  // path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
1139
1201
  // non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
1202
+ // D#69: `!lessonLearned` is the old `isLessonLowSignal && !retryRecovered` (the
1203
+ // two are equal with grounding off). A lesson the grounding check dropped caps
1204
+ // `decision` too: its body falls back to the model's narrative, which is exactly
1205
+ // as unanchored as the lesson it replaces.
1140
1206
  importance:
1141
- isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
1207
+ !lessonLearned && (groundingDropped || parsed.type !== 'decision')
1142
1208
  ? Math.min(ruleImportance, 1)
1143
1209
  : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
1144
1210
  lessonLearned,