claude-mem-lite 6.16.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,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.16.0",
12
+ "version": "6.17.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. 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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.16.0",
3
+ "version": "6.17.0",
4
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"
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,19 @@ 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
+
240
253
  ## Upgrading to 6.16.0
241
254
 
242
255
  **Three defaults change; one has an off switch.** No schema change and no migration, so
@@ -575,7 +588,7 @@ lesson_learned, minhash_sig, access_count, compressed_into, search_aliases,
575
588
  branch, superseded_at, superseded_by, last_accessed_at
576
589
  ```
577
590
 
578
- **session_summaries** -- LLM-generated session summaries
591
+ **session_summaries** -- per-session summaries (written at Stop, upgraded by the background model summary)
579
592
  ```
580
593
  id, memory_session_id, project, request, investigated,
581
594
  learned, completed, next_steps, files_read, files_edited, notes,
@@ -644,6 +657,7 @@ Stop
644
657
  -> Flush final episode buffer
645
658
  -> Save handoff snapshot (type 'exit')
646
659
  -> Mark session completed
660
+ -> Write the session summary row (sync, no model call)
647
661
  -> Spawn LLM summary worker (poll-based wait)
648
662
  -> Keep the session file <- Stop fires per TURN; deleting it here re-minted a mem
649
663
  session every turn and left the SessionStart /clear branch unreachable (v5.4.0)
@@ -1072,7 +1086,7 @@ and names can change between releases.
1072
1086
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1073
1087
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1074
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` |
1075
- | `CLAUDE_MEM_SALIENCE` | Selects a comprehension-bridge arm (`bridge`, `bind`); unset = current default behavior. | _(unset)_ |
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)_ |
1076
1090
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1077
1091
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
1078
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,18 @@ 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
+
202
214
  ## 升级到 6.16.0
203
215
 
204
216
  **三处默认行为改变,其中一处有开关。** 没有 schema 变更、不需要迁移,全部回退只需固定
@@ -470,7 +482,7 @@ text, narrative, concepts, facts, files_read, files_modified,
470
482
  importance, related_ids, created_at, created_at_epoch
471
483
  ```
472
484
 
473
- **session_summaries** -- LLM 生成的会话摘要
485
+ **session_summaries** -- 每个会话的摘要(Stop 时写入,后台模型摘要升级)
474
486
  ```
475
487
  id, memory_session_id, project, request, investigated,
476
488
  learned, completed, next_steps, files_read, files_edited, notes
@@ -529,6 +541,7 @@ Stop
529
541
  -> 刷新最终 episode 缓冲区
530
542
  -> 保存交接快照(/exit 时)
531
543
  -> 标记会话为已完成
544
+ -> 写入会话摘要行(同步,不调模型)
532
545
  -> 启动 LLM 摘要 worker(轮询等待)
533
546
  ```
534
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/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,7 +9,7 @@ import { buildSessionContextLines } from './hook-context.mjs';
9
9
  import { inferProject, debugCatch, debugLog } from './utils.mjs';
10
10
  import { RUNTIME_DIR } from './hook-shared.mjs';
11
11
  import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
12
- import { writeCappedHookText } from './lib/hook-text-cap.mjs';
12
+ import { writeCappedHookText, idsShownWhole } from './lib/hook-text-cap.mjs';
13
13
 
14
14
  /**
15
15
  * Build + emit the memory context block on stdout. Writes the Key Context ids
@@ -27,9 +27,9 @@ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_
27
27
  const collector = {};
28
28
  const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
29
29
  const rendered = body && String(body).trim() !== '';
30
- if (rendered) {
31
- writeCappedHookText(`<claude-mem-context>\n${body}\n</claude-mem-context>`);
32
- }
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>`) : '';
33
33
  // Recorded even when NOTHING was re-rendered, matching handleSessionStart — the two
34
34
  // callers must describe the same set (keyctx-marker.mjs header), and the marker is an
35
35
  // exclude-set for what is actually in context. The old empty-body early return left the
@@ -40,7 +40,7 @@ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_
40
40
  runtimeDir,
41
41
  project,
42
42
  sessionId: sessionId || null,
43
- ids: rendered ? collector.keyContextIds || [] : [],
43
+ ids: idsShownWhole(shown, collector.keyContextLines),
44
44
  });
45
45
  } catch (e) {
46
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,8 +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';
102
- import { writePlainHookText, resetPlainHookText } from './lib/hook-text-cap.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';
103
108
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
104
109
  import {
105
110
  entryInputTags,
@@ -1565,9 +1570,12 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1565
1570
  let gate = { gateInjected: null, gateRecalled: null, gateRatio: null };
1566
1571
  try {
1567
1572
  const gateInjectedIds = unionSurfaces(extractInjectedBySurface(transcriptPath, { mainOnly: true }));
1568
- // The nudge asks whether the agent ANSWERED what the hooks showed it, and
1569
- // `#NN n/a — <reason>` is a complete answer: counting it as silence would nag
1570
- // 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.
1571
1579
  const gateCited = extractCitationsFromTranscript(transcriptPath, {
1572
1580
  mainOnly: true,
1573
1581
  includeDismissed: true,
@@ -1745,6 +1753,12 @@ async function handleStop() {
1745
1753
  // waits on, then recreates the sandbox tree behind the test's cleanup. Any
1746
1754
  // grace period for that is a race, not a barrier — the post-tag review timed a
1747
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.
1748
1762
  if (!process.env.CLAUDE_MEM_SKIP_SUMMARY)
1749
1763
  spawnBackground('llm-summary', sessionId, project, String(stopEpoch));
1750
1764
 
@@ -2861,7 +2875,9 @@ async function handleSessionStart() {
2861
2875
  runtimeDir: RUNTIME_DIR,
2862
2876
  project,
2863
2877
  sessionId: ccSessionId,
2864
- 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),
2865
2881
  });
2866
2882
 
2867
2883
  // One-time migration: remove any stale <claude-mem-context> block left in
@@ -103,8 +103,9 @@ export function unanchoredInjectedIdRe() {
103
103
  const CITATION_RE = citationIdRe();
104
104
 
105
105
  // ─── Dismissals ──────────────────────────────────────────────────────────────
106
- // The adoption doc asks the agent to answer every surfaced lesson with `'#NN applied'`
107
- // 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
108
109
  // apply, yet `#NN` alone was the whole citation test, so a dismissal promoted the row
109
110
  // exactly like an application: cited_count + 1, uncited_streak reset, demoted_at
110
111
  // cleared, access_count bumped towards boostAccessed. Measured 2026-09-25T21:40Z over every
@@ -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}`;
@@ -236,7 +236,7 @@ export function flushHookStdout(deps = {}) {
236
236
  if (hasContext || hasInput) {
237
237
  envelope.hookSpecificOutput = { hookEventName: queuedEvent };
238
238
  if (hasInput) envelope.hookSpecificOutput.updatedInput = queuedInput;
239
- if (hasContext) envelope.hookSpecificOutput.additionalContext = capHookText(parts.join('\n\n'));
239
+ if (hasContext) envelope.hookSpecificOutput.additionalContext = renderContext();
240
240
  }
241
241
  parts = [];
242
242
  queuedEvent = null;
@@ -246,6 +246,23 @@ export function flushHookStdout(deps = {}) {
246
246
  return true;
247
247
  }
248
248
 
249
+ /** The additionalContext string the flush writes — one expression, shared with the preview. */
250
+ function renderContext() {
251
+ return capHookText(parts.join('\n\n'));
252
+ }
253
+
254
+ /**
255
+ * The additionalContext the next flush will write, as it will be written — capped. A
256
+ * handler that books rendered rows (SessionStart's Key Context marker) reads this after its
257
+ * last queueHookContext, so it books only what the cap keeps (D#108). '' when nothing is
258
+ * queued for the model.
259
+ *
260
+ * @returns {string}
261
+ */
262
+ export function previewHookContext() {
263
+ return queuedEvent && parts.length > 0 ? renderContext() : '';
264
+ }
265
+
249
266
  /** Test seam: forget anything queued but not yet written. */
250
267
  export function resetHookStdout() {
251
268
  parts = [];
@@ -134,16 +134,42 @@ export function capHookText(text, cap = HOOK_TEXT_CAP) {
134
134
  return out.length <= cap ? out : safeSlice(out, cap);
135
135
  }
136
136
 
137
+ /**
138
+ * Which of `entries` reached the model WHOLE in `shown` (D#108).
139
+ *
140
+ * Every surface books what it injected — the dedup marker, `injection_count`, the Key
141
+ * Context marker — and must book only what the cap kept: a row booked but cut is
142
+ * suppressed for the dedup window as if it had been seen. An entry counts when all of its
143
+ * text survives as whole lines; a row cut short (`…`) or a multi-line item whose tail was
144
+ * dropped does not. Each surface's rows carry their own id, so one row's text cannot stand
145
+ * in for another's.
146
+ *
147
+ * @template T
148
+ * @param {string} shown What the writer actually emitted (the writers below return it).
149
+ * @param {Iterable<{id: T, text: string}>} entries Rendered items, in any order.
150
+ * @returns {T[]} ids of the entries shown whole, in `entries` order.
151
+ */
152
+ export function idsShownWhole(shown, entries) {
153
+ const hay = `\n${String(shown ?? '')}\n`;
154
+ const out = [];
155
+ for (const e of entries || []) {
156
+ if (e && typeof e.text === 'string' && e.text !== '' && hay.includes(`\n${e.text}\n`)) out.push(e.id);
157
+ }
158
+ return out;
159
+ }
160
+
137
161
  /**
138
162
  * Write ONE capped string as a hook's whole plain stdout (PreCompact's single block).
139
163
  *
140
164
  * @param {string} text
141
165
  * @param {{write?: (s: string) => void}} [deps]
142
- * @returns {void}
166
+ * @returns {string} What was written, without the trailing newline — feed it to idsShownWhole.
143
167
  */
144
168
  export function writeCappedHookText(text, deps = {}) {
145
169
  const write = deps.write || ((s) => process.stdout.write(s));
146
- write(`${capHookText(text, HOOK_TEXT_CAP - 1)}\n`);
170
+ const out = capHookText(text, HOOK_TEXT_CAP - 1);
171
+ write(`${out}\n`);
172
+ return out;
147
173
  }
148
174
 
149
175
  // ── plain stdout, several chunks per handler ─────────────────────────────────────────
@@ -160,7 +186,8 @@ let plainUsed = 0;
160
186
  *
161
187
  * @param {string} text Chunk to write; a trailing newline is added.
162
188
  * @param {{write?: (s: string) => void, cap?: number}} [deps]
163
- * @returns {void}
189
+ * @returns {string} What was written, without the trailing newline ('' when nothing was) —
190
+ * feed it to idsShownWhole before booking any row as delivered.
164
191
  */
165
192
  export function writePlainHookText(text, deps = {}) {
166
193
  const write = deps.write || ((s) => process.stdout.write(s));
@@ -169,16 +196,18 @@ export function writePlainHookText(text, deps = {}) {
169
196
  const remaining = cap - plainUsed;
170
197
  if (remaining > RESERVE) {
171
198
  // capHookText returns `body` unchanged when it fits, so this is also the common path.
172
- const out = `${capHookText(body, remaining - 1)}\n`;
173
- write(out);
174
- plainUsed += out.length;
175
- return;
199
+ const out = capHookText(body, remaining - 1);
200
+ write(`${out}\n`);
201
+ plainUsed += out.length + 1;
202
+ return out;
176
203
  }
177
- const note = `${omissionFooter(body.split('\n').filter((l) => l.trim() !== ''))}\n`;
178
- if (note.length <= remaining) {
179
- write(note);
180
- plainUsed += note.length;
204
+ const note = omissionFooter(body.split('\n').filter((l) => l.trim() !== ''));
205
+ if (note.length + 1 <= remaining) {
206
+ write(`${note}\n`);
207
+ plainUsed += note.length + 1;
208
+ return note;
181
209
  }
210
+ return '';
182
211
  }
183
212
 
184
213
  /**
package/mem-cli.mjs CHANGED
@@ -170,6 +170,7 @@ import {
170
170
  searchDeferredWork,
171
171
  formatDeferredSearchTrailer,
172
172
  formatDeferListRow,
173
+ formatDeferMoreHint,
173
174
  countStaleOpen,
174
175
  formatDeferStaleHint,
175
176
  } from './lib/deferred-work.mjs';
@@ -1463,6 +1464,8 @@ function cmdDeferList(db, args) {
1463
1464
  for (const r of list) {
1464
1465
  out(` ${formatDeferListRow(r)}`);
1465
1466
  }
1467
+ const moreHint = formatDeferMoreHint(list, 'raise --limit (max 100)');
1468
+ if (moreHint) out(` ${moreHint}`);
1466
1469
  const staleHint = formatDeferStaleHint(countStaleOpen(db, project));
1467
1470
  if (staleHint) out(` ${staleHint}`);
1468
1471
  // Affordance for the detail field — list stays title-only by design (it is