claude-mem-lite 6.14.0 → 6.15.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.14.0",
12
+ "version": "6.15.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.14.0",
3
+ "version": "6.15.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
@@ -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.15.0
241
+
242
+ **Two defaults change; one has an off switch.** No schema change and no migration, so
243
+ reverting everything is pinning `claude-mem-lite@6.14.0`.
244
+
245
+ - **An auto-captured lesson that repeats text a tool printed is kept out of automatic
246
+ injection.** Whoever controls a command's output (a repository's test, a fetched page, an
247
+ MCP server) could otherwise get a sentence of their choosing stored as a lesson that later
248
+ sessions are shown. Such an event stays searchable at importance 1; a `change` observation
249
+ loses the lesson. Four shared words are enough, filler included, so an occasional lesson
250
+ of your own is demoted too. Off: `CLAUDE_MEM_LESSON_OUTPUT_CAP=off`.
251
+ - **Last Session reads your own Done / Not done report when it uses markdown headings**
252
+ (`## Done`, `**Not done**`) instead of falling back to the model's summary. No switch; pin
253
+ 6.14.0 to revert.
254
+
240
255
  ## Upgrading to 6.14.0
241
256
 
242
257
  **Five defaults change; three have an off switch.** No schema change and no migration: an
@@ -942,6 +957,7 @@ claude-mem-lite.
942
957
  | `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
958
  | `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
959
  | `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)_ |
960
+ | `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
961
  | `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
962
  | `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
963
 
package/README.zh-CN.md CHANGED
@@ -199,6 +199,18 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.15.0
203
+
204
+ **两处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
205
+ `claude-mem-lite@6.14.0`。
206
+
207
+ - **自动捕获的教训如果复述了工具打印的文字,不再自动注入。** 否则能控制命令输出的一方(仓库里的测试、
208
+ 抓取的网页、MCP 服务)就能让自己写的一句话被存成教训,之后的会话都会看到。这样的 event 仍可搜索,
209
+ importance 降为 1;`change` 类 observation 则去掉教训。连续 4 个词相同就算,虚词也算,所以偶尔也会
210
+ 误降你自己的教训。关闭:`CLAUDE_MEM_LESSON_OUTPUT_CAP=off`。
211
+ - **Last Session 能读到你用 markdown 标题写的 Done / Not done 报告**(`## Done`、`**Not done**`),
212
+ 不再退回模型写的摘要。没有开关,回退请固定 6.14.0。
213
+
202
214
  ## 升级到 6.14.0
203
215
 
204
216
  **五个默认行为变化,其中三个有关闭开关。** 没有 schema 变更、没有迁移:旧版本仍能打开数据库,
@@ -722,6 +734,7 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
722
734
  | `CLAUDE_MEM_EPISODE_INPUT_FILTER` | 决定 episode 摘要器能从哪些输入里学。子代理的工具调用不进入 episode 缓冲(修改项目内文件的除外);变异探针(改文件 → 跑红 → 还原)和 agent 自己失败的内联脚本(补丁的 `anchor not found`)在保存或摘要前被剔除——在 30 条已核验 event 上重放,这一项直接去掉 16 条错误中的 3 条;配合 `CLAUDE_MEM_LESSON_GROUNDING`,16 条错误教训一条都不会被注入。设为 `off` 恢复未过滤的输入。 | _(开启)_ |
723
735
  | `CLAUDE_MEM_BASH_RECALL` | 在查看(`cat`、`sed -n`、`head`…)或写入(`sed -i`、`cat > f`、python 补丁…)文件的 Bash 命令执行前做文件召回,与 Read / Edit 召回相同。bash 预过滤让其他命令不启动 Node。设为 `off` 只关闭这一路。 | _(开启)_ |
724
736
  | `CLAUDE_MEM_LESSON_GROUNDING` | 自动捕获的 event 只有在教训引用了本窗口自己的诊断文字(失败输出行、编辑新增的注释或提交信息)时才保留教训;否则保留这一行但去掉教训,importance 降为 1,低于所有注入面的门槛。设为 `off` 保留未引用原文的教训。 | _(开启)_ |
737
+ | `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
738
  | `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
739
  | `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
740
 
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,
package/hook.mjs CHANGED
@@ -102,7 +102,7 @@ import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib
102
102
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
103
103
  import {
104
104
  entryInputTags,
105
- extractDiagnosisLines,
105
+ extractDiagnosis,
106
106
  filterSummaryInput,
107
107
  episodeInputFilterEnabled,
108
108
  } from './lib/episode-input-filter.mjs';
@@ -725,16 +725,22 @@ async function handlePostToolUse() {
725
725
  // regex-scanned on every PostToolUse.
726
726
  const respWindow = resp.length > 65536 ? resp.slice(0, 32768) + '\n' + resp.slice(-32768) : resp;
727
727
 
728
+ // `diagOut` = the diagnosis lines read from tool OUTPUT: a lesson quoting one stays
729
+ // under every injection floor (D#100(3)). Always present on a Bash entry, [] included, so
730
+ // the worker can tell a new entry with no output lines from one buffered before the field.
731
+ const diagnosis = extractDiagnosis(tool_name, toolInput, respWindow, {
732
+ isError: bashSig?.isError || false,
733
+ writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
734
+ scrub: scrubSecrets,
735
+ });
736
+
728
737
  // Build episode entry
729
738
  const entry = {
730
739
  tool: tool_name,
731
740
  desc: scrubSecrets(makeEntryDesc(tool_name, toolInput, resp, bashSig)),
732
741
  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
- }),
742
+ diag: diagnosis.lines,
743
+ ...(tool_name === 'Bash' ? { diagOut: diagnosis.output } : {}),
738
744
  files,
739
745
  ...(tool_name === 'Bash' && bashWrites.length ? { bashWrites } : {}),
740
746
  ts: Date.now(),
@@ -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)) };
@@ -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). */
@@ -11,9 +11,93 @@
11
11
 
12
12
  import { readTranscriptEntries } from './transcript-scan.mjs';
13
13
 
14
- const EN_HEADER = /^[\s●*>-]*(Done|Not\s+done|Failed|Uncertain)\s*[::]\s*/im;
15
14
  const ZH_HEADER = /^[\s●*>-]*(剩下的?|剩余|还剩|未完成|下次(?:要做|做|继续)?|待做|未做)\s*[::]?\s*/m;
16
15
 
16
+ // EN section headers, in every form real reports use: `Done: text`, `**Done:** text`,
17
+ // `**Not done** — text`, `**Not done**(未开始):text`, and a heading that IS the whole line —
18
+ // `## Done`, `**Not done**`, `**Uncertain.**`, `## Not done(排除项)`. 362 of 440 turn-final
19
+ // reports on this machine (2026-09-26) used a heading form the colon-only header missed.
20
+ // A whole-line heading opens a BLOCK section, whose body runs through blank lines, paragraphs
21
+ // and tables up to the next heading or rule (BLOCK_END), not to the first paragraph break. A
22
+ // standalone bold line ends it too, but only once the section has content: reports open a
23
+ // section with a bold lead sentence (`## Done` / `**4 rounds, 3 defects fixed.**`) as often as
24
+ // they start an unrelated block with a bold title after it.
25
+ //
26
+ // Parsed as a head match plus small anchored steps over the rest, never as one pattern: the
27
+ // one-regex draft chained four optional `\s*` and took 37 s on `## Done` + 1000 spaces + x
28
+ // (pre-ship review P3-2), on the Stop hook's 5 s budget.
29
+ const SECTION = String.raw`(Done|Not\s+done|Failed|Uncertain)`;
30
+ // Every whitespace run is owned by exactly one quantifier (a `* ` bullet, a `#` heading's
31
+ // gap, the gap after `**`), so a failing match backtracks in linear time — the first repair's
32
+ // `(#…[ \t]+)?(\*\*)?[ \t]*` let two quantifiers share one run (delta review P3-1: 50k
33
+ // spaces + x read 1 s, 120k 5.8 s).
34
+ const HEAD_RE = new RegExp(
35
+ String.raw`^[\s●>-]*(?:\*[ \t]+)?(?:(#{1,6})[ \t]+)?(?:(\*\*)[ \t]*)?${SECTION}(?![\p{L}\p{N}_])`,
36
+ 'iu',
37
+ );
38
+ const MORE_SECTIONS_RE = new RegExp(String.raw`^(?:[ \t]*[/、,][ \t]*${SECTION})+`, 'i');
39
+ const PAREN_RE = /^[ \t]*([((][^))\n]{0,120}[))])/;
40
+ const SEP_RE = /^[ \t]*(?:[::]|[—–]|-(?=\s))/;
41
+
42
+ /** Index of the first non-blank character of `s` at or after `i`. */
43
+ function skipBlank(s, i) {
44
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++;
45
+ return i;
46
+ }
47
+
48
+ /** A trailing `**` that closes the header's bold, not bold inside the text (`**v2.1**`). */
49
+ function dropUnpairedBold(tail) {
50
+ return (tail.match(/\*\*/g) || []).length % 2 === 1 ? tail.replace(/\*\*[::]?\s*$/, '').trim() : tail;
51
+ }
52
+
53
+ /**
54
+ * The EN section header `line` opens, or null.
55
+ * @returns {{key: string, tail: string, block: boolean}|null}
56
+ */
57
+ function enHeader(line) {
58
+ const m = HEAD_RE.exec(line);
59
+ if (!m) return null;
60
+ const key = EN_KEY[m[3].toLowerCase().replace(/\s+/g, ' ')];
61
+ const heading = Boolean(m[1]);
62
+ const bold = Boolean(m[2]);
63
+ let rest = line.slice(m[0].length);
64
+ // `**Not done / Failed / Uncertain**:…` names several sections; the text goes to the first.
65
+ const more = MORE_SECTIONS_RE.exec(rest);
66
+ if (more) rest = rest.slice(more[0].length);
67
+ let closed = !bold;
68
+ let sep = false;
69
+ const take = (re) => {
70
+ const r = re.exec(rest);
71
+ if (r) rest = rest.slice(r[0].length);
72
+ return r;
73
+ };
74
+ // `**Done:**` / `**Failed.** Nothing.` — a colon or period inside the bold separates the
75
+ // header from its text; alone on the line (`**Uncertain.**`) it is a block heading. Read by
76
+ // index, not by a lookahead that rescans the whitespace run from every start.
77
+ if (bold || heading) {
78
+ const p = skipBlank(rest, 0);
79
+ if ('.::'.includes(rest[p] ?? '_')) {
80
+ const q = skipBlank(rest, p + 1);
81
+ if (q === rest.length || rest.startsWith('**', q)) {
82
+ rest = rest.slice(p + 1);
83
+ sep = true;
84
+ }
85
+ }
86
+ }
87
+ if (bold && take(/^[ \t]*\*\*/)) closed = true;
88
+ const paren = take(PAREN_RE);
89
+ if (bold && !closed && take(/^[ \t]*\*\*/)) closed = true;
90
+ if (take(SEP_RE)) sep = true;
91
+ if (bold && !closed && take(/^[ \t]*\*\*/)) closed = true;
92
+ const tail = [paren ? paren[1] : '', rest.replace(/^[ \t]*\*\*/, '').trim()].filter(Boolean).join(' ');
93
+ if (sep) return { key, tail: dropUnpairedBold(tail), block: !tail && (bold || heading) };
94
+ if (!rest.trim() && (heading || (bold && closed))) return { key, tail: paren ? paren[1] : '', block: true };
95
+ return null;
96
+ }
97
+
98
+ const BLOCK_END = /^\s*(?:#{1,6}\s|(?:-{3,}|\*{3,}|_{3,})\s*$)/;
99
+ const BOLD_LINE = /^\s*\*\*[^*].*\*\*[::]?\s*$/;
100
+
17
101
  // Recognised section keys, normalised.
18
102
  const EN_KEY = { done: 'done', 'not done': 'notDone', failed: 'failed', uncertain: 'uncertain' };
19
103
  const ZH_KEY_IS_NOTDONE = /剩下|剩余|还剩|未完成|下次|待做|未做/;
@@ -58,22 +142,37 @@ export function extractStructuredSummary(text) {
58
142
 
59
143
  const lines = text.split('\n');
60
144
  let current = null;
145
+ let block = false;
61
146
  const buffers = { done: [], notDone: [], failed: [], uncertain: [] };
62
147
 
63
148
  for (let i = 0; i < lines.length; i++) {
64
149
  const line = lines[i];
65
150
  const trimmed = line.trim();
66
151
 
67
- // Header detection — EN first (unambiguous), then 中文.
68
- const enMatch = line.match(EN_HEADER);
69
- if (enMatch) {
70
- const key = EN_KEY[enMatch[1].toLowerCase().replace(/\s+/g, ' ')];
71
- if (key) {
72
- current = key;
73
- const tail = line.slice(enMatch[0].length).trim();
74
- if (tail) buffers[current].push(tail);
152
+ // Header detection — EN first (unambiguous), then 中文. Any EN header ends the section
153
+ // before it; an inline one (\`Failed: none\`, \`**Failed:** 无。\`) also ends block mode.
154
+ const h = enHeader(line);
155
+ if (h) {
156
+ current = h.key;
157
+ block = h.block;
158
+ if (h.tail) buffers[current].push(h.tail);
159
+ continue;
160
+ }
161
+ if (block && current) {
162
+ // A question to the user after a blank line (\`要推送并发 v6.13.4 吗?\`) is not section
163
+ // content: the claims review found 21 of 46 closing questions absorbed into Uncertain.
164
+ // Skipped, not a boundary — a paragraph after it can still belong to the section — and
165
+ // only when short: a long paragraph ending in a question carries content too.
166
+ const afterBlank = i > 0 && !lines[i - 1].trim();
167
+ const question = trimmed.length <= 120 && /[??]\s*$/.test(trimmed);
168
+ if (afterBlank && question && !/^[-*•●\d|>]/.test(trimmed) && !BLOCK_END.test(line)) continue;
169
+ if (BLOCK_END.test(line) || (BOLD_LINE.test(line) && buffers[current].length > 0)) {
170
+ current = null;
171
+ block = false;
172
+ } else if (trimmed) {
173
+ buffers[current].push(trimmed);
75
174
  continue;
76
- }
175
+ } else continue;
77
176
  }
78
177
  const zhMatch = line.match(ZH_HEADER);
79
178
  if (zhMatch && ZH_KEY_IS_NOTDONE.test(zhMatch[1])) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.14.0",
3
+ "version": "6.15.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.14.0",
9
+ "version": "6.15.0",
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.14.0",
3
+ "version": "6.15.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
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",