claude-mem-lite 6.20.0 → 6.21.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +35 -4
  4. package/README.zh-CN.md +28 -5
  5. package/cli/common.mjs +11 -1
  6. package/hook-context.mjs +14 -8
  7. package/hook-episode.mjs +75 -7
  8. package/hook-handoff.mjs +26 -0
  9. package/hook-llm.mjs +11 -10
  10. package/hook-memory.mjs +4 -8
  11. package/hook-optimize.mjs +27 -5
  12. package/hook-shared.mjs +85 -1
  13. package/hook-update.mjs +5 -2
  14. package/hook.mjs +203 -35
  15. package/install.mjs +187 -30
  16. package/lib/citation-tracker.mjs +71 -2
  17. package/lib/cooldown-path.mjs +13 -0
  18. package/lib/deferred-work.mjs +1 -1
  19. package/lib/delete-core.mjs +34 -5
  20. package/lib/export-columns.mjs +1 -0
  21. package/lib/handoff-constants.mjs +4 -0
  22. package/lib/hook-prune.mjs +53 -17
  23. package/lib/hook-stdin.mjs +7 -1
  24. package/lib/llm-provider-probe.mjs +50 -1
  25. package/lib/maintain-core.mjs +109 -38
  26. package/lib/mcp-ownership.mjs +23 -0
  27. package/lib/observation-write.mjs +6 -1
  28. package/lib/private-strip.mjs +31 -19
  29. package/lib/project-rekey.mjs +178 -0
  30. package/lib/prompt-admission.mjs +68 -0
  31. package/lib/recall-core.mjs +36 -7
  32. package/lib/save-nudge.mjs +3 -2
  33. package/lib/search-core.mjs +4 -0
  34. package/lib/tmp-fixture-sweep.mjs +2 -1
  35. package/lib/verify-apply-core.mjs +9 -2
  36. package/mem-cli.mjs +49 -20
  37. package/npm-shrinkwrap.json +2 -2
  38. package/package.json +4 -1
  39. package/project-utils.mjs +24 -4
  40. package/schema.mjs +32 -1
  41. package/scripts/post-tool-recall.js +5 -3
  42. package/scripts/post-tool-use.sh +46 -20
  43. package/scripts/pre-tool-recall.js +12 -5
  44. package/scripts/prompt-search-utils.mjs +5 -27
  45. package/scripts/setup.sh +7 -4
  46. package/search-scoring.mjs +13 -6
  47. package/server.mjs +58 -9
  48. package/source-files.mjs +5 -0
  49. package/tool-schemas.mjs +14 -1
  50. package/utils.mjs +13 -1
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.20.0",
12
+ "version": "6.21.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.20.0",
3
+ "version": "6.21.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
@@ -88,7 +88,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
88
88
  - **Observation relations** -- Bidirectional links between related observations based on file overlap
89
89
  - **User prompt capture** -- Records user prompts via UserPromptSubmit hook for intent tracking
90
90
  - **Read file tracking** -- Tracks files read during sessions for richer episode context
91
- - **Zero data loss** -- If LLM fails, observations are saved with degraded (inferred) metadata instead of being discarded
91
+ - **Degraded fallback when the LLM fails** -- An episode the deterministic rules already rate notable (an error fixed, a config or schema file changed) is saved with inferred metadata instead of being discarded. A routine edit the rules rate as noise (`Modified a.js, b.js` with no lesson) is dropped — as it is when the model answers without a lesson — so a failing provider loses those episodes; `claude-mem-lite doctor` warns when the `claude` CLI it would call does not resolve
92
92
  - **Two-tier dedup** -- Jaccard similarity (5-minute window) + MinHash signatures (7-day cross-session window) prevent duplicates
93
93
  - **Synonym expansion** -- Abbreviations like `K8s`, `DB`, `auth` automatically expand to full forms in FTS5 search (100+ pairs including CJK↔EN cross-language mappings)
94
94
  - **CJK synonym extraction** -- Unsegmented Chinese text is scanned for known vocabulary words (数据库→database, 搜索→search, etc.) enabling cross-language memory recall
@@ -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.21.0
241
+
242
+ **Projects whose names are not plain ASCII get a new id, and what they stored moves once.** No
243
+ schema-version change: 6.20.0 still opens the database after this release has. Pin
244
+ `claude-mem-lite@6.20.0` before upgrading to avoid the move; after it, 6.20.0 names these
245
+ directories by their old ids again, so their moved rows are listed only with
246
+ `--project <new id>`.
247
+
248
+ - **Which projects.** Every character outside ASCII letters, digits and `_.-` used to become `-`,
249
+ so `~/projects/博客` and `~/projects/商城` were both `projects----` and shared one memory.
250
+ Letters, marks and digits of every script are now kept (`projects--博客`). An id whose parent
251
+ and directory names are both plain ASCII keeps its id byte for byte; a name with letters,
252
+ marks or digits of another script, or with a character outside the Basic Multilingual Plane
253
+ such as `🚀`, changes id.
254
+ - **At a project's first session start its data moves.** When no stored file path shows another
255
+ directory using the old id, the project takes everything under it, deferred items and session
256
+ history included. Otherwise only the memories whose file paths lie inside the directory move;
257
+ the rest stay under the old id. A one-time notice says what moved and how to list what stayed
258
+ (`claude-mem-lite recent 50 --project <old id>`).
259
+ - **Not in this release:** directories with the same parent and name in different repositories
260
+ (`~/a/packages/api` and `~/b/packages/api`) still share one id.
261
+ - **Also changed:** two sessions open in one project keep separate memory sessions (handoffs,
262
+ summaries and unsaved tool activity no longer mix); a session's follow-up prompts no longer
263
+ resume another session; maintenance hides idle memories for 7 days before queuing them for
264
+ deletion, in every project; an importance you set is no longer changed by reads, the access
265
+ boost or re-enrich; `recall` / `mem_recall` rank the current project and the exact path first
266
+ (`--project` / `project` keep one project); `<private>` fails closed on an unclosed, nested or
267
+ attributed tag. Full list: CHANGELOG.md.
268
+
240
269
  ## Upgrading to 6.19.0
241
270
 
242
271
  **Search output changes; no switch.** No schema change and no migration, so reverting is
@@ -580,8 +609,10 @@ Slash commands `/adopt` and `/unadopt` wrap the same CLI.
580
609
  `/exit` + fresh session is enough. Same caveat applies to `unadopt`.
581
610
 
582
611
  **Safety:**
583
- - Hash-guarded: editing the managed-block body yourself blocks automatic
584
- rewrites unless you pass `--force`.
612
+ - Regenerated, not hand-edit-safe: `adopt` rewrites the managed block to the shipped
613
+ template, and so does every SessionStart whenever the block differs from it — hand edits
614
+ included. Keep your own notes outside the `claude-mem-lite:begin…end` markers, or set
615
+ `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1` to freeze the block.
585
616
  - Slug-scoped & dedup-guarded: only the `claude-mem-lite:begin…end` region is
586
617
  ever rewritten, and duplicate / CRLF-orphaned copies are collapsed to one.
587
618
  Unlike the legacy `MEMORY.md` scheme there is no line-budget gate — `CLAUDE.md`
@@ -708,7 +739,7 @@ Episodes are batched related operations (edits to the same file group) that get
708
739
  Episode buffer -> Flush to JSON -> claude -p --model haiku -> Structured observation -> SQLite
709
740
  ```
710
741
 
711
- Each observation includes type, title, narrative, concepts, facts, importance (1-3), and is automatically deduplicated via two tiers: Jaccard similarity (>70% within 5 minutes) and MinHash signatures (>80% within 7 days across sessions). If the LLM call fails, a degraded observation is saved with inferred metadata (zero data loss). Related observations are linked via `related_ids` based on FTS5 title similarity and file overlap.
742
+ Each observation includes type, title, narrative, concepts, facts, importance (1-3), and is automatically deduplicated via two tiers: Jaccard similarity (>70% within 5 minutes) and MinHash signatures (>80% within 7 days across sessions). If the LLM call fails, an episode the rules rate notable is saved with inferred metadata; a routine edit the rules rate as noise is dropped. Related observations are linked via `related_ids` based on FTS5 title similarity and file overlap.
712
743
 
713
744
  ## Management Commands
714
745
 
package/README.zh-CN.md CHANGED
@@ -67,7 +67,7 @@
67
67
  - **观察关联** -- 基于文件重叠自动建立观察之间的双向链接
68
68
  - **用户提示捕获** -- 通过 UserPromptSubmit 钩子记录用户提示,追踪用户意图
69
69
  - **Read 文件追踪** -- 追踪会话中读取的文件,丰富 episode 上下文
70
- - **零数据丢失** -- LLM 失败时,使用推断的元数据保存降级记录,而非丢弃
70
+ - **LLM 失败时的降级保存** -- 规则已判定为有价值的 episode(修掉了报错、改了配置或 schema 文件)用推断的元数据保存,而非丢弃;规则判为噪声的日常编辑(`Modified a.js, b.js` 且没有 lesson)会被丢掉——模型成功但没给 lesson 时也一样——所以 LLM 不可用期间这类 episode 会丢失。`claude-mem-lite doctor` 会在要调用的 `claude` CLI 不存在时告警
71
71
  - **两级去重** -- Jaccard 相似度(5 分钟窗口)+ MinHash 签名(7 天跨会话窗口)双重防重
72
72
  - **同义词扩展** -- 缩写如 `K8s`、`DB`、`auth` 在 FTS5 搜索时自动扩展为全称(48+ 对)
73
73
  - **伪相关反馈(PRF)** -- 首轮结果作为种子扩展查询,提升召回率
@@ -199,6 +199,27 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.21.0
203
+
204
+ **名字不是纯 ASCII 的项目会换一个新标识,它们存下的内容会搬一次。** 没有 schema 版本变更:
205
+ 本版本打开过的数据库,6.20.0 仍能打开。想避免搬迁,请在**升级之前**固定 `claude-mem-lite@6.20.0`;
206
+ 升级之后再回退,6.20.0 会按旧标识称呼这些目录,搬走的行只能用 `--project <新标识>` 列出。
207
+
208
+ - **哪些项目。** 以前 ASCII 字母、数字和 `_.-` 以外的字符都变成 `-`,所以 `~/projects/博客` 和
209
+ `~/projects/商城` 都是 `projects----`,共用一份记忆。现在任何文字的字母、组合符和数字都会保留
210
+ (`projects--博客`)。父目录名和目录名都是纯 ASCII 的,标识逐字节不变;名字里有其他文字的字母、
211
+ 组合符或数字,或有基本多文种平面以外的字符(如 `🚀`)时,标识会变。
212
+ - **项目第一次启动会话时搬迁数据。** 如果旧标识下没有任何文件路径显示另一个同样使用该旧标识的目录,
213
+ 就把旧标识下的全部内容搬过来,包括待办和会话历史。否则只搬文件路径落在本目录里的记忆,其余留在
214
+ 旧标识下。会提示一次搬了什么,以及怎么列出留下的内容(`claude-mem-lite recent 50 --project <旧标识>`)。
215
+ - **本版本不包含:** 不同仓库里父目录和目录名都相同的目录(`~/a/packages/api` 与 `~/b/packages/api`)
216
+ 仍共用一个标识。
217
+ - **其他变化:** 同一项目同时开两个会话,各自的交接、摘要和未保存的工具记录不再混在一起;会话里的
218
+ 跟进提示不再续上别的会话;维护会先把闲置记忆隐藏 7 天,再排队删除,所有项目一致;你设置的 importance
219
+ 不再被读取、访问提升或 re-enrich 改动;`recall` / `mem_recall` 当前项目和完全匹配的路径排在前面
220
+ (`--project` / `project` 只看一个项目);`<private>` 在未闭合、嵌套或带属性时一律按私密处理。
221
+ 完整列表见 CHANGELOG.md。
222
+
202
223
  ## 升级到 6.19.0
203
224
 
204
225
  **搜索输出有变化,没有开关。** 没有 schema 变更、不需要迁移,回退只需固定 `claude-mem-lite@6.18.0`。
@@ -479,9 +500,11 @@ Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
479
500
  同理。
480
501
 
481
502
  **安全性:**
482
- - Hash 守护:你手动改了 sentinel 段 → 下一次 adopt 报 `UserEditedError`,
483
- 除非显式 `--force`。
484
- - 预算门:MEMORY.md 已 >180 行时拒绝新增(避开 Claude Code 200 行截断)。
503
+ - 托管块会被重新生成,不保留手改:`adopt` 会把它改写成出货模板,每次 SessionStart
504
+ 只要它和模板不同也会改写——手动编辑的内容一样被覆盖。自己的笔记写在
505
+ `claude-mem-lite:begin…end` 标记之外,或设 `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1` 冻结。
506
+ - 按 slug 限定、去重:只改写 `claude-mem-lite:begin…end` 这一段,重复或 CRLF 残留的
507
+ 副本会合并成一份。旧的 `MEMORY.md` 方案才有行数预算,`CLAUDE.md` 没有截断上限。
485
508
  - **任何安装路径每次 SessionStart 都自动 adopt,且不再把托管块加进 `CLAUDE.md`(6.19.4
486
509
  之后的下一个版本起)。** 没有托管块的 git 项目,引导写进 git 根目录的 `CLAUDE.local.md`
487
510
  (经 `.git/info/exclude` 排除在 git 之外),不在 git 里时注入 SessionStart 上下文;带
@@ -579,7 +602,7 @@ Episode 是一批相关操作(对同一组文件的编辑),由后台 LLM w
579
602
  Episode 缓冲区 -> 刷新为 JSON -> claude -p --model haiku -> 结构化观察 -> SQLite
580
603
  ```
581
604
 
582
- 每条观察包含类型、标题、叙述、概念、事实和重要度(1-3),并通过两级机制自动去重:Jaccard 相似度(5 分钟内 >70%)和 MinHash 签名(7 天跨会话 >80%)。LLM 调用失败时,使用推断的元数据保存降级记录(零数据丢失)。相关观察通过 FTS5 标题相似度和文件重叠自动建立 `related_ids` 链接。
605
+ 每条观察包含类型、标题、叙述、概念、事实和重要度(1-3),并通过两级机制自动去重:Jaccard 相似度(5 分钟内 >70%)和 MinHash 签名(7 天跨会话 >80%)。LLM 调用失败时,规则判定有价值的 episode 用推断的元数据保存降级记录,规则判为噪声的日常编辑会被丢掉。相关观察通过 FTS5 标题相似度和文件重叠自动建立 `related_ids` 链接。
583
606
 
584
607
  ## 管理命令
585
608
 
package/cli/common.mjs CHANGED
@@ -559,7 +559,17 @@ export function obsFieldLabel(field) {
559
559
  * @returns {string} the full indented line, identical on both surfaces.
560
560
  */
561
561
  export function formatPendingPurgeLine(n) {
562
- return ` Pending purge (idle-marked): ${n} (live originals marked idle by decay — purge_stale deletes them)`;
562
+ return ` Pending purge (idle-marked): ${n} (rows maintenance queued: hidden and still idle 7 days later, or queued by an earlier version — purge_stale deletes them)`;
563
+ }
564
+
565
+ /**
566
+ * D12: the step before pending-purge. Maintenance HIDES an idle row first (kept, reachable by
567
+ * id) and queues it for purge only once it stays idle through the grace.
568
+ * @param {number} n stats.hidden
569
+ * @returns {string} the full indented line, identical on both surfaces.
570
+ */
571
+ export function formatHiddenLine(n) {
572
+ return ` Hidden by maintenance: ${n} (idle rows kept for 7 days; still idle then → pending purge)`;
563
573
  }
564
574
 
565
575
  // Pure formatter — null/undefined/non-time pass through; integer time fields
package/hook-context.mjs CHANGED
@@ -956,17 +956,23 @@ export function buildSummaryLines(latestSummary) {
956
956
  if (latestSummary.completed) lines.push(`Completed: ${truncate(latestSummary.completed, 120)}`);
957
957
  if (latestSummary.remaining_items) lines.push(`Remaining: ${truncate(latestSummary.remaining_items, 120)}`);
958
958
  if (latestSummary.next_steps) lines.push(`Next: ${truncate(latestSummary.next_steps, 120)}`);
959
- if (latestSummary.lessons) {
959
+ // String items only: rows written before the summary worker filtered its reply can hold
960
+ // [123, null, {…}] and rendered as "Lessons: 123; ; [object Object]".
961
+ const textItems = (json) => {
960
962
  try {
961
- const lessons = JSON.parse(latestSummary.lessons);
962
- if (lessons.length > 0) lines.push(`Lessons: ${lessons.slice(0, 3).join('; ')}`);
963
- } catch {}
963
+ const v = JSON.parse(json);
964
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()) : [];
965
+ } catch {
966
+ return [];
967
+ }
968
+ };
969
+ if (latestSummary.lessons) {
970
+ const lessons = textItems(latestSummary.lessons);
971
+ if (lessons.length > 0) lines.push(`Lessons: ${lessons.slice(0, 3).join('; ')}`);
964
972
  }
965
973
  if (latestSummary.key_decisions) {
966
- try {
967
- const decisions = JSON.parse(latestSummary.key_decisions);
968
- if (decisions.length > 0) lines.push(`Decisions: ${decisions.slice(0, 3).join('; ')}`);
969
- } catch {}
974
+ const decisions = textItems(latestSummary.key_decisions);
975
+ if (decisions.length > 0) lines.push(`Decisions: ${decisions.slice(0, 3).join('; ')}`);
970
976
  }
971
977
  lines.push('');
972
978
  return lines;
package/hook-episode.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // claude-mem-lite episode buffer management
2
2
  // Handles file-based episode storage with advisory locking and pending entry recovery
3
3
 
4
- import { join } from 'path';
4
+ import { join, basename } from 'path';
5
5
  import {
6
6
  readFileSync,
7
7
  writeFileSync,
@@ -12,10 +12,13 @@ import {
12
12
  writeSync,
13
13
  renameSync,
14
14
  statSync,
15
+ existsSync,
15
16
  constants as fsConstants,
16
17
  } from 'fs';
17
18
  import { inferProject, isEditEntry } from './utils.mjs';
18
- import { RUNTIME_DIR } from './hook-shared.mjs';
19
+ import { RUNTIME_DIR, hostScopeSuffix, deadHostFiles, isOtherLiveHost } from './hook-shared.mjs';
20
+ import { inferProjectDir } from './project-utils.mjs';
21
+ import { legacyProjectNameFromDir } from './lib/project-rekey.mjs';
19
22
 
20
23
  /**
21
24
  * Read the episode buffer WITHOUT holding the lock: the dying-process salvage in hook.mjs's
@@ -39,11 +42,70 @@ export function readEpisodeRaw() {
39
42
  }
40
43
 
41
44
  /**
42
- * Get the path to the current project's episode buffer file.
45
+ * Get the path to the episode buffer of this project AND this Claude Code process
46
+ * (`hostScopeSuffix`, D14): two sessions open in one project each buffer their own work.
47
+ * The parameters exist for orphanEpisodeFiles, which must name buffers of OTHER processes
48
+ * without a second spelling of the name; every other caller takes the defaults.
49
+ * @param {string} [host] a hostScopeSuffix() value; '' is the per-project name
50
+ * @param {string} [project]
43
51
  * @returns {string} Absolute path to the episode JSON file
44
52
  */
45
- export function episodeFile() {
46
- return join(RUNTIME_DIR, `ep-${inferProject()}.json`);
53
+ export function episodeFile(host = hostScopeSuffix(), project = inferProject()) {
54
+ return join(RUNTIME_DIR, `ep-${project}${host}.json`);
55
+ }
56
+
57
+ /**
58
+ * The file scripts/post-tool-use.sh appends a Read's path to, which the next saving flush
59
+ * collects into `files_read`. With a host pid it is the process's own and names no project
60
+ * (the process pins CLAUDE_PROJECT_DIR), which is what lets bash spell it for a project in any
61
+ * script; without one it is the project's. The bash spelling of the same rule is in
62
+ * post-tool-use.sh (tests/reads-file-name-cross-language.test.mjs).
63
+ * @param {string} [project]
64
+ * @returns {string}
65
+ */
66
+ export function readsFile(project = inferProject(), host = hostScopeSuffix()) {
67
+ return join(RUNTIME_DIR, `reads-${host || project}.txt`);
68
+ }
69
+
70
+ /**
71
+ * The reads file that went with an orphan buffer (orphanEpisodeFiles), named by the same rule
72
+ * as readsFile: `ep-<id>@h<pid>.json` read into `reads-@h<pid>.txt`, a per-project
73
+ * `ep-<id>.json` into `reads-<id>.txt`. `@` never occurs in a project id.
74
+ * @param {string} orphanPath
75
+ * @returns {string}
76
+ */
77
+ export function orphanReadsFile(orphanPath) {
78
+ const key = basename(orphanPath).slice('ep-'.length, -'.json'.length);
79
+ const at = key.indexOf('@h');
80
+ return at === -1 ? readsFile(key, '') : readsFile(key.slice(0, at), key.slice(at));
81
+ }
82
+
83
+ /**
84
+ * Episode buffers of this project that no live session will ever flush: the per-project
85
+ * buffer an older version wrote, and the buffer of a Claude Code process that has exited —
86
+ * a host closed mid-turn never reaches Stop. Before D14 the next session in the project
87
+ * flushed the one shared buffer at SessionStart; with per-process buffers nobody else reads
88
+ * these, so SessionStart adopts them instead of leaving them to the 7-day sweep.
89
+ *
90
+ * Without a host pid the per-project buffer is this process's own, so only the pre-D9 one is
91
+ * an orphan.
92
+ *
93
+ * @param {string} [project]
94
+ * @returns {string[]} absolute paths
95
+ */
96
+ export function orphanEpisodeFiles(project = inferProject()) {
97
+ const own = hostScopeSuffix();
98
+ const out = [];
99
+ // Per-project buffers: this id's (an older version's, unless it is this process's own because
100
+ // there is no host pid), and the pre-D9 id's — a non-Latin project buffered under
101
+ // `ep-projects----.json` before its id changed, and nothing looks under that name again.
102
+ for (const id of new Set([project, legacyProjectNameFromDir(inferProjectDir())])) {
103
+ if (!own && id === project) continue;
104
+ const file = episodeFile('', id);
105
+ if (existsSync(file)) out.push(file);
106
+ }
107
+ if (own) out.push(...deadHostFiles(basename(episodeFile('', project)).slice(0, -'.json'.length), '.json'));
108
+ return out;
47
109
  }
48
110
 
49
111
  /**
@@ -237,7 +299,10 @@ export function writePendingEntry(entry, sessionId, project) {
237
299
  const pendingFile = join(RUNTIME_DIR, `pending-${ts}-${rand}.json`);
238
300
  const tmp = pendingFile + '.tmp';
239
301
  try {
240
- writeFileSync(tmp, JSON.stringify({ entry, sessionId, project, ts }), { mode: 0o600 });
302
+ // `host`: the process whose buffer this entry belongs to (D14) — merged only into that one.
303
+ writeFileSync(tmp, JSON.stringify({ entry, sessionId, project, ts, host: hostScopeSuffix() }), {
304
+ mode: 0o600,
305
+ });
241
306
  renameSync(tmp, pendingFile);
242
307
  } catch {
243
308
  try {
@@ -282,8 +347,11 @@ export function mergePendingEntries(episode) {
282
347
  } catch {}
283
348
  continue;
284
349
  }
285
- // Only merge entries belonging to the same project
350
+ // Only merge entries belonging to the same project, and not another RUNNING process's
351
+ // (D14): its own next flush takes those. A gone process's spill is merged here, as the
352
+ // shared buffer's was before D14. An entry written before `host` existed merges as ever.
286
353
  if (pending.project && episode.project && pending.project !== episode.project) continue;
354
+ if (typeof pending.host === 'string' && isOtherLiveHost(pending.host)) continue;
287
355
  if (pending.entry) {
288
356
  unlinkSync(fp);
289
357
  episode.entries.push(pending.entry);
package/hook-handoff.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  CONTINUE_KEYWORDS,
26
26
  UNCONSUMED_HANDOFF_SQL,
27
27
  } from './hook-shared.mjs';
28
+ import { RESUME_PAST_KEYWORDS } from './lib/handoff-constants.mjs';
28
29
  // T10d: import the whole module (not a named export) so tests can spy on
29
30
  // gitStateModule.readGitState via vi.spyOn. Named-import bindings are
30
31
  // immutable in ESM and cannot be mocked after the fact.
@@ -704,6 +705,31 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
704
705
  if (!promptText || typeof promptText !== 'string') return false;
705
706
  if (promptText.trim().length < 2) return false;
706
707
 
708
+ // A session's own follow-ups are not a resume of ANOTHER session. The stages below counted
709
+ // this session's OWN exit handoff (written by its previous Stop) as evidence, while
710
+ // pickHandoffToInject excludes own exit rows and returns another session's newest one — so
711
+ // `ok do it` after a `继续` injected (and consumed) a second, unrelated session's handoff,
712
+ // and a session that had started a new task got yesterday's on a short follow-up.
713
+ if (currentCcSessionId) {
714
+ const firstPromptAt = db
715
+ .prepare('SELECT MIN(created_at_epoch) AS m FROM user_prompts WHERE cc_session_id = ?')
716
+ .get(currentCcSessionId)?.m;
717
+ // One resume per session: a handoff consumed in this PROJECT since this session's first
718
+ // prompt ends this session's resumes — by this session, or by a concurrent one (the table has
719
+ // no consumed-by column; a known limit, deferred).
720
+ if (
721
+ typeof firstPromptAt === 'number' &&
722
+ db
723
+ .prepare('SELECT 1 FROM session_handoffs WHERE project = ? AND consumed_at >= ?')
724
+ .get(project, firstPromptAt)
725
+ )
726
+ return false;
727
+ const endedATurn = db
728
+ .prepare(`SELECT 1 FROM session_handoffs WHERE project = ? AND type = 'exit' AND session_id = ?`)
729
+ .get(project, currentCcSessionId);
730
+ if (endedATurn) return RESUME_PAST_KEYWORDS.test(promptText);
731
+ }
732
+
707
733
  // T10d Stage -1: Git-commit anchor — current HEAD == a stored
708
734
  // git_sha_at_handoff ⇒ working tree hasn't moved since the handoff.
709
735
  //
package/hook-llm.mjs CHANGED
@@ -1601,24 +1601,25 @@ ${obsList}`;
1601
1601
  // empty request: INSERT writes '' and the UPDATE keeps the row's own request (or an older row's)
1602
1602
  // when the reply's is empty. Use asText in the gate so a non-string / empty-array field can't falsely
1603
1603
  // trigger it.
1604
+ // Items are kept only when they are non-empty strings. Array.isArray alone stored a reply of
1605
+ // [123, null, {…}, "real lesson"] as-is, and SessionStart then injected
1606
+ // "Lessons: 123; ; [object Object]", pushing the real lesson out of its 3-item window.
1607
+ const textItems = (v) =>
1608
+ Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : [];
1609
+ const lessons = llmParsed ? textItems(llmParsed.lessons) : [];
1610
+ const keyDecisions = llmParsed ? textItems(llmParsed.key_decisions) : [];
1604
1611
  const hasSummaryContent =
1605
1612
  llmParsed &&
1606
1613
  (asText(llmParsed.request) ||
1607
1614
  asText(llmParsed.completed) ||
1608
1615
  asText(llmParsed.remaining_items) ||
1609
1616
  asText(llmParsed.next_steps) ||
1610
- (Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0) ||
1611
- (Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0));
1617
+ lessons.length > 0 ||
1618
+ keyDecisions.length > 0);
1612
1619
  if (hasSummaryContent) {
1613
1620
  const now = new Date();
1614
- const lessonsJson =
1615
- Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0
1616
- ? JSON.stringify(llmParsed.lessons)
1617
- : null;
1618
- const decisionsJson =
1619
- Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0
1620
- ? JSON.stringify(llmParsed.key_decisions)
1621
- : null;
1621
+ const lessonsJson = lessons.length > 0 ? JSON.stringify(lessons) : null;
1622
+ const decisionsJson = keyDecisions.length > 0 ? JSON.stringify(keyDecisions) : null;
1622
1623
 
1623
1624
  // Upgrade the session's summary row instead of creating another. This worker runs after
1624
1625
  // EVERY Stop (one per assistant turn) and again from SessionStart's /clear path; selecting
package/hook-memory.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  neutralizeContextDelimiters,
12
12
  } from './utils.mjs';
13
13
  import { upsFtsQuery, upsQueryTerms } from './lib/ups-query.mjs';
14
+ import { meetsRecallLengthFloor } from './lib/prompt-admission.mjs';
14
15
  import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
15
16
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
16
17
  import { recordMetric } from './lib/metrics.mjs';
@@ -280,14 +281,9 @@ export function searchRelevantMemories(
280
281
  excludeIds = [],
281
282
  { counterfactual = false } = {},
282
283
  ) {
283
- // Min-length guard is English-centric: 5 chars ≈ one short English word. A CJK
284
- // query is meaningful at 2 chars (状态/架构) and most real Chinese queries are
285
- // 2-4 chars (状态管理, 召回率, 熔断降级) — the bare `.length < 5` silently
286
- // rejected ALL of them, so a Chinese-primary user got zero memory injection.
287
- // Apply the 5-char floor only to non-CJK queries; CJK needs ≥2.
288
- if (!db || !userPrompt) return [];
289
- const queryHasCjk = /[一-鿿㐀-䶿]/.test(userPrompt);
290
- if (userPrompt.length < (queryHasCjk ? 2 : 5)) return [];
284
+ // Min-length guard: 5 chars for non-CJK, 2 for CJK. The rationale and the one definition
285
+ // are in lib/prompt-admission.mjs, shared with hook.mjs's events arm (issue #39).
286
+ if (!db || !meetsRecallLengthFloor(userPrompt)) return [];
291
287
  // CJK-DOMINANT (not merely CJK-containing) gates the OR-fallback bypass below.
292
288
  // A substring test would let one incidental CJK char — an IME-leaked particle,
293
289
  // a 中文 noun in an otherwise-English prompt — flip OR-fallback on and inject
package/hook-optimize.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  debugLog,
14
14
  debugCatch,
15
15
  COMPRESSED_AUTO,
16
+ NOT_COMPRESSION_KEEPER_SQL,
16
17
  computeMinHash,
17
18
  estimateJaccardFromMinHash,
18
19
  jaccardSimilarity,
@@ -481,7 +482,24 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
481
482
  // reachable by no auto-recovery pass — so one Haiku "importance 0" misjudgment would
482
483
  // hide a real observation until manual surgery. In wide scope, fall through and let
483
484
  // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
484
- if ((parsed.importance === 0 || parsed.importance === '0') && scope !== 'wide') {
485
+ // A compression keeper is never hidden: hiding it hides every member compressed into it
486
+ // (the same rule NOT_COMPRESSION_KEEPER_SQL enforces on the maintenance writers). It falls
487
+ // through to the normal update instead: floored to importance 1 and stamped, so it is not
488
+ // re-sent. In narrow scope that update still replaces its title and narrative.
489
+ const isKeeper = !!db
490
+ .prepare('SELECT 1 FROM observations WHERE compressed_into = ? LIMIT 1')
491
+ .get(cand.id);
492
+ // D10: an importance a person set (importance_set_at) is theirs — neither hidden on a
493
+ // model's 0 nor re-scored below.
494
+ const humanSet =
495
+ (db.prepare('SELECT importance_set_at FROM observations WHERE id = ?').get(cand.id)
496
+ ?.importance_set_at ?? null) !== null;
497
+ if (
498
+ (parsed.importance === 0 || parsed.importance === '0') &&
499
+ scope !== 'wide' &&
500
+ !isKeeper &&
501
+ !humanSet
502
+ ) {
485
503
  // D#12, and this one is not a stale-write guard — it is a POINTER guard.
486
504
  // `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
487
505
  // concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
@@ -493,7 +511,7 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
493
511
  const res = db
494
512
  .prepare(
495
513
  `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
496
- WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL`,
514
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL AND ${NOT_COMPRESSION_KEEPER_SQL}`,
497
515
  )
498
516
  .run(Date.now(), cand.id);
499
517
  if (res.changes === 0) {
@@ -552,8 +570,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
552
570
  : cand.narrative || '';
553
571
  // Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
554
572
  // a user-set/promoted importance (the UPDATE also sets optimized_at → the loss is permanent).
555
- // Upgrades are still honored.
556
- const importance = Math.max(clampImportance(parsed.importance), cand.importance || 1);
573
+ // Upgrades are still honored — except over an importance a person set (D10), which stays.
574
+ const importance = humanSet
575
+ ? cand.importance
576
+ : Math.max(clampImportance(parsed.importance), cand.importance || 1);
557
577
 
558
578
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
559
579
  const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
@@ -1250,7 +1270,9 @@ Return ONLY valid JSON:
1250
1270
  const factsText = facts.length ? facts.join(' ') : keeper.facts || '';
1251
1271
  // Scrub BEFORE truncate (see re-enrich note): keep the boundary cut on
1252
1272
  // already-scrubbed text so a straddling secret can't leak a sub-floor head.
1253
- const title = truncate(scrubSecrets(parsed.merged_title || ''), 120);
1273
+ // Preserve-on-empty for the title too, like narrative/concepts/facts: `{"should_merge":true}`
1274
+ // alone blanked the keeper's title and it listed as "(untitled)".
1275
+ const title = truncate(scrubSecrets(parsed.merged_title || keeper.title || ''), 120);
1254
1276
  const narrative = truncate(scrubSecrets(parsed.merged_narrative || keeper.narrative || ''), 800);
1255
1277
  // Preserve-on-empty. The merge overwrites the keeper in place and hides every non-keeper
1256
1278
  // member (compressed_into=keeper.id), so if the LLM returns merged_lesson:null (the prompt
package/hook-shared.mjs CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { isDbUnusableError, DB_UNUSABLE_MARKER_PREFIX } from './lib/db-unusable.mjs';
32
32
  import { shouldRecordOnce } from './lib/record-once.mjs';
33
33
  import { hookSessionId } from './lib/provenance.mjs';
34
+ import { PROJECT_REKEY_MARKER_PREFIX } from './lib/project-rekey.mjs';
34
35
  // Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
35
36
  // predicates and the handoff constants moved into `lib/` because two lib modules
36
37
  // imported them from here and dragged this file's whole import graph — haiku-client,
@@ -265,6 +266,8 @@ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
265
266
  '.adopt-offered-',
266
267
  // r3: the one-time note that CLAUDE.local.md was written. Deleting it would repeat the note.
267
268
  '.local-steering-noted-',
269
+ // D9: the one-time re-key of a project's rows off its pre-D9 id, and its one-time note.
270
+ PROJECT_REKEY_MARKER_PREFIX,
268
271
  '.deferred-block-migrated-',
269
272
  '.legacy-claude-md-cleaned-',
270
273
  // v3.66.1: these two shipped in the GC list for one release and had to come
@@ -376,8 +379,89 @@ try {
376
379
 
377
380
  // ─── Session ID Management ───────────────────────────────────────────────────
378
381
 
382
+ /**
383
+ * The Claude Code process this hook runs under, as a file-name suffix: `@h<pid>`, or '' when
384
+ * the host did not say.
385
+ *
386
+ * D14: the session file and the episode buffer were keyed by PROJECT, so a second session opened
387
+ * in the same project overwrote the first one's session file, and every later hook of the first
388
+ * session ran under the second one's id — its handoff "Working On" was the other session's
389
+ * prompt, one summary row stood for two sessions, and one session's Stop flushed the other's
390
+ * work in progress. The host PROCESS is the key, not the host session_id, because /clear rotates
391
+ * session_id inside the same process and SessionStart's /clear branch has to find the session
392
+ * that was just cleared: that is this process's file.
393
+ *
394
+ * `CLAUDE_PID` is set by Claude Code on hook subprocesses (observed 2026-09-29: the hooks of two
395
+ * concurrent sessions each saw their own host's pid, and it is inherited by spawnBackground
396
+ * workers). It is not in the documented hook contract, so absent or malformed it degrades to ''
397
+ * — the per-project names this replaced, byte for byte. `@` cannot occur in a project id
398
+ * (projectNameFromDir keeps only letters, marks, digits and `_.-`), so a suffixed name never
399
+ * collides with another project's plain one. scripts/post-tool-use.sh mirrors this rule for
400
+ * the reads file.
401
+ *
402
+ * @param {Record<string, string|undefined>} [env]
403
+ * @returns {string}
404
+ */
405
+ export function hostScopeSuffix(env = process.env) {
406
+ const pid = env.CLAUDE_PID;
407
+ return typeof pid === 'string' && /^[1-9]\d{0,9}$/.test(pid) ? `@h${pid}` : '';
408
+ }
409
+
379
410
  export function sessionFile() {
380
- return join(RUNTIME_DIR, `session-${inferProject()}`);
411
+ return join(RUNTIME_DIR, `session-${inferProject()}${hostScopeSuffix()}`);
412
+ }
413
+
414
+ /** True unless `pid` is certainly gone (ESRCH); EPERM means it exists under another user. */
415
+ function processAlive(pid) {
416
+ try {
417
+ process.kill(pid, 0);
418
+ return true;
419
+ } catch (e) {
420
+ return e.code !== 'ESRCH';
421
+ }
422
+ }
423
+
424
+ /**
425
+ * True when `suffix` (a hostScopeSuffix() value) names a Claude Code process other than this one
426
+ * that is still running — whose buffer, and lock-contention spill, are its own to flush. False
427
+ * for this process, for a gone one, and for '' (a writer that had no host pid).
428
+ *
429
+ * @param {string} suffix
430
+ * @returns {boolean}
431
+ */
432
+ export function isOtherLiveHost(suffix) {
433
+ const m = /^@h([1-9]\d{0,9})$/.exec(String(suffix));
434
+ return !!m && suffix !== hostScopeSuffix() && processAlive(Number(m[1]));
435
+ }
436
+
437
+ /**
438
+ * Runtime files `<stem>@h<pid><ext>` left by a Claude Code process that has exited — files no
439
+ * live session will read again (D14). Never this process's own, and none at all when this
440
+ * process has no host pid. A recycled pid reads as alive, so such a file waits for the
441
+ * age-based sweeps instead: the error is towards keeping.
442
+ *
443
+ * @param {string} stem e.g. `ep-<project>`
444
+ * @param {string} [ext] e.g. `.json`
445
+ * @returns {string[]} absolute paths
446
+ */
447
+ export function deadHostFiles(stem, ext = '') {
448
+ const own = hostScopeSuffix();
449
+ if (!own) return [];
450
+ let names;
451
+ try {
452
+ names = readdirSync(RUNTIME_DIR);
453
+ } catch {
454
+ return [];
455
+ }
456
+ const prefix = `${stem}@h`;
457
+ const out = [];
458
+ for (const f of names) {
459
+ if (!f.startsWith(prefix) || !f.endsWith(ext)) continue;
460
+ const pid = f.slice(prefix.length, f.length - ext.length);
461
+ if (!/^[1-9]\d{0,9}$/.test(pid) || `@h${pid}` === own || processAlive(Number(pid))) continue;
462
+ out.push(join(RUNTIME_DIR, f));
463
+ }
464
+ return out;
381
465
  }
382
466
 
383
467
  export function getSessionId() {
package/hook-update.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Checks for new versions on SessionStart, downloads and installs automatically.
3
3
  // Skips in dev mode (symlinked installs). Silent on network failure.
4
4
 
5
+ import { isOurMcpRegistration } from './lib/mcp-ownership.mjs';
5
6
  import { execSync, execFileSync } from 'node:child_process';
6
7
  import {
7
8
  readFileSync,
@@ -1053,14 +1054,16 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1053
1054
  // Post-update migration: clean stale global MCPs if plugin handles it.
1054
1055
  // Both "mem" (legacy, pre-v2.78) and "mem-lite" (current) are purged so a
1055
1056
  // user who manually ran `claude mcp add` in either era doesn't end up with
1056
- // duplicate global + plugin registrations after the rename.
1057
+ // duplicate global + plugin registrations after the rename — "mem" only when it runs
1058
+ // our server (lib/mcp-ownership.mjs): the name is generic, and a user's own `mem`
1059
+ // server was deleted on every plugin update that re-synced a direct install.
1057
1060
  try {
1058
1061
  if (isPluginMode()) {
1059
1062
  const claudeJsonPath = join(homedir(), '.claude.json');
1060
1063
  const cfg = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
1061
1064
  let changed = false;
1062
1065
  for (const k of ['mem', 'mem-lite']) {
1063
- if (cfg.mcpServers?.[k]) {
1066
+ if (cfg.mcpServers?.[k] && isOurMcpRegistration(k, cfg.mcpServers[k])) {
1064
1067
  delete cfg.mcpServers[k];
1065
1068
  changed = true;
1066
1069
  debugLog('DEBUG', 'hook-update', `Post-update: removed stale global MCP "${k}"`);