claude-mem-lite 6.16.0 → 6.17.1
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -3
- package/README.zh-CN.md +15 -2
- package/adopt-content.mjs +5 -4
- package/hook-context.mjs +39 -4
- package/hook-handoff.mjs +6 -3
- package/hook-precompact.mjs +5 -5
- package/hook-update.mjs +18 -4
- package/hook.mjs +22 -6
- package/install.mjs +13 -1
- package/lib/citation-tracker.mjs +3 -2
- package/lib/deferred-work.mjs +27 -2
- package/lib/fast-summary.mjs +12 -0
- package/lib/hook-stdout.mjs +18 -1
- package/lib/hook-text-cap.mjs +40 -11
- package/mem-cli.mjs +3 -0
- package/npm-shrinkwrap.json +217 -206
- package/package.json +6 -6
- package/scripts/pre-tool-recall.js +28 -2
- package/scripts/user-prompt-search.js +60 -29
- package/server.mjs +3 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.17.1",
|
|
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.
|
|
3
|
+
"version": "6.17.1",
|
|
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** --
|
|
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,20 @@ 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` (since 6.17.1
|
|
248
|
+
it also says it overrides the managed row and detail doc, which keep the new wording). Adopted
|
|
249
|
+
projects get the matching CLAUDE.md managed row and detail doc on the next SessionStart;
|
|
250
|
+
`CLAUDE_MEM_NO_TEMPLATE_REFRESH=1` keeps the old text.
|
|
251
|
+
- **SessionStart's "Deferred Work" list ends with "+N more open"** when more than its 5 rows
|
|
252
|
+
are open. No switch; pin 6.16.0 to revert.
|
|
253
|
+
|
|
240
254
|
## Upgrading to 6.16.0
|
|
241
255
|
|
|
242
256
|
**Three defaults change; one has an off switch.** No schema change and no migration, so
|
|
@@ -575,7 +589,7 @@ lesson_learned, minhash_sig, access_count, compressed_into, search_aliases,
|
|
|
575
589
|
branch, superseded_at, superseded_by, last_accessed_at
|
|
576
590
|
```
|
|
577
591
|
|
|
578
|
-
**session_summaries** --
|
|
592
|
+
**session_summaries** -- per-session summaries (written at Stop, upgraded by the background model summary)
|
|
579
593
|
```
|
|
580
594
|
id, memory_session_id, project, request, investigated,
|
|
581
595
|
learned, completed, next_steps, files_read, files_edited, notes,
|
|
@@ -644,6 +658,7 @@ Stop
|
|
|
644
658
|
-> Flush final episode buffer
|
|
645
659
|
-> Save handoff snapshot (type 'exit')
|
|
646
660
|
-> Mark session completed
|
|
661
|
+
-> Write the session summary row (sync, no model call)
|
|
647
662
|
-> Spawn LLM summary worker (poll-based wait)
|
|
648
663
|
-> Keep the session file <- Stop fires per TURN; deleting it here re-minted a mem
|
|
649
664
|
session every turn and left the SessionStart /clear branch unreachable (v5.4.0)
|
|
@@ -1072,7 +1087,7 @@ and names can change between releases.
|
|
|
1072
1087
|
| `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
|
|
1073
1088
|
| `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
|
|
1074
1089
|
| `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
|
|
1090
|
+
| `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 adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
|
|
1076
1091
|
| `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
|
|
1077
1092
|
| `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
|
|
1078
1093
|
|
package/README.zh-CN.md
CHANGED
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
- **Episode 批处理** -- 将相关文件操作分组为连贯的 episode,再进行 LLM 编码
|
|
61
61
|
- **错误触发回忆** -- Bash 出错时自动搜索记忆,浮现相关的历史修复方案
|
|
62
62
|
- **主动文件历史** -- 编辑文件时,自动显示该文件相关的历史观察记录
|
|
63
|
-
- **会话摘要** --
|
|
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`(6.17.1 起它会注明自己优先于托管行和详细文档,那两处保持新写法)。
|
|
210
|
+
已接入的项目会在下次 SessionStart 时把 CLAUDE.md 托管行和详细文档换成对应的新写法;想保留旧文本设 `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** --
|
|
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
|
|
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] …\`
|
|
91
|
-
|
|
90
|
+
以一行 ack 指令重新浮出。看到 \`#NN [bugfix] …\` 这类行时:**某条 lesson 改变了你的做法,就在描述
|
|
91
|
+
那处改动时顺带提一次 \`#NN\`**;没用上的 lesson 不必提,也不要逐条列出。纯工具回合不算;
|
|
92
|
+
把 ID 记在工作记忆里,写回时引用。
|
|
92
93
|
- 系统按会话追踪引用:被引用的 lesson 在召回排序里上浮,被注入却未引用的下沉(有界的排序乘数);
|
|
93
94
|
反复注入却从未被引用的,后台维护会把它的 importance 降到 2(无 lesson 的降到 1)。
|
|
94
|
-
|
|
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)
|
|
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;
|
|
@@ -705,20 +710,36 @@ export function buildSessionContextLines(
|
|
|
705
710
|
// asserted in tests/hook-context.test.mjs rather than left as a claim, because "it
|
|
706
711
|
// only adds" is exactly the kind of sentence this repo keeps finding to be false.
|
|
707
712
|
const { fileLessonQuota, keyContextQuota } = sectionQuotas(fileLessons.length, keyContext.length);
|
|
713
|
+
// The collector records the line as the caller will SEE it: this function's return runs
|
|
714
|
+
// neutralizeContextDelimiters over the whole body, and idsShownWhole matches exact lines,
|
|
715
|
+
// so a row carrying a delimiter tag was shown yet never booked (D#115 P3-1). Matches the
|
|
716
|
+
// block-level pass for any tag closed on its own line. Two cases still differ, and leave a
|
|
717
|
+
// shown row unbooked as before this fix: an UNclosed tag whose attribute tail
|
|
718
|
+
// (CONTEXT_DELIMITER_RE's `[^>]*` does not stop at a newline) runs to a later row's `>` —
|
|
719
|
+
// the rows at the two ends of that span; and the block pass failing closed (32+ nested
|
|
720
|
+
// forged tags, defangToFixpoint strips every `<` / `>`) — any shown row holding `<` or
|
|
721
|
+
// `>`. 0 of 178 live importance>=2 rows carry a tag (2026-09-27).
|
|
722
|
+
const bookedLine = (e) => ({ id: e.id, text: neutralizeContextDelimiters(e.line) });
|
|
708
723
|
|
|
709
724
|
if (fileLessons.length > 0 && !quiet) {
|
|
710
725
|
const shown = fileLessons.slice(0, fileLessonQuota);
|
|
711
726
|
summaryLines.push('### File Lessons');
|
|
712
727
|
summaryLines.push(...shown.map((e) => e.line));
|
|
713
728
|
summaryLines.push('');
|
|
714
|
-
if (collector)
|
|
729
|
+
if (collector) {
|
|
730
|
+
collector.keyContextIds.push(...shown.map((e) => e.id));
|
|
731
|
+
collector.keyContextLines.push(...shown.map(bookedLine));
|
|
732
|
+
}
|
|
715
733
|
}
|
|
716
734
|
if (keyContext.length > 0 && !quiet) {
|
|
717
735
|
const shown = keyContext.slice(0, keyContextQuota);
|
|
718
736
|
summaryLines.push('### Key Context');
|
|
719
737
|
summaryLines.push(...shown.map((e) => e.line));
|
|
720
738
|
summaryLines.push('');
|
|
721
|
-
if (collector)
|
|
739
|
+
if (collector) {
|
|
740
|
+
collector.keyContextIds.push(...shown.map((e) => e.id));
|
|
741
|
+
collector.keyContextLines.push(...shown.map(bookedLine));
|
|
742
|
+
}
|
|
722
743
|
}
|
|
723
744
|
} else if (!latestSummary && !effectiveQuiet()) {
|
|
724
745
|
// Fallback: no summary AND no key observations — show recent activity.
|
|
@@ -851,7 +872,8 @@ export function buildSessionContextLines(
|
|
|
851
872
|
SELECT id, title, priority,
|
|
852
873
|
ROW_NUMBER() OVER (
|
|
853
874
|
ORDER BY priority DESC, created_at_epoch ASC, id ASC
|
|
854
|
-
) AS ordinal
|
|
875
|
+
) AS ordinal,
|
|
876
|
+
COUNT(*) OVER () AS open_total
|
|
855
877
|
FROM deferred_work
|
|
856
878
|
WHERE project = ? AND status = 'open'
|
|
857
879
|
ORDER BY priority DESC, created_at_epoch ASC, id ASC
|
|
@@ -867,6 +889,19 @@ export function buildSessionContextLines(
|
|
|
867
889
|
const pTag = d.priority === 3 ? '🔴' : d.priority === 1 ? '⚪' : '🟡';
|
|
868
890
|
deferredLines.push(`${d.ordinal}. ${pTag} [P${d.priority}] ${truncate(d.title, 120)} (D#${d.id})`);
|
|
869
891
|
}
|
|
892
|
+
// The list is capped at 5 and used to stop there silently, so 10 open items read as a
|
|
893
|
+
// 5-item backlog. The total is the same statement's COUNT(*) OVER (), taken before LIMIT.
|
|
894
|
+
const openTotal = Number(deferredItems[0].open_total);
|
|
895
|
+
const hidden = openTotal - deferredItems.length;
|
|
896
|
+
// Both listing surfaces page at 10, so the line names the knob rather than promising "all".
|
|
897
|
+
// Their maxima differ (mem_defer_list 50, `defer list` 100): past 50 only the CLI can list
|
|
898
|
+
// them, past 100 nothing lists them all (v6.17.1 pre-tag review).
|
|
899
|
+
if (hidden > 0)
|
|
900
|
+
deferredLines.push(
|
|
901
|
+
openTotal <= 50
|
|
902
|
+
? `+${hidden} more open — mem_defer_list / \`defer list\` with a larger limit lists them`
|
|
903
|
+
: `+${hidden} more open — \`defer list --limit 100\` lists ${openTotal <= 100 ? 'them' : 'the first 100'}`,
|
|
904
|
+
);
|
|
870
905
|
deferredLines.push('');
|
|
871
906
|
}
|
|
872
907
|
|
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
|
-
|
|
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).
|
package/hook-precompact.mjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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:
|
|
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
|
-
|
|
79
|
+
const running = pendingCachedUpdate(state);
|
|
80
|
+
if (running) {
|
|
80
81
|
return {
|
|
81
82
|
updateAvailable: true,
|
|
82
83
|
updated: false,
|
|
83
|
-
from:
|
|
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
|
-
|
|
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${
|
|
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. Every cached face (the SessionStart
|
|
184
|
+
// banner, the throttled checkForUpdate and install.mjs `doctor`) asks this, so none can nag alone.
|
|
185
|
+
export 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 {
|
|
102
|
-
|
|
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,
|
|
1569
|
-
// `#NN n/a — <reason>`
|
|
1570
|
-
//
|
|
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
|
-
|
|
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
|
package/install.mjs
CHANGED
|
@@ -2369,7 +2369,19 @@ async function doctor() {
|
|
|
2369
2369
|
if (state.lastCheck) parts.push(`last check: ${state.lastCheck}`);
|
|
2370
2370
|
if (state.latestVersion) parts.push(`latest: v${state.latestVersion}`);
|
|
2371
2371
|
if (state.lastUpdate) parts.push(`last update: ${state.lastUpdate}`);
|
|
2372
|
-
|
|
2372
|
+
// Judged against the version running now, like the banner (#35): in plugin mode
|
|
2373
|
+
// nothing clears the cached flag once Claude Code has applied the update. Dynamic, as
|
|
2374
|
+
// elsewhere in doctor, so a hook-update that cannot load costs only this judgement.
|
|
2375
|
+
if (state.updateAvailable) {
|
|
2376
|
+
let pending = true;
|
|
2377
|
+
try {
|
|
2378
|
+
const { pendingCachedUpdate } = await import('./hook-update.mjs');
|
|
2379
|
+
pending = pendingCachedUpdate(state) !== null;
|
|
2380
|
+
} catch {
|
|
2381
|
+
/* cannot judge — report the cached flag as it stands */
|
|
2382
|
+
}
|
|
2383
|
+
if (pending) parts.push('update pending');
|
|
2384
|
+
}
|
|
2373
2385
|
if (state.rateLimited) parts.push('rate-limited');
|
|
2374
2386
|
if (state.lastError) parts.push(`last error: ${state.lastError}`);
|
|
2375
2387
|
ok(`Update state: ${parts.join(', ') || 'empty'}`);
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -103,8 +103,9 @@ export function unanchoredInjectedIdRe() {
|
|
|
103
103
|
const CITATION_RE = citationIdRe();
|
|
104
104
|
|
|
105
105
|
// ─── Dismissals ──────────────────────────────────────────────────────────────
|
|
106
|
-
// The adoption doc
|
|
107
|
-
// or `'#NN n/a — <reason>'
|
|
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
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -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,29 @@ 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
|
+
* A page already at the surface's maximum gets no `raise` advice: the reader cannot act on
|
|
96
|
+
* it (D#115 P3-7), so the line says which rows are missing instead.
|
|
97
|
+
* @param {Array<{open_total?: number}>} page rows from listOpenWithOrdinal
|
|
98
|
+
* @param {string} raise e.g. `raise --limit (max 100)`
|
|
99
|
+
* @param {number} [max] the surface's largest page
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatDeferMoreHint(page, raise, max = Infinity) {
|
|
103
|
+
const hidden = page.length > 0 ? Number(page[0].open_total) - page.length : 0;
|
|
104
|
+
if (!(hidden > 0)) return '';
|
|
105
|
+
const head = `${hidden} more open item${hidden === 1 ? '' : 's'} not shown`;
|
|
106
|
+
if (page.length >= max) {
|
|
107
|
+
return `${head} — this is already the largest page (${max}); the rest sort last (lowest priority, then newest)`;
|
|
108
|
+
}
|
|
109
|
+
return `${head} — ${raise}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
// ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
|
|
88
113
|
|
|
89
114
|
// Internal const (was exported at v3.51.0 birth with zero external importers —
|
package/lib/fast-summary.mjs
CHANGED
|
@@ -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}`;
|
package/lib/hook-stdout.mjs
CHANGED
|
@@ -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 =
|
|
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 = [];
|
package/lib/hook-text-cap.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
/**
|