claude-mem-lite 6.15.0 → 6.17.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.15.0",
12
+ "version": "6.17.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.15.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.17.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
@@ -81,7 +81,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
81
81
  - **Episode batching** -- Groups related file operations into coherent episodes before LLM encoding
82
82
  - **Error-triggered recall** -- Automatically searches memory when Bash errors occur, surfacing relevant past fixes
83
83
  - **Proactive file history** -- When editing a file, automatically shows relevant past observations for that file
84
- - **Session summaries** -- LLM-generated summaries at session end (via background workers using `claude -p`)
84
+ - **Session summaries** -- written at every Stop (the assistant's final report when it has Done / Not done sections, else the first prompt and recent observation titles), then upgraded by a background model summary when the session has observations
85
85
  - **Project-scoped context** -- Injects recent memory into `CLAUDE.md` and session startup for immediate context
86
86
  - **Observation types** -- Categorized as `decision`, `bugfix`, `feature`, `refactor`, `discovery`, or `change`
87
87
  - **Importance grading** -- LLM assigns 1-3 importance levels (routine / notable / critical) to each observation
@@ -237,6 +237,35 @@ 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.17.0
241
+
242
+ **Two defaults change; one has a switch.** No schema change and no migration, so reverting
243
+ everything is pinning `claude-mem-lite@6.16.0`.
244
+
245
+ - **The pre-edit lesson line asks for a lesson's `#NN` only where it changed the edit.** It no
246
+ longer asks for an applied / not-applicable verdict on every lesson in your next reply, which
247
+ put lesson-id lists into replies. Old directive: `CLAUDE_MEM_SALIENCE=verdict`. Adopted
248
+ projects get the matching CLAUDE.md managed row and detail doc on the next SessionStart;
249
+ `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1` keeps the old text.
250
+ - **SessionStart's "Deferred Work" list ends with "+N more open"** when more than its 5 rows
251
+ are open. No switch; pin 6.16.0 to revert.
252
+
253
+ ## Upgrading to 6.16.0
254
+
255
+ **Three defaults change; one has an off switch.** No schema change and no migration, so
256
+ reverting everything is pinning `claude-mem-lite@6.15.0`.
257
+
258
+ - **Each session gets one of two first lines on a file-recall block.** Half of sessions keep
259
+ "system-injected context, continue your planned action"; the other half get a plain
260
+ statement of where the notes come from, as Claude Code's hooks guide recommends. The two
261
+ are compared by cite-rate before one becomes the default. Off (old line everywhere):
262
+ `CLAUDE_MEM_RECALL_FRAMING=legacy`.
263
+ - **Memory text longer than the host's 10,000-character hook limit is trimmed by whole
264
+ lines**, with a closing line naming the ids left out, instead of the host replacing it with
265
+ a 2,000-character preview. No switch; pin 6.15.0 to revert.
266
+ - **Lessons shown after a failed Bash command now count in citation decay**, like every other
267
+ surface: ones never cited are ranked down over time. No switch; pin 6.15.0 to revert.
268
+
240
269
  ## Upgrading to 6.15.0
241
270
 
242
271
  **Two defaults change; one has an off switch.** No schema change and no migration, so
@@ -559,7 +588,7 @@ lesson_learned, minhash_sig, access_count, compressed_into, search_aliases,
559
588
  branch, superseded_at, superseded_by, last_accessed_at
560
589
  ```
561
590
 
562
- **session_summaries** -- LLM-generated session summaries
591
+ **session_summaries** -- per-session summaries (written at Stop, upgraded by the background model summary)
563
592
  ```
564
593
  id, memory_session_id, project, request, investigated,
565
594
  learned, completed, next_steps, files_read, files_edited, notes,
@@ -628,6 +657,7 @@ Stop
628
657
  -> Flush final episode buffer
629
658
  -> Save handoff snapshot (type 'exit')
630
659
  -> Mark session completed
660
+ -> Write the session summary row (sync, no model call)
631
661
  -> Spawn LLM summary worker (poll-based wait)
632
662
  -> Keep the session file <- Stop fires per TURN; deleting it here re-minted a mem
633
663
  session every turn and left the SessionStart /clear branch unreachable (v5.4.0)
@@ -1055,7 +1085,8 @@ and names can change between releases.
1055
1085
  |----------|-------------|---------|
1056
1086
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1057
1087
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1058
- | `CLAUDE_MEM_SALIENCE` | Selects a comprehension-bridge arm (`bridge`, `bind`); unset = current default behavior. | _(unset)_ |
1088
+ | `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` |
1089
+ | `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the directive only: the adoption row in CLAUDE.md and the detail doc keep the new wording); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback); `legacy` = no directive. | _(unset)_ |
1059
1090
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1060
1091
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
1061
1092
 
package/README.zh-CN.md CHANGED
@@ -60,7 +60,7 @@
60
60
  - **Episode 批处理** -- 将相关文件操作分组为连贯的 episode,再进行 LLM 编码
61
61
  - **错误触发回忆** -- Bash 出错时自动搜索记忆,浮现相关的历史修复方案
62
62
  - **主动文件历史** -- 编辑文件时,自动显示该文件相关的历史观察记录
63
- - **会话摘要** -- 会话结束时通过后台 worker(使用 `claude -p`)生成 LLM 摘要
63
+ - **会话摘要** -- 每次 Stop 时写入(助手最终回复带 Done / Not done 段落时取其内容,否则取首条提示和最近的 observation 标题),会话有 observation 时再由后台模型摘要升级
64
64
  - **项目作用域上下文** -- 将最近的记忆注入 `CLAUDE.md` 和会话启动上下文
65
65
  - **观察类型** -- 分类为 `decision`、`bugfix`、`feature`、`refactor`、`discovery` 或 `change`
66
66
  - **重要度分级** -- LLM 为每条观察分配 1-3 级重要度(日常/关注/关键)
@@ -199,6 +199,32 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.17.0
203
+
204
+ **两处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
205
+ `claude-mem-lite@6.16.0`。
206
+
207
+ - **编辑前的 lesson 提示只在某条 lesson 改变了这次改动时,才请模型提一次它的 `#NN`。** 不再要求在
208
+ 下一条回复里对每条 lesson 逐一表态“采纳 / 不适用”——那会让回复里出现成串的 lesson 编号。恢复旧提示:
209
+ `CLAUDE_MEM_SALIENCE=verdict`。已接入的项目会在下次 SessionStart 时把 CLAUDE.md 托管行和详细文档
210
+ 换成对应的新写法;想保留旧文本设 `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1`。
211
+ - **SessionStart 的 "Deferred Work" 列表(5 行)在还有更多未完成事项时,末尾加一行 "+N more open"。**
212
+ 没有开关,回退请固定 6.16.0。
213
+
214
+ ## 升级到 6.16.0
215
+
216
+ **三处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
217
+ `claude-mem-lite@6.15.0`。
218
+
219
+ - **文件召回块的第一行,每个会话分到两种写法之一。** 一半会话保留原来的 "system-injected context,
220
+ continue your planned action",另一半改成说明这些笔记来自哪里的事实陈述(Claude Code 的 hooks
221
+ 指南建议这样写)。两种写法先比较引用率,再决定默认用哪个。关闭(全部用旧写法):
222
+ `CLAUDE_MEM_RECALL_FRAMING=legacy`。
223
+ - **超过宿主 1 万字符 hook 上限的记忆文本会按整行裁剪**,末尾一行列出被略去的 id;以前宿主会把它换成
224
+ 2,000 字符的预览。没有开关,回退请固定 6.15.0。
225
+ - **Bash 命令失败后展示的教训,现在也计入引用衰减**,和其他注入面一致:一直没被引用的会逐渐排到后面。
226
+ 没有开关,回退请固定 6.15.0。
227
+
202
228
  ## 升级到 6.15.0
203
229
 
204
230
  **两处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
@@ -456,7 +482,7 @@ text, narrative, concepts, facts, files_read, files_modified,
456
482
  importance, related_ids, created_at, created_at_epoch
457
483
  ```
458
484
 
459
- **session_summaries** -- LLM 生成的会话摘要
485
+ **session_summaries** -- 每个会话的摘要(Stop 时写入,后台模型摘要升级)
460
486
  ```
461
487
  id, memory_session_id, project, request, investigated,
462
488
  learned, completed, next_steps, files_read, files_edited, notes
@@ -515,6 +541,7 @@ Stop
515
541
  -> 刷新最终 episode 缓冲区
516
542
  -> 保存交接快照(/exit 时)
517
543
  -> 标记会话为已完成
544
+ -> 写入会话摘要行(同步,不调模型)
518
545
  -> 启动 LLM 摘要 worker(轮询等待)
519
546
  ```
520
547
 
package/adopt-content.mjs CHANGED
@@ -50,7 +50,7 @@ PreToolUse hooks already run \`mem_recall\` for past lessons before Read/Edit/Wr
50
50
 
51
51
  | When | Call |
52
52
  |------|------|
53
- | Before Edit/Write | hook already recalled; if a \`#NN\` lesson was injected, cite \`#NN\` next time you produce user-visible text (citing = adopting the feedback; uncited lessons decay) |
53
+ | Before Edit/Write | hook already recalled; if an injected \`#NN\` lesson changed what you did, name \`#NN\` once where you say so (citing = adopting; uncited lessons decay; skip ones that did not apply) |
54
54
  | After fixing a non-trivial bug | \`mem_save(type="bugfix", lesson_learned="<root cause + fix>", importance=2)\` |
55
55
  | After a non-obvious architecture decision | \`mem_save(type="decision", lesson_learned="<constraint + tradeoff>")\` |
56
56
  | Deferring to a future session | \`mem_defer({title, priority:1|2|3, detail})\`; when fixed, add \`closes_deferred=[N]\` to \`mem_save\` |
@@ -87,11 +87,12 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
87
87
  - **Edit / Write** 路径:decision-support——最多 3 条、240 字符、高重要度 bugfix/decision 即使无
88
88
  lesson 也注入。
89
89
  - Read→Edit 同文件共享 cooldown(不重复注入正文),但 Read 注入后的首个 Edit 会把 lesson **ID**
90
- 以一行 ack 指令重新浮出。看到 \`#NN [bugfix] …\` 这类行时:**下次产出用户可见文字时引用 \`#NN\`**
91
- (\`'#NN applied'\` 或 \`'#NN n/a — <理由>'\`)。纯工具回合不算;把 ID 记在工作记忆里,写回时引用。
90
+ 以一行 ack 指令重新浮出。看到 \`#NN [bugfix] …\` 这类行时:**某条 lesson 改变了你的做法,就在描述
91
+ 那处改动时顺带提一次 \`#NN\`**;没用上的 lesson 不必提,也不要逐条列出。纯工具回合不算;
92
+ 把 ID 记在工作记忆里,写回时引用。
92
93
  - 系统按会话追踪引用:被引用的 lesson 在召回排序里上浮,被注入却未引用的下沉(有界的排序乘数);
93
94
  反复注入却从未被引用的,后台维护会把它的 importance 降到 2(无 lesson 的降到 1)。
94
- \`'#NN n/a'\` 算作已回应,但不算采纳:排序上与未引用相同,同样下沉。
95
+ 写成 \`#NN n/a\` 的驳回不算采纳:排序上与未引用相同,同样下沉——所以不必写。
95
96
  引用是给系统的反馈,不是合规仪式——注入池据此自调。
96
97
 
97
98
  ## 何时主动调用 MCP 工具
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-context.mjs CHANGED
@@ -580,7 +580,12 @@ export function buildSessionContextLines(
580
580
  currentCcSessionId = null,
581
581
  collector = null,
582
582
  ) {
583
- if (collector) collector.keyContextIds = [];
583
+ if (collector) {
584
+ collector.keyContextIds = [];
585
+ // The same rows as {id, text: rendered line}, so a caller can book only the ones the
586
+ // hook-output cap keeps (D#108; lib/hook-text-cap.mjs idsShownWhole).
587
+ collector.keyContextLines = [];
588
+ }
584
589
  // 1. Token-budgeted observation selection
585
590
  const selected = selectWithTokenBudget(db, project, 2000);
586
591
  const observations = selected.observations;
@@ -711,14 +716,20 @@ export function buildSessionContextLines(
711
716
  summaryLines.push('### File Lessons');
712
717
  summaryLines.push(...shown.map((e) => e.line));
713
718
  summaryLines.push('');
714
- if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
719
+ if (collector) {
720
+ collector.keyContextIds.push(...shown.map((e) => e.id));
721
+ collector.keyContextLines.push(...shown.map((e) => ({ id: e.id, text: e.line })));
722
+ }
715
723
  }
716
724
  if (keyContext.length > 0 && !quiet) {
717
725
  const shown = keyContext.slice(0, keyContextQuota);
718
726
  summaryLines.push('### Key Context');
719
727
  summaryLines.push(...shown.map((e) => e.line));
720
728
  summaryLines.push('');
721
- if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
729
+ if (collector) {
730
+ collector.keyContextIds.push(...shown.map((e) => e.id));
731
+ collector.keyContextLines.push(...shown.map((e) => ({ id: e.id, text: e.line })));
732
+ }
722
733
  }
723
734
  } else if (!latestSummary && !effectiveQuiet()) {
724
735
  // Fallback: no summary AND no key observations — show recent activity.
@@ -851,7 +862,8 @@ export function buildSessionContextLines(
851
862
  SELECT id, title, priority,
852
863
  ROW_NUMBER() OVER (
853
864
  ORDER BY priority DESC, created_at_epoch ASC, id ASC
854
- ) AS ordinal
865
+ ) AS ordinal,
866
+ COUNT(*) OVER () AS open_total
855
867
  FROM deferred_work
856
868
  WHERE project = ? AND status = 'open'
857
869
  ORDER BY priority DESC, created_at_epoch ASC, id ASC
@@ -867,6 +879,14 @@ export function buildSessionContextLines(
867
879
  const pTag = d.priority === 3 ? '🔴' : d.priority === 1 ? '⚪' : '🟡';
868
880
  deferredLines.push(`${d.ordinal}. ${pTag} [P${d.priority}] ${truncate(d.title, 120)} (D#${d.id})`);
869
881
  }
882
+ // The list is capped at 5 and used to stop there silently, so 10 open items read as a
883
+ // 5-item backlog. The total is the same statement's COUNT(*) OVER (), taken before LIMIT.
884
+ const hidden = Number(deferredItems[0].open_total) - deferredItems.length;
885
+ // Both listing surfaces page at 10, so the line names the knob rather than promising "all".
886
+ if (hidden > 0)
887
+ deferredLines.push(
888
+ `+${hidden} more open — mem_defer_list / \`defer list\` with a larger limit lists them`,
889
+ );
870
890
  deferredLines.push('');
871
891
  }
872
892
 
package/hook-handoff.mjs CHANGED
@@ -33,6 +33,7 @@ import * as taskReaderModule from './lib/task-reader.mjs';
33
33
  // so a named import could not be spied on in tests.
34
34
  import * as pausedReaderModule from './lib/paused-reader.mjs';
35
35
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
36
+ import { summarySourceLabel } from './lib/fast-summary.mjs';
36
37
 
37
38
  /**
38
39
  * Build and save a handoff snapshot to session_handoffs table.
@@ -1035,7 +1036,7 @@ function renderHandoffFromRow(handoff, db, project) {
1035
1036
  let summary = db
1036
1037
  .prepare(
1037
1038
  `
1038
- SELECT completed, next_steps, remaining_items FROM session_summaries
1039
+ SELECT completed, next_steps, remaining_items, notes FROM session_summaries
1039
1040
  WHERE memory_session_id = ? AND project = ?
1040
1041
  ORDER BY created_at_epoch DESC, id DESC LIMIT 1
1041
1042
  `,
@@ -1049,7 +1050,7 @@ function renderHandoffFromRow(handoff, db, project) {
1049
1050
  summary = db
1050
1051
  .prepare(
1051
1052
  `
1052
- SELECT completed, next_steps, remaining_items FROM session_summaries
1053
+ SELECT completed, next_steps, remaining_items, notes FROM session_summaries
1053
1054
  WHERE project = ?
1054
1055
  ORDER BY ABS(created_at_epoch - ?) ASC, id DESC LIMIT 1
1055
1056
  `,
@@ -1058,7 +1059,9 @@ function renderHandoffFromRow(handoff, db, project) {
1058
1059
  }
1059
1060
  if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
1060
1061
  lines.push('');
1061
- lines.push('<session-summary source="haiku">');
1062
+ // Provenance of the Done text from the row's own tag, not a constant: it read "haiku" on
1063
+ // every row, including rows whose Done came from the Stop report or observation titles.
1064
+ lines.push(`<session-summary source="${summarySourceLabel(summary.notes)}">`);
1062
1065
  // Defang: these come from session_summaries, populated by Haiku OR by
1063
1066
  // extractStructuredSummary over the assistant transcript tail — replayed text that can
1064
1067
  // carry tool-XML / forged authority tags, same class as working_on above (audit MED-4).
@@ -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, idsShownWhole } 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
@@ -26,9 +27,9 @@ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_
26
27
  const collector = {};
27
28
  const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
28
29
  const rendered = body && String(body).trim() !== '';
29
- if (rendered) {
30
- process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
31
- }
30
+ // What the cap kept, not what was rendered: a Key Context row the cap cut is not in
31
+ // context, so booking it would exclude it from <memory-context> too (D#108).
32
+ const shown = rendered ? writeCappedHookText(`<claude-mem-context>\n${body}\n</claude-mem-context>`) : '';
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
34
35
  // exclude-set for what is actually in context. The old empty-body early return left the
@@ -39,7 +40,7 @@ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_
39
40
  runtimeDir,
40
41
  project,
41
42
  sessionId: sessionId || null,
42
- ids: rendered ? collector.keyContextIds || [] : [],
43
+ ids: idsShownWhole(shown, collector.keyContextLines),
43
44
  });
44
45
  } catch (e) {
45
46
  debugCatch(e, 'handlePreCompact');
package/hook-update.mjs CHANGED
@@ -76,11 +76,12 @@ export async function checkForUpdate(options = {}) {
76
76
  const state = readState();
77
77
  if (!force && !shouldCheck(state)) {
78
78
  // Return cached update info if previously detected
79
- if (state.updateAvailable && state.latestVersion) {
79
+ const running = pendingCachedUpdate(state);
80
+ if (running) {
80
81
  return {
81
82
  updateAvailable: true,
82
83
  updated: false,
83
- from: state.installedVersion,
84
+ from: running,
84
85
  to: state.latestVersion,
85
86
  installDeferred: pluginMode || !allowInstall,
86
87
  pluginMode,
@@ -160,13 +161,14 @@ export function getCachedUpdateBanner() {
160
161
  try {
161
162
  if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
162
163
  const state = readState();
163
- if (state.updateAvailable && state.latestVersion) {
164
+ const running = pendingCachedUpdate(state);
165
+ if (running) {
164
166
  // Cached "available" state only persists for deferred installs (plugin mode
165
167
  // / allowInstall=false); a successful auto-install clears updateAvailable.
166
168
  const hint = isPluginMode()
167
169
  ? ' — plugin mode only checks for updates; reinstall/update the plugin to apply it'
168
170
  : '';
169
- return `\n📦 claude-mem-lite: v${state.latestVersion} available (current: v${state.installedVersion})${hint}\n`;
171
+ return `\n📦 claude-mem-lite: v${state.latestVersion} available (current: v${running})${hint}\n`;
170
172
  }
171
173
  return null;
172
174
  } catch {
@@ -174,6 +176,18 @@ export function getCachedUpdateBanner() {
174
176
  }
175
177
  }
176
178
 
179
+ // Issue #35. A cached `updateAvailable` is a claim about the version that was
180
+ // running when the check ran, and in plugin mode the update itself is applied by
181
+ // Claude Code — downloadAndInstall never runs, so nothing clears the flag. Judge
182
+ // the cache against the version running NOW: returns that version when the cached
183
+ // latest is still ahead of it, else null. Both cached faces (the SessionStart
184
+ // banner and the throttled checkForUpdate) ask this, so neither can nag alone.
185
+ function pendingCachedUpdate(state) {
186
+ if (!state.updateAvailable || !state.latestVersion) return null;
187
+ const running = getCurrentVersion();
188
+ return compareVersions(state.latestVersion, running) > 0 ? running : null;
189
+ }
190
+
177
191
  // True when a network refresh is due (24h throttle) and updates aren't disabled.
178
192
  // Caller spawns the refresh in the background so this session doesn't wait.
179
193
  export function isUpdateCheckDue() {
package/hook.mjs CHANGED
@@ -98,7 +98,13 @@ import {
98
98
  } from './lib/fast-summary.mjs';
99
99
  import { formatHookError } from './lib/native-binding-hint.mjs';
100
100
  import { recordHookError } from './lib/hook-telemetry.mjs';
101
- import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
101
+ import {
102
+ queueHookContext,
103
+ queueHookSystemMessage,
104
+ flushHookStdout,
105
+ previewHookContext,
106
+ } from './lib/hook-stdout.mjs';
107
+ import { writePlainHookText, resetPlainHookText, idsShownWhole } from './lib/hook-text-cap.mjs';
102
108
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
103
109
  import {
104
110
  entryInputTags,
@@ -1564,9 +1570,12 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1564
1570
  let gate = { gateInjected: null, gateRecalled: null, gateRatio: null };
1565
1571
  try {
1566
1572
  const gateInjectedIds = unionSurfaces(extractInjectedBySurface(transcriptPath, { mainOnly: true }));
1567
- // The nudge asks whether the agent ANSWERED what the hooks showed it, and
1568
- // `#NN n/a — <reason>` is a complete answer: counting it as silence would nag
1569
- // an agent for following the convention to the letter.
1573
+ // The nudge asks whether the agent ANSWERED what the hooks showed it, so a
1574
+ // `#NN n/a — <reason>` still counts. Since D#98 the default directive asks for no
1575
+ // reply on a lesson that did not apply, so that silence now reads as a miss here and
1576
+ // the gate fires more (projected 78/95 → up to 87/95 qualifying sessions,
1577
+ // docs/audits/20260927-d98-dismissal-baseline.md). Left as is: the gate already fired
1578
+ // on most sessions and self-silences after 3; D#111's readout re-measures it.
1570
1579
  const gateCited = extractCitationsFromTranscript(transcriptPath, {
1571
1580
  mainOnly: true,
1572
1581
  includeDismissed: true,
@@ -1744,6 +1753,12 @@ async function handleStop() {
1744
1753
  // waits on, then recreates the sandbox tree behind the test's cleanup. Any
1745
1754
  // grace period for that is a race, not a barrier — the post-tag review timed a
1746
1755
  // recreate at 432ms and watched a 300ms grace lose.
1756
+ //
1757
+ // D#95 made this opt-in in 34a65cd and was reverted before release: its premise ("Last
1758
+ // Session already comes from the Stop report") holds only when the assistant's final reply
1759
+ // carries Done / Not done sections (lib/summary-extractor.mjs). Without them the Stop row is
1760
+ // the first prompt + recent observation titles, and this worker's model summary is the only
1761
+ // prose summary such a user gets.
1747
1762
  if (!process.env.CLAUDE_MEM_SKIP_SUMMARY)
1748
1763
  spawnBackground('llm-summary', sessionId, project, String(stopEpoch));
1749
1764
 
@@ -2860,7 +2875,9 @@ async function handleSessionStart() {
2860
2875
  runtimeDir: RUNTIME_DIR,
2861
2876
  project,
2862
2877
  sessionId: ccSessionId,
2863
- ids: contextCollector.keyContextIds || [],
2878
+ // Only the rows the cap keeps: flushHookStdout caps additionalContext at the
2879
+ // dispatcher's exit, and nothing is queued for the model after this point (D#108).
2880
+ ids: idsShownWhole(previewHookContext(), contextCollector.keyContextLines),
2864
2881
  });
2865
2882
 
2866
2883
  // One-time migration: remove any stale <claude-mem-context> block left in
@@ -3040,7 +3057,7 @@ function injectHandoffIfEarly(db, { project, promptText, promptNumber, ccSession
3040
3057
  const picked = pickHandoffToInject(db, project, ccSessionId);
3041
3058
  if (picked) {
3042
3059
  const injection = renderHandoffInjection(db, project, ccSessionId);
3043
- if (injection) process.stdout.write(injection + '\n');
3060
+ if (injection) writePlainHookText(injection);
3044
3061
  // Consume ONLY the row we just injected — leave other projects' exit
3045
3062
  // handoffs intact so future sessions can still resume from them.
3046
3063
  // Pre-v2.46 wiped every exit handoff for the project on any continuation
@@ -3251,7 +3268,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3251
3268
  const lines = ['<memory-context relevance="high">'];
3252
3269
  for (const m of memories) lines.push(formatMemoryLine(m));
3253
3270
  lines.push('</memory-context>');
3254
- process.stdout.write(lines.join('\n') + '\n');
3271
+ writePlainHookText(lines.join('\n'));
3255
3272
  }
3256
3273
  // HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
3257
3274
  // store for promoted bugfix/decision/lesson memories that persistHaikuSummary
@@ -3272,7 +3289,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3272
3289
  const elines = ['<memory-context relevance="events">'];
3273
3290
  for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
3274
3291
  elines.push('</memory-context>');
3275
- process.stdout.write(elines.join('\n') + '\n');
3292
+ writePlainHookText(elines.join('\n'));
3276
3293
  }
3277
3294
  } catch (e) {
3278
3295
  debugCatch(e, 'handleUserPrompt-events');
@@ -3281,7 +3298,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3281
3298
  // Guard the write on a non-empty return — formatTaskImperative yields '' for a
3282
3299
  // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
3283
3300
  const imperativeLine = formatTaskImperative(imperativePick.lesson_learned, imperativePick.id);
3284
- if (imperativeLine) process.stdout.write(imperativeLine + '\n');
3301
+ if (imperativeLine) writePlainHookText(imperativeLine);
3285
3302
  }
3286
3303
 
3287
3304
  // D#214's ruler, second half: arm B was computed above, before anything was
@@ -3312,6 +3329,7 @@ async function injectSemanticMemory(db, { project, promptText, ccSessionId }) {
3312
3329
  }
3313
3330
 
3314
3331
  async function handleUserPrompt() {
3332
+ resetPlainHookText();
3315
3333
  const input = await readUserPromptInput();
3316
3334
  if (!input) return;
3317
3335
  const { promptText, hookData } = input;
@@ -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
  /**
@@ -102,8 +103,9 @@ export function unanchoredInjectedIdRe() {
102
103
  const CITATION_RE = citationIdRe();
103
104
 
104
105
  // ─── Dismissals ──────────────────────────────────────────────────────────────
105
- // The adoption doc asks the agent to answer every surfaced lesson with `'#NN applied'`
106
- // or `'#NN n/a — <reason>'`. The second form is the agent saying the lesson did NOT
106
+ // The adoption doc asked the agent (until D#98; still under CLAUDE_MEM_SALIENCE=verdict) to
107
+ // answer every surfaced lesson with `'#NN applied'` or `'#NN n/a — <reason>'`, and agents
108
+ // still write the second form unprompted. It is the agent saying the lesson did NOT
107
109
  // apply, yet `#NN` alone was the whole citation test, so a dismissal promoted the row
108
110
  // exactly like an application: cited_count + 1, uncited_streak reset, demoted_at
109
111
  // cleared, access_count bumped towards boostAccessed. Measured 2026-09-25T21:40Z over every
@@ -743,8 +745,13 @@ const SURFACE_MATCHERS = {
743
745
  // post-tool-use.sh. High-volume surface that NO extractor matched before
744
746
  // v3.47 — error-recall'd obs accrued injection_count but never reached
745
747
  // applyCitationDecay, so they could neither promote nor demote.
748
+ // TWO deliveries: post-tool-use.sh (PostToolUse) and `hook.mjs post-tool-failure`
749
+ // (PostToolUseFailure — where a host-flagged failure goes, not PostToolUse). Keyed on
750
+ // the first alone until 2026-09-27, which kept every failure-path recall out of decay
751
+ // and every cite-rate ruler (C1 denominator count: 158 attachments carrying 389 ids).
746
752
  accepts: ({ command, text }) =>
747
- command.includes('post-tool-use') && text.includes('Related memories found for this error'),
753
+ (command.includes('post-tool-use') || command.includes('post-tool-failure')) &&
754
+ text.includes('Related memories found for this error'),
748
755
  collect: (text, add) => {
749
756
  // Per-line anchored: match only a row that STARTS with `#NN [type]` (after its
750
757
  // indent), NOT every such token in the block. The inlined lesson body (v3.16.x)
@@ -857,6 +864,32 @@ export function countInjectedBySurface(transcriptPath, opts = {}) {
857
864
  return out;
858
865
  }
859
866
 
867
+ /**
868
+ * Which recall framing arm(s) this transcript's PreToolUse blocks carried (A1 A/B).
869
+ *
870
+ * Read from the injected text, not recomputed from the session id: a session that ran
871
+ * before the A/B shipped saw the legacy line whatever its id hashes to, and only the text
872
+ * knows. Same walk and same `pretool` matcher as every other face.
873
+ *
874
+ * @param {string|null|undefined} transcriptPath
875
+ * @param {{mainOnly?: boolean}} [opts]
876
+ * @returns {'legacy'|'factual'|'mixed'|null} null when no PreToolUse block carried a framing line.
877
+ */
878
+ export function pretoolFramingOf(transcriptPath, opts = {}) {
879
+ const seen = new Set();
880
+ eachHookAttachment(
881
+ transcriptPath,
882
+ (ctx) => {
883
+ if (!SURFACE_MATCHERS.pretool.accepts(ctx)) return;
884
+ const arm = classifyRecallFraming(ctx.text);
885
+ if (arm) seen.add(arm);
886
+ },
887
+ opts,
888
+ );
889
+ if (seen.size === 0) return null;
890
+ return seen.size > 1 ? 'mixed' : [...seen][0];
891
+ }
892
+
860
893
  // Per-face extractors: thin wrappers over the shared table, kept as named
861
894
  // exports because callers and tests address individual faces.
862
895
  function extractOneSurface(face, transcriptPath, opts) {
@@ -67,14 +67,16 @@ export function insertDeferred(db, args) {
67
67
  * @param {Database} db
68
68
  * @param {string} project
69
69
  * @param {number} [limit=10]
70
- * @returns {Array<{id, project, title, detail, priority, status, created_at_epoch, ordinal}>}
70
+ * @returns {Array<{id, project, title, detail, priority, status, created_at_epoch, ordinal, open_total}>}
71
+ * `open_total` is every open row in the project, counted by the same statement before LIMIT.
71
72
  */
72
73
  export function listOpenWithOrdinal(db, project, limit = 10) {
73
74
  return db
74
75
  .prepare(
75
76
  `
76
77
  SELECT id, project, title, detail, priority, status, created_at_epoch,
77
- ROW_NUMBER() OVER (ORDER BY priority DESC, created_at_epoch ASC, id ASC) AS ordinal
78
+ ROW_NUMBER() OVER (ORDER BY priority DESC, created_at_epoch ASC, id ASC) AS ordinal,
79
+ COUNT(*) OVER () AS open_total
78
80
  FROM deferred_work
79
81
  WHERE project = ? AND status = 'open'
80
82
  ORDER BY priority DESC, created_at_epoch ASC, id ASC
@@ -84,6 +86,22 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
84
86
  .all(project, limit);
85
87
  }
86
88
 
89
+ /**
90
+ * How many open rows `defer list` / `mem_defer_list` left off their page, as a line to
91
+ * print — '' when the page held them all. Without it a page one short of the open set
92
+ * (11 open, default page 10) reads as the whole list. Shared by both surfaces; `raise`
93
+ * names the knob in each surface's own spelling. The total comes from the page's own
94
+ * statement (`open_total`), so a concurrent `defer add` cannot make the two disagree.
95
+ * @param {Array<{open_total?: number}>} page rows from listOpenWithOrdinal
96
+ * @param {string} raise e.g. `raise --limit (max 100)`
97
+ * @returns {string}
98
+ */
99
+ export function formatDeferMoreHint(page, raise) {
100
+ const hidden = page.length > 0 ? Number(page[0].open_total) - page.length : 0;
101
+ if (!(hidden > 0)) return '';
102
+ return `${hidden} more open item${hidden === 1 ? '' : 's'} not shown — ${raise}`;
103
+ }
104
+
87
105
  // ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
88
106
 
89
107
  // Internal const (was exported at v3.51.0 birth with zero external importers —
@@ -151,6 +151,18 @@ export function parseSummaryNotes(notes) {
151
151
  return { done: 'titles', left: 'other', lines: text };
152
152
  }
153
153
 
154
+ /**
155
+ * The `source` attribute the /clear handoff puts on `<session-summary>`: who wrote the row's
156
+ * Done text — `haiku` (the model worker), `report` (the assistant's own final report,
157
+ * extracted at Stop) or `titles` (observation titles, the fast fallback).
158
+ * @param {string|null|undefined} notes session_summaries.notes
159
+ * @returns {'haiku'|'report'|'titles'}
160
+ */
161
+ export function summarySourceLabel(notes) {
162
+ const { done } = parseSummaryNotes(notes);
163
+ return done === 'model' ? 'haiku' : done;
164
+ }
165
+
154
166
  /** Inverse of parseSummaryNotes. `lines` must already be scrubbed. */
155
167
  export function formatSummaryNotes({ done, left, lines }, max) {
156
168
  const head = `done${done} left${left}`;