claude-mem-lite 6.27.0 → 6.29.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +64 -5
  4. package/README.zh-CN.md +29 -2
  5. package/adopt-cli.mjs +36 -2
  6. package/claudemd.mjs +45 -8
  7. package/cli/activity.mjs +58 -20
  8. package/cli/common.mjs +63 -24
  9. package/cli/fts-check.mjs +6 -0
  10. package/cli.mjs +84 -18
  11. package/deep-search.mjs +10 -3
  12. package/haiku-client.mjs +44 -5
  13. package/hash-utils.mjs +43 -2
  14. package/hook-context.mjs +8 -1
  15. package/hook-episode.mjs +22 -11
  16. package/hook-handoff.mjs +52 -19
  17. package/hook-llm.mjs +271 -40
  18. package/hook-optimize.mjs +141 -87
  19. package/hook-semaphore.mjs +7 -1
  20. package/hook-shared.mjs +116 -7
  21. package/hook-update.mjs +23 -18
  22. package/hook.mjs +231 -46
  23. package/install.mjs +553 -81
  24. package/lib/activity.mjs +9 -3
  25. package/lib/blank-text.mjs +21 -0
  26. package/lib/cite-back-hint.mjs +2 -0
  27. package/lib/cite-recall-path.mjs +26 -3
  28. package/lib/compress-core.mjs +18 -3
  29. package/lib/db-busy.mjs +18 -0
  30. package/lib/db-unusable.mjs +11 -4
  31. package/lib/deferred-work.mjs +15 -1
  32. package/lib/delete-core.mjs +4 -1
  33. package/lib/edit-distance.mjs +25 -0
  34. package/lib/export-columns.mjs +6 -0
  35. package/lib/get-core.mjs +17 -1
  36. package/lib/handoff-constants.mjs +8 -0
  37. package/lib/hook-prune.mjs +175 -24
  38. package/lib/id-routing.mjs +25 -1
  39. package/lib/import-jsonl.mjs +51 -16
  40. package/lib/install-shape.mjs +89 -0
  41. package/lib/maintain-core.mjs +74 -13
  42. package/lib/memory-input-guard.mjs +15 -6
  43. package/lib/npx-run.mjs +39 -0
  44. package/lib/observation-write.mjs +31 -16
  45. package/lib/plugin-key.mjs +58 -2
  46. package/lib/provenance.mjs +15 -1
  47. package/lib/recall-shown.mjs +78 -1
  48. package/lib/record-once.mjs +4 -1
  49. package/lib/save-enrich.mjs +76 -18
  50. package/lib/save-observation.mjs +37 -10
  51. package/lib/search-core.mjs +34 -2
  52. package/lib/startup-dashboard.mjs +28 -6
  53. package/lib/time-constants.mjs +50 -0
  54. package/lib/timeline-core.mjs +34 -13
  55. package/lib/verify-apply-core.mjs +3 -1
  56. package/mem-cli.mjs +433 -58
  57. package/nlp.mjs +85 -0
  58. package/npm-shrinkwrap.json +2 -2
  59. package/package.json +5 -1
  60. package/plugin-cache-guard.mjs +21 -16
  61. package/project-utils.mjs +103 -2
  62. package/rerank.mjs +3 -2
  63. package/schema.mjs +441 -56
  64. package/scoring-sql.mjs +6 -3
  65. package/scripts/hook-launcher.mjs +52 -6
  66. package/scripts/launch.mjs +12 -1
  67. package/scripts/post-tool-use.sh +35 -7
  68. package/scripts/pre-agent-inject.js +5 -1
  69. package/scripts/pre-tool-recall.js +3 -1
  70. package/scripts/user-prompt-search.js +12 -4
  71. package/search-scoring.mjs +9 -1
  72. package/server/fts-check.mjs +8 -10
  73. package/server.mjs +189 -47
  74. package/source-files.mjs +186 -169
  75. package/tool-schemas.mjs +106 -24
  76. package/utils.mjs +11 -1
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.27.0",
12
+ "version": "6.29.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.27.0",
3
+ "version": "6.29.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
- - **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
91
+ - **Degraded fallback when the LLM fails** -- An episode that fixed an error is saved with inferred metadata (as an activity event) instead of being discarded, when a command that exited 0 printed the error (`npm test 2>&1 | tail`): a command Claude Code reports as failed does not enter the episode, so a fix that follows one counts as edit-only. An edit-only episode, config and schema changes included, is summarised from raw tool output, which the noise rules drop in most cases (`Modified a.js, b.js` with no lesson; only a short single-file summary survives) — as they do when the model answers without a lesson — so a failing provider loses those episodes unless `CLAUDE_MEM_KEEP_LOW_SIGNAL=1` is set; `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,65 @@ 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.29.0
241
+
242
+ **Chinese text in lessons, facts, concepts and aliases becomes searchable, and the database opens
243
+ after the update fill that in without holding up a hook.** No schema-version change: 6.28.1 still
244
+ opens, searches and writes the database after this release has. Go back only after the fill
245
+ finishes: rows it has not reached stay without the Latin identifiers glued to Chinese under 6.28.1.
246
+
247
+ - **What it costs.** Each database open spends about 250 ms filling the index terms of existing
248
+ observations, newest first, each batch committed, until all are done. On a copy of the
249
+ maintainer's store (516 observations, most already filled by an earlier build of this pass)
250
+ the pass walked every row in one open of 48–49 ms. On a synthetic v6.27.0 store of 250,000
251
+ observations and 200,000 events (mostly CJK) the first open takes about 1 s (6.28.1: about 9 s,
252
+ and a 5 s hook opening it first was killed), and the fill then takes 105 opens of about 0.5 s;
253
+ an open after it takes 3 ms.
254
+ - **A lesson a model writes onto one of your explicit saves starts with `🤖`**, and `get` says
255
+ "🤖 lesson auto-written". A lesson you write is never marked.
256
+ - **Exit codes.** A flag that takes no value (`--force`, `--json`, `--confirm`, `--or`,
257
+ `--dry-run`, …) no longer swallows the next word: `delete --confirm 12` now deletes #12, and
258
+ `recent --json 5` prints JSON. `maintain`, `compress`, `optimize`, `delete`, `activity promote` /
259
+ `delete`, `import-jsonl`, `defer drop` and `fts-check rebuild`, which never read `--dry-run`,
260
+ refuse it with exit 1 on a run that would write, instead of running; `activity save`, `import-jsonl` and `restore` refuse a mistyped
261
+ `--project`; `uninstall` exits 3 when the MCP registration is left. CHANGELOG.md lists the
262
+ rest; a script that relied on the old behaviour needs a look.
263
+ - **Also in this release:** the database waits of a hook that opens through `ensureDb` all end by
264
+ its deadline, the first open's migration, the prompt search and the Agent injection included;
265
+ the unattended re-enrich raises importance to 2 at most; three more background prompts send
266
+ stored rows to the model as data, and smart-compress and cluster merge act only on an explicit
267
+ yes. Details in CHANGELOG.md.
268
+
269
+ ## Upgrading to 6.28.1
270
+
271
+ **After the update, events are re-indexed so Chinese text in them is searchable** (the Han ranges
272
+ U+4E00–9FFF and U+3400–4DBF; kana, Hangul and the rarer Han extensions are not segmented). No
273
+ schema-version change: 6.27.0 still opens, searches and writes the database after this release has,
274
+ and an event it writes is filled the next time this release opens the database.
275
+
276
+ - **What it costs.** The first open adds a column, rebuilds the events index and adds the Latin
277
+ identifiers glued to Chinese text to existing observations' index text. Existing events are then
278
+ filled newest first, about 250 ms per database open (checked after each row), each batch
279
+ committed, so a killed open keeps its progress; with nothing left an open takes 3–4 ms. On the
280
+ maintainer's store (6963 events) the first three opens took 295–327, 80–102 and 4 ms, and the file
281
+ grew from 14 to 15 MB; on a CJK-heavy synthetic store of 200,000 events the first open takes 964
282
+ ms, the fill 79 opens, and the file grows from 93 to 273 MB. Until an event is filled, a Chinese
283
+ query misses it as before.
284
+ - **The MCP server waits out another writer's lock at startup** (up to 15 s of retries) instead of
285
+ exiting with "database is locked".
286
+ - **Exit codes.** Some commands that exited 0 now exit 1, among them `save`, `update`, `defer add`
287
+ and `activity save` given a flag they do not take, `save` and `defer add` given a mistyped
288
+ project, `delete` given an id list with a malformed part (`1,abc`), `self-update` when it checked
289
+ nothing, and `import-jsonl` on lines none of which is a transcript event (an empty file still
290
+ exits 0); CHANGELOG.md lists the rest. `install` exits 3 when only the MCP registration failed. A
291
+ script that relied on exit 0 there needs a look.
292
+ - **Also in this release:** `install` no longer empties the marketplace clone's `hooks/hooks.json`
293
+ (it puts back, with `git checkout`, one an earlier install emptied); four MCP tool texts say what
294
+ the tools do; a hook under a long write lock exits instead of being killed (UserPromptSubmit and
295
+ SessionStart with their context), and a lock that ends between 3.5 s and the 5 s kill, which
296
+ 6.27.0 waited out, now drops the write the hook was waiting to make (UserPromptSubmit's prompt
297
+ row, for one). Details in CHANGELOG.md.
298
+
240
299
  ## Upgrading to 6.25.0
241
300
 
242
301
  **Every session gets the same first line on a file-recall block again.** No schema-version
@@ -824,7 +883,7 @@ Episodes are batched related operations (edits to the same file group) that get
824
883
  Episode buffer -> Flush to JSON -> claude -p --model haiku -> Structured observation -> SQLite
825
884
  ```
826
885
 
827
- 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.
886
+ 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 that fixed an error a successful command printed is saved with inferred metadata (a command reported as failed does not enter the episode); an edit-only episode (config and schema changes included) is usually dropped as noise. Related observations are linked via `related_ids` based on FTS5 title similarity and file overlap.
828
887
 
829
888
  ## Management Commands
830
889
 
@@ -1232,7 +1291,7 @@ what is already stored — only whether new work runs.
1232
1291
  |----------|-------------|---------|
1233
1292
  | `CLAUDE_MEM_SKIP_SUMMARY` | Skip the background LLM session summary at **both** of its spawn sites — `Stop`, and the SessionStart `/clear`-handoff path. Until v5.3.0 only the `Stop` one honoured it. | _(runs)_ |
1234
1293
  | `CLAUDE_MEM_LEGACY_STOP_UNLINK` | Restore the pre-v5.4.0 behaviour where `Stop` deletes the session file. Documented revert path for the session-lifecycle change, not a supported configuration: it re-mints a mem session per turn and makes the `/clear` handoff unreachable again. Only reach for it on a host that fires `Stop` once per session rather than once per turn. | _(file kept)_ |
1235
- | `CLAUDE_MEM_SKIP_EPISODE_LLM` | Skip LLM extraction on episode flush — observations are still batched, just not summarized. | _(runs)_ |
1294
+ | `CLAUDE_MEM_SKIP_EPISODE_LLM` | Skip LLM extraction on episode flush. Only the rule-based pre-save runs, through the noise gate: an error-fixing episode with a low-signal title ("Error: …") is dropped, where a failed call would save it as an activity event; an edit-only one is usually dropped as noise. It also turns off SessionStart's hand-off of a killed episode worker's flush file to a new worker. | _(runs)_ |
1236
1295
  | `CLAUDE_MEM_SKIP_SAVE_ENRICH` | Skip the background Haiku call that backfills `lesson_learned` / search aliases after a save. | _(runs)_ |
1237
1296
  | `CLAUDE_MEM_SKIP_COMPRESS` | Skip auto-compression of old observations. | _(runs)_ |
1238
1297
  | `CLAUDE_MEM_SKIP_MAINTAIN` | Skip the 24h auto-maintain pass (decay, purge, backup). | _(runs)_ |
@@ -1242,7 +1301,7 @@ what is already stored — only whether new work runs.
1242
1301
  | `CLAUDE_MEM_SKIP_UPDATE` | Skip the 24h update check against GitHub Releases, its session-start notice, and `self-update` / `install.mjs update` (unset it to update). A `repair` triggered by a missing module or dependency still runs. | _(runs)_ |
1243
1302
  | `CLAUDE_MEM_SKIP_SIG_VERIFY` | Skip Ed25519 signature verification of a downloaded update. **Escape hatch — leaves updates unauthenticated.** | _(verifies)_ |
1244
1303
  | `CLAUDE_MEM_NO_LESSON_RETRY` | `1` disables the one-shot retry that re-asks for a missing `lesson_learned`. | _(retries)_ |
1245
- | `CLAUDE_MEM_FLUSH_TIMEOUT` | Seconds the Stop hook waits for pending episode flushes. | `15` |
1304
+ | `CLAUDE_MEM_FLUSH_TIMEOUT` | Seconds the background session-summary worker waits for its pending episode flushes; it stops as soon as they clear. The default spans the episode worker's longest legitimate run. | `275` |
1246
1305
  | `CLAUDE_MEM_BACKUP_BUDGET_MB` | Disk budget for backup snapshots; the next maintain/save evicts oldest snapshots past the 7-day undo grace. | `256` |
1247
1306
 
1248
1307
  ### Experimental
@@ -1311,7 +1370,7 @@ The original called an LLM on every tool use with raw JSON inputs. claude-mem-li
1311
1370
 
1312
1371
  ### Does this work cross-project? Cross-machine?
1313
1372
 
1314
- Project-scoped by default — each project has its own memory namespace. Single-machine only (SQLite, not networked). Use `mem_export` (JSON / JSONL) to back up or migrate between machines.
1373
+ Each memory belongs to the project it was saved in (an id like `parent--dir`). Search covers every project and weights the current project's rows ×2 in the ranking; name a project (`project` on the MCP tools, `--project` on the CLI) to search only that one. `mem_recent`, mem_search's `obs_type` fallback and the timeline without an anchor use the current project. Single-machine only (SQLite, not networked). Use `mem_export` (JSON / JSONL) to back up or migrate between machines.
1315
1374
 
1316
1375
  ### What about privacy? Does it call external APIs?
1317
1376
 
package/README.zh-CN.md CHANGED
@@ -67,7 +67,7 @@
67
67
  - **观察关联** -- 基于文件重叠自动建立观察之间的双向链接
68
68
  - **用户提示捕获** -- 通过 UserPromptSubmit 钩子记录用户提示,追踪用户意图
69
69
  - **Read 文件追踪** -- 追踪会话中读取的文件,丰富 episode 上下文
70
- - **LLM 失败时的降级保存** -- 规则已判定为有价值的 episode(修掉了报错、改了配置或 schema 文件)用推断的元数据保存,而非丢弃;规则判为噪声的日常编辑(`Modified a.js, b.js` 且没有 lesson)会被丢掉——模型成功但没给 lesson 时也一样——所以 LLM 不可用期间这类 episode 会丢失。`claude-mem-lite doctor` 会在要调用的 `claude` CLI 不存在时告警
70
+ - **LLM 失败时的降级保存** -- 修掉了报错的 episode 用推断的元数据保存(作为活动事件),而非丢弃,前提是报错由一条正常退出的命令打印出来(如 `npm test 2>&1 | tail`):Claude Code 报告为失败的命令不进入 episode,紧随其后的修复按只有编辑的 episode 处理;只有编辑的 episode(包括改配置、改 schema)只能用原始工具输出拼成摘要,多数会被噪声规则丢掉(`Modified a.js, b.js` 且没有 lesson;只有很短的单文件摘要能留下)——模型成功但没给 lesson 时也一样——所以 LLM 不可用期间这类 episode 会丢失,除非设了 `CLAUDE_MEM_KEEP_LOW_SIGNAL=1`。`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,33 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.29.0
203
+
204
+ **lesson、facts、concepts 和 aliases 里的中文变得可以搜到;更新后的数据库打开会逐步补上这些索引,不会拖住钩子。** 没有 schema 版本变化:本版打开过的数据库,6.28.1 仍能打开、搜索和写入。要退回,请等补完之后再退:还没补到的行,在 6.28.1 下会缺少粘在中文上的英文标识符。
205
+
206
+ - **代价。** 每次打开数据库约花 250 ms 为已有的 observation 补索引词,从最新的开始,每批单独提交,直到补完。维护者的库的副本(516 条 observation,大部分已由这一遍的早期版本补过)上,这一遍一次打开(48–49 ms)就走完。在 25 万条 observation、20 万条 event(大部分含中文)的合成 v6.27.0 库上,首次打开约 1 s(6.28.1 约要 9 s,先打开它的 5 s 钩子 3 次都被杀掉),之后补完需要 105 次打开、每次约 0.5 s;补完后的一次打开 3 ms。
207
+ - **模型写在你显式保存的记录上的 lesson 以 `🤖` 开头**,`get` 的标题行注明 "🤖 lesson auto-written"。你自己写的 lesson 不会被标记。
208
+ - **退出码。** 不带值的 flag(`--force`、`--json`、`--confirm`、`--or`、`--dry-run` 等)不再吞掉后面的词:`delete --confirm 12` 现在会删除 #12,`recent --json 5` 输出 JSON。`maintain`、`compress`、`optimize`、`delete`、`activity promote` / `delete`、`import-jsonl`、`defer drop` 和 `fts-check rebuild` 从来不读 `--dry-run`,在会写入的运行中遇到它会以 exit 1 拒绝,而不是照常执行;`activity save`、`import-jsonl` 和 `restore` 拒绝拼错的 `--project`;MCP 注册没删掉时 `uninstall` 以 exit 3 结束。其余见 CHANGELOG.md;依赖旧行为的脚本需要检查一下。
209
+ - **本版还有:** 经 `ensureDb` 打开数据库的钩子,所有数据库等待都在截止时间前结束,包括首次打开的迁移、提示词搜索和 Agent 注入;无人值守的 re-enrich 最多把 importance 提到 2;又有三个后台提示把存储的行作为数据交给模型;smart-compress 和 cluster merge 只在明确同意时才动手。详见 CHANGELOG.md。
210
+
211
+ ## 升级到 6.28.1
212
+
213
+ **更新后会给事件重建索引,事件里的中文从此能搜到**(切分 U+4E00–9FFF 和 U+3400–4DBF 两段汉字;假名、韩文和更少见的汉字扩展区不切分)。
214
+ 没有 schema 版本变更:本版本打开过的数据库,6.27.0 仍能打开、搜索和写入;它写入的事件,本版本下一次打开数据库时会补上。
215
+
216
+ - **代价。** 第一次打开时加一列、重建事件索引,并给已有 observation 的索引文本补上粘在中文里的英文标识符。已有事件随后从新到旧补齐,
217
+ 每次打开数据库约做 250 ms(每切分一行检查一次),每一批单独提交,所以被杀的那次打开也会保留进度;补完之后每次打开 3–4 ms。
218
+ 在维护者的库上(6963 条事件)前三次打开分别为 295–327、80–102、4 ms,文件从 14 MB 变为 15 MB;在中文较多的 20 万条事件合成库上,
219
+ 第一次打开 964 ms,补齐需 79 次打开,文件从 93 MB 变为 273 MB。补齐之前,用中文查询仍搜不到这条事件,和以前一样。
220
+ - **MCP server 启动时遇到别的进程的写锁会等待**(最多重试 15 s),不再报 "database is locked" 退出。
221
+ - **退出码。** 一些原来退出 0 的命令现在退出 1,其中包括:`save`、`update`、`defer add`、`activity save` 遇到不接受的 flag,
222
+ `save`、`defer add` 遇到拼错的项目名,`delete` 的 id 列表里有格式错误的部分(`1,abc`),`self-update` 什么都没检查成,`import-jsonl`
223
+ 的输入里没有一行是 transcript 事件(空文件仍退出 0);其余见 CHANGELOG.md。其余步骤都成功、只有 MCP 注册失败时,`install` 退出 3。
224
+ 依赖这些地方退出 0 的脚本需要检查。
225
+ - **本版本还有:** `install` 不再清空 marketplace clone 里的 `hooks/hooks.json`(早先被清空的,会用 `git checkout` 恢复);
226
+ 四处 MCP 工具文本改成了工具实际做的事;钩子遇到长时间的写锁时会自行退出,不再被杀(UserPromptSubmit 和 SessionStart 仍带着上下文),
227
+ 代价是:锁在 3.5 s 之后、5 s 被杀之前结束时,6.27.0 会等到并完成的那次写入(例如 UserPromptSubmit 的 prompt 行),现在会丢掉。详见 CHANGELOG.md。
228
+
202
229
  ## 升级到 6.25.0
203
230
 
204
231
  **文件召回块的第一行,所有会话重新用同一种写法。** 没有 schema 版本变更:本版本打开过的数据库,6.24.0 仍能打开。
@@ -660,7 +687,7 @@ Episode 是一批相关操作(对同一组文件的编辑),由后台 LLM w
660
687
  Episode 缓冲区 -> 刷新为 JSON -> claude -p --model haiku -> 结构化观察 -> SQLite
661
688
  ```
662
689
 
663
- 每条观察包含类型、标题、叙述、概念、事实和重要度(1-3),并通过两级机制自动去重:Jaccard 相似度(5 分钟内 >70%)和 MinHash 签名(7 天跨会话 >80%)。LLM 调用失败时,规则判定有价值的 episode 用推断的元数据保存降级记录,规则判为噪声的日常编辑会被丢掉。相关观察通过 FTS5 标题相似度和文件重叠自动建立 `related_ids` 链接。
690
+ 每条观察包含类型、标题、叙述、概念、事实和重要度(1-3),并通过两级机制自动去重:Jaccard 相似度(5 分钟内 >70%)和 MinHash 签名(7 天跨会话 >80%)。LLM 调用失败时,修掉了报错(报错由正常退出的命令打印;报告为失败的命令不进入 episode)的 episode 用推断的元数据保存降级记录;只有编辑的 episode(包括改配置、改 schema)多数会作为噪声丢掉。相关观察通过 FTS5 标题相似度和文件重叠自动建立 `related_ids` 链接。
664
691
 
665
692
  ## 管理命令
666
693
 
package/adopt-cli.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  writeFileSync,
24
24
  unlinkSync,
25
25
  readFileSync,
26
+ realpathSync,
26
27
  } from 'fs';
27
28
  import { claudeConfigDir, claudeStatePath } from './lib/data-paths.mjs';
28
29
  import { join, isAbsolute, relative, dirname } from 'path';
@@ -50,6 +51,7 @@ import {
50
51
  detailDocPath,
51
52
  addAgentsImports,
52
53
  holdsOnlyOwnLines,
54
+ straySentinelLines,
53
55
  } from './claudemd.mjs';
54
56
  import { PLUGIN_SLUG, CURRENT_SENTINEL_VERSION, buildClaudeMdBlock, getDetailDoc } from './adopt-content.mjs';
55
57
  import {
@@ -100,8 +102,23 @@ function log(msg) {
100
102
  console.log(msg);
101
103
  }
102
104
 
103
- function detectCwd() {
104
- return process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
105
+ // $PWD keeps a symlinked project's logical spelling (the one Claude Code's CLAUDE_PROJECT_DIR
106
+ // uses), but only when it names the directory this process is in: a program that spawns `adopt`
107
+ // with `cwd` set leaves its OWN PWD in the env, and adopt wrote into the caller's directory
108
+ // instead of the one it ran in (E2E 2026-10-16).
109
+ /** @public install.mjs reads it through a dynamic-import namespace (`adopt.detectCwd?.()`). */
110
+ export function detectCwd() {
111
+ if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
112
+ const cwd = process.cwd();
113
+ const pwd = process.env.PWD;
114
+ if (pwd) {
115
+ try {
116
+ if (realpathSync(pwd) === realpathSync(cwd)) return pwd;
117
+ } catch {
118
+ /* PWD names nothing that exists: use the real cwd */
119
+ }
120
+ }
121
+ return cwd;
105
122
  }
106
123
 
107
124
  function projectsRoot() {
@@ -182,6 +199,18 @@ function adoptOne(cwd, { force, dryRun }) {
182
199
  const block = buildClaudeMdBlock();
183
200
  const doc = getDetailDoc();
184
201
  const version = CURRENT_SENTINEL_VERSION;
202
+ // A damaged sentinel line pairs with no block, so writing would add a second block beside it
203
+ // (E2E 2026-10-16 round 2, B14). Which lines are the block is the user's call: refuse first.
204
+ const stray = straySentinelLines(claudeMdPath(cwd), PLUGIN_SLUG);
205
+ if (stray.length > 0) {
206
+ log(`[adopt] ${cwd} → refused, nothing was written:`);
207
+ for (const s of stray) log(` ${claudeMdPath(cwd)} line ${s.line}: ${s.text}`);
208
+ log(
209
+ ` ${stray.length === 1 ? 'This sentinel line pairs' : 'These sentinel lines pair'} with no well-formed block, so adopt would add a second one. Make the block's first line exactly \`<!-- ${PLUGIN_SLUG}:begin ${version} -->\` and its last \`<!-- ${PLUGIN_SLUG}:end -->\`, each on a line of its own, or delete the marker lines and the text between them; then run adopt again.`,
210
+ );
211
+ process.exitCode = 1;
212
+ return { action: 'refused' };
213
+ }
185
214
  // Claude Code stops reading AGENTS.md once a CLAUDE.md exists: the one written here imports
186
215
  // the AGENTS.md files beside it and names the ones it cannot import.
187
216
  const agents = agentsMdForNewClaudeMd(cwd, PLUGIN_SLUG);
@@ -347,6 +376,11 @@ export function silentAutoAdopt({ cwd, markerDir, markerKey }) {
347
376
  const hasBlock = blockIn(cwd);
348
377
  if (!hasBlock) {
349
378
  if (markerDir && markerKey) writeMarker(markerDir, markerKey);
379
+ // A damaged sentinel line (`… --> (team note)`) leaves the block's text in CLAUDE.md, which
380
+ // the host loads, while the block no longer parses: a copy written to CLAUDE.local.md or
381
+ // injected here was a second one. Neither; `adopt` names the line and the fix (D#369 B14).
382
+ const stray = straySentinelLines(claudeMdPath(cwd), PLUGIN_SLUG);
383
+ if (stray.length > 0) return { ok: true, action: 'damaged', reason: 'stray-sentinel', lines: stray };
350
384
  const root = localSteeringRoot(cwd);
351
385
  if (root) {
352
386
  // A session started below the top-level (pre-tag claims review P2-3, P1-4): the host
package/claudemd.mjs CHANGED
@@ -104,6 +104,35 @@ function orphanSentinelCount(raw, slug) {
104
104
  });
105
105
  return total - paired;
106
106
  }
107
+ /**
108
+ * The lines of `p` that open with a sentinel of this slug and that no well-formed block
109
+ * accounts for: a begin marker with text after it, a lost version tag, a missing partner. The
110
+ * block pattern cannot see them, so a write that trusts it adds a second block beside them
111
+ * (E2E 2026-10-16 round 2, B14). A sentinel inside a sentence is prose, not a marker (pre-ship
112
+ * review P2-3), and is not listed.
113
+ * @param {string} p absolute path of the instructions file
114
+ * @param {string} slug
115
+ * @returns {{line: number, text: string}[]} 1-based; empty for a missing or unreadable file
116
+ */
117
+ export function straySentinelLines(p, slug) {
118
+ let raw;
119
+ try {
120
+ raw = readFileSync(p, 'utf8');
121
+ } catch {
122
+ return [];
123
+ }
124
+ const spans = [...raw.matchAll(blockRegexG(slug))].map((m) => [m.index, m.index + m[0].length]);
125
+ const opensLine = new RegExp(`^\\s*<!-- ${escapeRe(slug)}:(?:begin|end)\\b`);
126
+ const stray = [];
127
+ let offset = 0;
128
+ raw.split('\n').forEach((text, i) => {
129
+ const at = offset + text.indexOf('<!--');
130
+ if (opensLine.test(text) && !spans.some(([s, e]) => at >= s && at < e))
131
+ stray.push({ line: i + 1, text: text.trim() });
132
+ offset += text.length + 1;
133
+ });
134
+ return stray;
135
+ }
107
136
  function blockRegex(slug) {
108
137
  return new RegExp(blockBody(escapeRe(slug)));
109
138
  }
@@ -331,8 +360,8 @@ export function needsRefresh(cwd, { slug, version, block, doc }) {
331
360
  /**
332
361
  * Write (insert-or-replace) the managed block in CLAUDE.md and the detail doc
333
362
  * under .claude/, plus a state sidecar. Idempotent on the user-visible files:
334
- * re-running with identical inputs leaves CLAUDE.md and the detail doc byte-for-
335
- * byte unchanged (only the state sidecar's writtenAt updates).
363
+ * re-running with identical inputs leaves CLAUDE.md, the detail doc and the state
364
+ * sidecar byte-for-byte unchanged.
336
365
  *
337
366
  * CLAUDE.md is created if absent. Only our slug-scoped region is rewritten;
338
367
  * everything else in the file is preserved verbatim.
@@ -351,12 +380,20 @@ export function writeManaged(cwd, { slug, version, block, doc }) {
351
380
  const existingDoc = existsSync(dp) ? readFileSync(dp, 'utf8') : null;
352
381
  if (existingDoc !== docContent) atomicWrite(dp, docContent);
353
382
 
354
- writeState(cwd, slug, {
355
- version,
356
- blockHash: sha256(block),
357
- docHash: sha256(doc),
358
- writtenAt: new Date().toISOString(),
359
- });
383
+ // Only when what the sidecar records changed: it is committed alongside the block in a team
384
+ // repo, and a fresh writtenAt on every re-run of `adopt` left a diff each time (E2E
385
+ // 2026-10-16). Nothing reads writtenAt.
386
+ const blockHash = sha256(block);
387
+ const docHash = sha256(doc);
388
+ let prev = null;
389
+ try {
390
+ prev = JSON.parse(readFileSync(stateFilePath(cwd, slug), 'utf8'));
391
+ } catch {
392
+ /* absent or unreadable: write it */
393
+ }
394
+ if (!(prev?.version === version && prev?.blockHash === blockHash && prev?.docHash === docHash)) {
395
+ writeState(cwd, slug, { version, blockHash, docHash, writtenAt: new Date().toISOString() });
396
+ }
360
397
  return { action };
361
398
  }
362
399
 
package/cli/activity.mjs CHANGED
@@ -9,16 +9,25 @@
9
9
  // events_fts via triggers.
10
10
 
11
11
  import { inferProject } from '../utils.mjs';
12
- import { resolveProject } from '../project-utils.mjs';
12
+ import { resolveProject, checkWriteProject } from '../project-utils.mjs';
13
13
  import { resolveCliProject as cliProject } from '../lib/cli-project.mjs';
14
14
  import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
15
15
  import { parseIntFlag, isNumericToken } from '../lib/cli-flags.mjs';
16
+ import { snapshotDb } from '../lib/db-backup.mjs';
17
+ import { isBlankText } from '../lib/blank-text.mjs';
16
18
 
17
19
  function formatActivityResults(rows) {
18
20
  if (!rows || rows.length === 0) return '(no events)';
19
21
  return rows.map((r) => `#${r.id} [${r.event_type}] ${r.title}`).join('\n');
20
22
  }
21
23
 
24
+ /** An event id as typed: N, #N or E#N (the form search and get print), else null. */
25
+ function eventIdOf(token) {
26
+ const m = /^(?:[Ee]#?|#)?(\d+)$/.exec(String(token).trim());
27
+ const n = m ? Number(m[1]) : NaN;
28
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
29
+ }
30
+
22
31
  export async function cmdActivity(db, args) {
23
32
  const sub = args[0];
24
33
  if (!sub) {
@@ -33,9 +42,11 @@ export async function cmdActivity(db, args) {
33
42
  // `save` CREATES a row, so it keeps plain inferProject(): the DB-aware fallback is a read
34
43
  // affordance, and applying it to a write absorbs a not-yet-born subproject's first event
35
44
  // into the enclosing repo (pre-tag review, reproduced). Every other subcommand reads or
36
- // operates on rows that already exist, where falling back is what finds them.
45
+ // operates on rows that already exist, where falling back is what finds them. A named
46
+ // project is resolved for `save` the way `save` and `defer add` resolve theirs: exact
47
+ // matching only, then checkWriteProject below.
37
48
  const project = flags.project
38
- ? resolveProject(db, flags.project)
49
+ ? resolveProject(db, flags.project, sub === 'save' ? { mode: 'write' } : undefined)
39
50
  : sub === 'save'
40
51
  ? inferProject()
41
52
  : cliProject(db);
@@ -50,7 +61,7 @@ export async function cmdActivity(db, args) {
50
61
  return;
51
62
  }
52
63
  const title = flags.title || positional.join(' ').trim();
53
- if (!title) {
64
+ if (isBlankText(title)) {
54
65
  fail('[mem] activity save: --title or positional text required');
55
66
  return;
56
67
  }
@@ -77,6 +88,13 @@ export async function cmdActivity(db, args) {
77
88
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
78
89
  return;
79
90
  }
91
+ // A near miss of an existing project is refused, a new name is noted: the check `save` and
92
+ // `defer add` make (pre-tag review 2026-10-16 P3-4). stdout stays the JSON line.
93
+ const projectCheck = flags.project ? checkWriteProject(db, project) : { ok: true, note: '' };
94
+ if (!projectCheck.ok) {
95
+ fail(`[mem] ${projectCheck.message}`);
96
+ return;
97
+ }
80
98
  const id = saveEvent(db, {
81
99
  project,
82
100
  event_type: type,
@@ -86,6 +104,7 @@ export async function cmdActivity(db, args) {
86
104
  file_paths,
87
105
  });
88
106
  out(JSON.stringify({ ok: true, id }));
107
+ if (projectCheck.note) process.stderr.write(`[mem] Saved event E#${id}${projectCheck.note}\n`);
89
108
  return;
90
109
  }
91
110
 
@@ -126,9 +145,11 @@ export async function cmdActivity(db, args) {
126
145
  }
127
146
 
128
147
  if (sub === 'show') {
129
- const id = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
130
- if (!Number.isFinite(id)) {
131
- fail('[mem] activity show: numeric id required');
148
+ const id = positional.length > 0 ? eventIdOf(positional[0]) : null;
149
+ if (id === null) {
150
+ fail(
151
+ `[mem] activity show: an event id is required (N, #N or E#N)${positional[0] ? `, got "${positional[0]}"` : ''}`,
152
+ );
132
153
  return;
133
154
  }
134
155
  const row = getEvent(db, id);
@@ -153,12 +174,20 @@ export async function cmdActivity(db, args) {
153
174
  fail('[mem] Usage: claude-mem-lite activity delete <id1,id2,...> [--confirm]');
154
175
  return;
155
176
  }
156
- const ids = idStr
177
+ const tokens = idStr
157
178
  .split(',')
158
179
  .map((s) => s.trim())
159
- .filter(Boolean)
160
- .map((s) => parseInt(s, 10))
161
- .filter((n) => Number.isInteger(n) && n > 0);
180
+ .filter(Boolean);
181
+ // Every token must be an id, or nothing is deleted: `2x` used to parseInt to 2 and delete
182
+ // a row the caller never named (E2E 2026-10-16).
183
+ const bad = tokens.filter((t) => eventIdOf(t) === null);
184
+ if (bad.length > 0) {
185
+ fail(
186
+ `[mem] activity delete: not an event id: ${bad.join(', ')} (use N, #N or E#N) — nothing was deleted`,
187
+ );
188
+ return;
189
+ }
190
+ const ids = [...new Set(tokens.map(eventIdOf))];
162
191
  if (ids.length === 0) {
163
192
  fail('[mem] activity delete: no valid IDs provided (must be positive integers)');
164
193
  return;
@@ -188,16 +217,23 @@ export async function cmdActivity(db, args) {
188
217
  return;
189
218
  }
190
219
 
220
+ // A pre-image first, as deleteObservations takes for observations (E2E 2026-10-16 round 2:
221
+ // this hard delete had none). Best-effort; outside the transaction (VACUUM INTO).
222
+ snapshotDb(db, { tag: 'pre-delete' });
191
223
  // The opt-in search telemetry keeps a title per returned result; an explicit delete drops the
192
224
  // deleted events' titles, as lib/delete-core.mjs does for observations (D#245 N6).
193
- const result = db.transaction(() => {
194
- if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()) {
195
- db.prepare(
196
- `UPDATE search_results SET snapshot_label = NULL WHERE source = 'event' AND result_id IN (${placeholders})`,
197
- ).run(...ids);
198
- }
199
- return db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
200
- })();
225
+ const result = db
226
+ .transaction(() => {
227
+ if (
228
+ db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()
229
+ ) {
230
+ db.prepare(
231
+ `UPDATE search_results SET snapshot_label = NULL WHERE source = 'event' AND result_id IN (${placeholders})`,
232
+ ).run(...ids);
233
+ }
234
+ return db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
235
+ })
236
+ .immediate(); // IMMEDIATE: it starts with a read (E2E 2026-10-16 round 3; see delete-core)
201
237
  out(`[mem] Deleted ${result.changes} event(s).`);
202
238
  return;
203
239
  }
@@ -206,7 +242,9 @@ export async function cmdActivity(db, args) {
206
242
  // P2(b): promote insight-bearing events (body present, importance>=N) into
207
243
  // searchable observations. Preview by default; --execute applies. One-time
208
244
  // backfill — the source events are marked promoted (idempotent re-runs).
209
- const minImp = flags['min-importance'] !== undefined ? parseInt(flags['min-importance'], 10) : 2;
245
+ // Whole digits only: a bare parseInt read `2x` as 2.
246
+ const rawMin = flags['min-importance'];
247
+ const minImp = rawMin === undefined ? 2 : /^\d+$/.test(String(rawMin)) ? Number(rawMin) : NaN;
210
248
  if (isNaN(minImp) || minImp < 1 || minImp > 3) {
211
249
  fail('[mem] activity promote: --min-importance must be 1, 2, or 3.');
212
250
  return;
package/cli/common.mjs CHANGED
@@ -8,16 +8,58 @@
8
8
  // relative-time formatting — every command imports from here so the CLI stays
9
9
  // consistent.
10
10
 
11
+ import { editDistance } from '../lib/edit-distance.mjs';
11
12
  import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from '../format-utils.mjs';
12
13
 
13
14
  // ─── Argument Parsing ────────────────────────────────────────────────────────
14
15
 
16
+ /**
17
+ * Flags that take no value: every reader tests them against true / 'true' (or for presence in
18
+ * argv), so the word after one is a positional. parseArgs read every `--key next` as key=value,
19
+ * and `save --type bugfix --title X --force 'text'` parsed `force: 'text'` and no content (the
20
+ * usage error) while the text-first spelling saved; the swallowed word did not even turn the
21
+ * flag on, so `search --or redis` lost its query and its --or (D#385, same in v6.27.0). An
22
+ * explicit `--json true` / `--force false` still parses as before.
23
+ * tests/cli-boolean-flag-parsing.test.mjs derives the boolean readers from the sources and
24
+ * fails on one missing here.
25
+ */
26
+ export const BOOLEAN_CLI_FLAGS = new Set([
27
+ 'all',
28
+ 'apply',
29
+ 'benchmark',
30
+ 'confirm',
31
+ 'deep',
32
+ 'disable',
33
+ 'dry-run',
34
+ 'enable',
35
+ 'execute',
36
+ 'force',
37
+ 'help',
38
+ 'include-compressed',
39
+ 'include-noise',
40
+ 'json',
41
+ 'metrics',
42
+ 'no-deep',
43
+ 'or',
44
+ 'print-project',
45
+ 'quality',
46
+ 'rerank',
47
+ 'retry',
48
+ 'run',
49
+ 'run-all',
50
+ 'search-telemetry',
51
+ 'session-audit',
52
+ 'sidechain',
53
+ 'status',
54
+ 'verbose',
55
+ ]);
56
+
15
57
  /**
16
58
  * Parse argv-style array into { positional, flags }.
17
59
  * `--key value` → flags.key = value; `--flag` (no value) → flags.key = true.
18
60
  * `-h` → flags.help = true.
19
61
  */
20
- export function parseArgs(argv) {
62
+ export function parseArgs(argv, { track = true } = {}) {
21
63
  const positional = [];
22
64
  const flags = {};
23
65
  // Canonical flag name for a raw `--key`. Two normalizations, both aimed at the
@@ -53,6 +95,13 @@ export function parseArgs(argv) {
53
95
  let i = 0;
54
96
  while (i < argv.length) {
55
97
  const arg = argv[i];
98
+ // POSIX end of options: everything after a bare `--` is positional, so text that starts
99
+ // with `--` can be saved (`save -- "--dry-run is ignored by deploy.sh"`). It parsed as an
100
+ // empty-named flag and the text after it as a flag of its own (E2E 2026-10-16).
101
+ if (arg === '--') {
102
+ positional.push(...argv.slice(i + 1));
103
+ break;
104
+ }
56
105
  if (arg.startsWith('--')) {
57
106
  const body = arg.slice(2);
58
107
  // `--key=value` (GNU long-option form). Split on the FIRST '=' so values that
@@ -68,7 +117,11 @@ export function parseArgs(argv) {
68
117
  }
69
118
  const key = body;
70
119
  const next = argv[i + 1];
71
- if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
120
+ // A flag that takes no value leaves the next word alone, except an explicit true/false.
121
+ const takes = BOOLEAN_CLI_FLAGS.has(canonicalFlag(key))
122
+ ? next === 'true' || next === 'false'
123
+ : next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next));
124
+ if (takes) {
72
125
  setFlag(key, next);
73
126
  i += 2;
74
127
  } else {
@@ -83,7 +136,9 @@ export function parseArgs(argv) {
83
136
  i++;
84
137
  }
85
138
  }
86
- return { positional, flags: trackFlagReads(flags) };
139
+ // `track: false` for the dispatcher's own peeks (typo hints, the unknown-project note): a read
140
+ // there is not the command consuming the flag, and recording it hid the inert-flag notice.
141
+ return { positional, flags: track ? trackFlagReads(flags) : flags };
87
142
  }
88
143
 
89
144
  // ─── Inert selection flags ───────────────────────────────────────────────────
@@ -269,7 +324,11 @@ export function fail(text) {
269
324
  export function rejectBareStringFlags(flags, keys) {
270
325
  for (const key of keys) {
271
326
  if (flags[key] === true) {
272
- fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
327
+ // A value that itself starts with `-` parses as a flag of its own and lands here; the
328
+ // `=` form is the way to pass one, and nothing said so (E2E 2026-10-16 round 2).
329
+ fail(
330
+ `[mem] --${key} requires a value (received a bare flag with no value). A value that starts with - is written --${key}="…".`,
331
+ );
273
332
  return true;
274
333
  }
275
334
  }
@@ -436,26 +495,6 @@ export const COMMAND_SCOPED_FLAGS = new Map([
436
495
  ['undo', 'verify-apply'],
437
496
  ]);
438
497
 
439
- /** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
440
- function editDistance(a, b, max = 2) {
441
- const m = a.length,
442
- n = b.length;
443
- if (Math.abs(m - n) > max) return max + 1;
444
- let prev = Array.from({ length: n + 1 }, (_, j) => j);
445
- for (let i = 1; i <= m; i++) {
446
- const cur = [i];
447
- let rowMin = i;
448
- for (let j = 1; j <= n; j++) {
449
- const d = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0));
450
- cur[j] = d;
451
- if (d < rowMin) rowMin = d;
452
- }
453
- if (rowMin > max) return max + 1; // whole row already past budget → give up
454
- prev = cur;
455
- }
456
- return prev[n];
457
- }
458
-
459
498
  /**
460
499
  * Detect likely-typo flags: names NOT in KNOWN_CLI_FLAGS but within edit distance 2 of
461
500
  * a known flag. parseArgs silently drops unknown flags, so `save --improtance 3` used to
package/cli/fts-check.mjs CHANGED
@@ -32,6 +32,12 @@ export function cmdFtsCheck(db, args) {
32
32
  const result = checkFTSIntegrity(db);
33
33
  if (result.healthy) {
34
34
  out('[mem] FTS5 indexes are healthy — all integrity checks passed.');
35
+ } else if (result.busy) {
36
+ // Not a finding: "issues found" invited a rebuild of a healthy store, a long write that
37
+ // blocks the live hooks (E2E 2026-10-16 round 2). Exit 1 all the same: nothing was checked.
38
+ out('[mem] FTS5 check not run — the database is in use by another process. This is not');
39
+ out(' corruption; do not rebuild. Run the check again when the store is idle.');
40
+ process.exitCode = 1;
35
41
  } else {
36
42
  out(`[mem] FTS5 issues found:`);
37
43
  for (const d of result.details) out(` ${d}`);