claude-mem-lite 6.27.0 → 6.28.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.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +35 -5
  4. package/README.zh-CN.md +20 -2
  5. package/adopt-cli.mjs +36 -2
  6. package/claudemd.mjs +45 -8
  7. package/cli/activity.mjs +45 -17
  8. package/cli/common.mjs +17 -23
  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-handoff.mjs +52 -19
  16. package/hook-llm.mjs +188 -21
  17. package/hook-optimize.mjs +88 -60
  18. package/hook-semaphore.mjs +7 -1
  19. package/hook-shared.mjs +116 -6
  20. package/hook.mjs +223 -43
  21. package/install.mjs +501 -77
  22. package/lib/activity.mjs +9 -3
  23. package/lib/blank-text.mjs +21 -0
  24. package/lib/cite-back-hint.mjs +2 -0
  25. package/lib/cite-recall-path.mjs +26 -3
  26. package/lib/compress-core.mjs +10 -2
  27. package/lib/db-busy.mjs +18 -0
  28. package/lib/db-unusable.mjs +11 -4
  29. package/lib/deferred-work.mjs +15 -1
  30. package/lib/delete-core.mjs +4 -1
  31. package/lib/edit-distance.mjs +25 -0
  32. package/lib/export-columns.mjs +6 -0
  33. package/lib/get-core.mjs +17 -1
  34. package/lib/handoff-constants.mjs +8 -0
  35. package/lib/hook-prune.mjs +168 -25
  36. package/lib/id-routing.mjs +25 -1
  37. package/lib/import-jsonl.mjs +45 -14
  38. package/lib/install-shape.mjs +73 -0
  39. package/lib/maintain-core.mjs +74 -13
  40. package/lib/memory-input-guard.mjs +11 -6
  41. package/lib/npx-run.mjs +39 -0
  42. package/lib/observation-write.mjs +24 -6
  43. package/lib/plugin-key.mjs +58 -2
  44. package/lib/recall-shown.mjs +78 -1
  45. package/lib/record-once.mjs +4 -1
  46. package/lib/save-enrich.mjs +58 -9
  47. package/lib/save-observation.mjs +37 -10
  48. package/lib/search-core.mjs +34 -2
  49. package/lib/startup-dashboard.mjs +28 -6
  50. package/lib/time-constants.mjs +42 -0
  51. package/lib/timeline-core.mjs +34 -13
  52. package/lib/verify-apply-core.mjs +3 -1
  53. package/mem-cli.mjs +357 -55
  54. package/nlp.mjs +57 -0
  55. package/npm-shrinkwrap.json +2 -2
  56. package/package.json +5 -1
  57. package/project-utils.mjs +85 -2
  58. package/rerank.mjs +3 -2
  59. package/schema.mjs +181 -46
  60. package/scoring-sql.mjs +6 -3
  61. package/scripts/hook-launcher.mjs +52 -6
  62. package/scripts/launch.mjs +12 -1
  63. package/scripts/post-tool-use.sh +35 -7
  64. package/scripts/pre-tool-recall.js +3 -1
  65. package/scripts/user-prompt-search.js +9 -3
  66. package/search-scoring.mjs +9 -1
  67. package/server/fts-check.mjs +8 -10
  68. package/server.mjs +154 -40
  69. package/source-files.mjs +186 -169
  70. package/tool-schemas.mjs +103 -24
  71. 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.28.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.27.0",
3
+ "version": "6.28.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
@@ -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,36 @@ 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.28.1
241
+
242
+ **After the update, events are re-indexed so Chinese text in them is searchable** (the Han ranges
243
+ U+4E00–9FFF and U+3400–4DBF; kana, Hangul and the rarer Han extensions are not segmented). No
244
+ schema-version change: 6.27.0 still opens, searches and writes the database after this release has,
245
+ and an event it writes is filled the next time this release opens the database.
246
+
247
+ - **What it costs.** The first open adds a column, rebuilds the events index and adds the Latin
248
+ identifiers glued to Chinese text to existing observations' index text. Existing events are then
249
+ filled newest first, about 250 ms per database open (checked after each row), each batch
250
+ committed, so a killed open keeps its progress; with nothing left an open takes 3–4 ms. On the
251
+ maintainer's store (6963 events) the first three opens took 295–327, 80–102 and 4 ms, and the file
252
+ grew from 14 to 15 MB; on a CJK-heavy synthetic store of 200,000 events the first open takes 964
253
+ ms, the fill 79 opens, and the file grows from 93 to 273 MB. Until an event is filled, a Chinese
254
+ query misses it as before.
255
+ - **The MCP server waits out another writer's lock at startup** (up to 15 s of retries) instead of
256
+ exiting with "database is locked".
257
+ - **Exit codes.** Some commands that exited 0 now exit 1, among them `save`, `update`, `defer add`
258
+ and `activity save` given a flag they do not take, `save` and `defer add` given a mistyped
259
+ project, `delete` given an id list with a malformed part (`1,abc`), `self-update` when it checked
260
+ nothing, and `import-jsonl` on lines none of which is a transcript event (an empty file still
261
+ exits 0); CHANGELOG.md lists the rest. `install` exits 3 when only the MCP registration failed. A
262
+ script that relied on exit 0 there needs a look.
263
+ - **Also in this release:** `install` no longer empties the marketplace clone's `hooks/hooks.json`
264
+ (it puts back, with `git checkout`, one an earlier install emptied); four MCP tool texts say what
265
+ the tools do; a hook under a long write lock exits instead of being killed (UserPromptSubmit and
266
+ SessionStart with their context), and a lock that ends between 3.5 s and the 5 s kill, which
267
+ 6.27.0 waited out, now drops the write the hook was waiting to make (UserPromptSubmit's prompt
268
+ row, for one). Details in CHANGELOG.md.
269
+
240
270
  ## Upgrading to 6.25.0
241
271
 
242
272
  **Every session gets the same first line on a file-recall block again.** No schema-version
@@ -824,7 +854,7 @@ Episodes are batched related operations (edits to the same file group) that get
824
854
  Episode buffer -> Flush to JSON -> claude -p --model haiku -> Structured observation -> SQLite
825
855
  ```
826
856
 
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.
857
+ 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
858
 
829
859
  ## Management Commands
830
860
 
@@ -1232,7 +1262,7 @@ what is already stored — only whether new work runs.
1232
1262
  |----------|-------------|---------|
1233
1263
  | `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
1264
  | `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)_ |
1265
+ | `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
1266
  | `CLAUDE_MEM_SKIP_SAVE_ENRICH` | Skip the background Haiku call that backfills `lesson_learned` / search aliases after a save. | _(runs)_ |
1237
1267
  | `CLAUDE_MEM_SKIP_COMPRESS` | Skip auto-compression of old observations. | _(runs)_ |
1238
1268
  | `CLAUDE_MEM_SKIP_MAINTAIN` | Skip the 24h auto-maintain pass (decay, purge, backup). | _(runs)_ |
@@ -1242,7 +1272,7 @@ what is already stored — only whether new work runs.
1242
1272
  | `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
1273
  | `CLAUDE_MEM_SKIP_SIG_VERIFY` | Skip Ed25519 signature verification of a downloaded update. **Escape hatch — leaves updates unauthenticated.** | _(verifies)_ |
1244
1274
  | `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` |
1275
+ | `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
1276
  | `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
1277
 
1248
1278
  ### Experimental
@@ -1311,7 +1341,7 @@ The original called an LLM on every tool use with raw JSON inputs. claude-mem-li
1311
1341
 
1312
1342
  ### Does this work cross-project? Cross-machine?
1313
1343
 
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.
1344
+ 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
1345
 
1316
1346
  ### What about privacy? Does it call external APIs?
1317
1347
 
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,24 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.28.1
203
+
204
+ **更新后会给事件重建索引,事件里的中文从此能搜到**(切分 U+4E00–9FFF 和 U+3400–4DBF 两段汉字;假名、韩文和更少见的汉字扩展区不切分)。
205
+ 没有 schema 版本变更:本版本打开过的数据库,6.27.0 仍能打开、搜索和写入;它写入的事件,本版本下一次打开数据库时会补上。
206
+
207
+ - **代价。** 第一次打开时加一列、重建事件索引,并给已有 observation 的索引文本补上粘在中文里的英文标识符。已有事件随后从新到旧补齐,
208
+ 每次打开数据库约做 250 ms(每切分一行检查一次),每一批单独提交,所以被杀的那次打开也会保留进度;补完之后每次打开 3–4 ms。
209
+ 在维护者的库上(6963 条事件)前三次打开分别为 295–327、80–102、4 ms,文件从 14 MB 变为 15 MB;在中文较多的 20 万条事件合成库上,
210
+ 第一次打开 964 ms,补齐需 79 次打开,文件从 93 MB 变为 273 MB。补齐之前,用中文查询仍搜不到这条事件,和以前一样。
211
+ - **MCP server 启动时遇到别的进程的写锁会等待**(最多重试 15 s),不再报 "database is locked" 退出。
212
+ - **退出码。** 一些原来退出 0 的命令现在退出 1,其中包括:`save`、`update`、`defer add`、`activity save` 遇到不接受的 flag,
213
+ `save`、`defer add` 遇到拼错的项目名,`delete` 的 id 列表里有格式错误的部分(`1,abc`),`self-update` 什么都没检查成,`import-jsonl`
214
+ 的输入里没有一行是 transcript 事件(空文件仍退出 0);其余见 CHANGELOG.md。其余步骤都成功、只有 MCP 注册失败时,`install` 退出 3。
215
+ 依赖这些地方退出 0 的脚本需要检查。
216
+ - **本版本还有:** `install` 不再清空 marketplace clone 里的 `hooks/hooks.json`(早先被清空的,会用 `git checkout` 恢复);
217
+ 四处 MCP 工具文本改成了工具实际做的事;钩子遇到长时间的写锁时会自行退出,不再被杀(UserPromptSubmit 和 SessionStart 仍带着上下文),
218
+ 代价是:锁在 3.5 s 之后、5 s 被杀之前结束时,6.27.0 会等到并完成的那次写入(例如 UserPromptSubmit 的 prompt 行),现在会丢掉。详见 CHANGELOG.md。
219
+
202
220
  ## 升级到 6.25.0
203
221
 
204
222
  **文件召回块的第一行,所有会话重新用同一种写法。** 没有 schema 版本变更:本版本打开过的数据库,6.24.0 仍能打开。
@@ -660,7 +678,7 @@ Episode 是一批相关操作(对同一组文件的编辑),由后台 LLM w
660
678
  Episode 缓冲区 -> 刷新为 JSON -> claude -p --model haiku -> 结构化观察 -> SQLite
661
679
  ```
662
680
 
663
- 每条观察包含类型、标题、叙述、概念、事实和重要度(1-3),并通过两级机制自动去重:Jaccard 相似度(5 分钟内 >70%)和 MinHash 签名(7 天跨会话 >80%)。LLM 调用失败时,规则判定有价值的 episode 用推断的元数据保存降级记录,规则判为噪声的日常编辑会被丢掉。相关观察通过 FTS5 标题相似度和文件重叠自动建立 `related_ids` 链接。
681
+ 每条观察包含类型、标题、叙述、概念、事实和重要度(1-3),并通过两级机制自动去重:Jaccard 相似度(5 分钟内 >70%)和 MinHash 签名(7 天跨会话 >80%)。LLM 调用失败时,修掉了报错(报错由正常退出的命令打印;报告为失败的命令不进入 episode)的 episode 用推断的元数据保存降级记录;只有编辑的 episode(包括改配置、改 schema)多数会作为噪声丢掉。相关观察通过 FTS5 标题相似度和文件重叠自动建立 `related_ids` 链接。
664
682
 
665
683
  ## 管理命令
666
684
 
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
@@ -13,12 +13,21 @@ import { resolveProject } 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) {
@@ -50,7 +59,7 @@ export async function cmdActivity(db, args) {
50
59
  return;
51
60
  }
52
61
  const title = flags.title || positional.join(' ').trim();
53
- if (!title) {
62
+ if (isBlankText(title)) {
54
63
  fail('[mem] activity save: --title or positional text required');
55
64
  return;
56
65
  }
@@ -126,9 +135,11 @@ export async function cmdActivity(db, args) {
126
135
  }
127
136
 
128
137
  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');
138
+ const id = positional.length > 0 ? eventIdOf(positional[0]) : null;
139
+ if (id === null) {
140
+ fail(
141
+ `[mem] activity show: an event id is required (N, #N or E#N)${positional[0] ? `, got "${positional[0]}"` : ''}`,
142
+ );
132
143
  return;
133
144
  }
134
145
  const row = getEvent(db, id);
@@ -153,12 +164,20 @@ export async function cmdActivity(db, args) {
153
164
  fail('[mem] Usage: claude-mem-lite activity delete <id1,id2,...> [--confirm]');
154
165
  return;
155
166
  }
156
- const ids = idStr
167
+ const tokens = idStr
157
168
  .split(',')
158
169
  .map((s) => s.trim())
159
- .filter(Boolean)
160
- .map((s) => parseInt(s, 10))
161
- .filter((n) => Number.isInteger(n) && n > 0);
170
+ .filter(Boolean);
171
+ // Every token must be an id, or nothing is deleted: `2x` used to parseInt to 2 and delete
172
+ // a row the caller never named (E2E 2026-10-16).
173
+ const bad = tokens.filter((t) => eventIdOf(t) === null);
174
+ if (bad.length > 0) {
175
+ fail(
176
+ `[mem] activity delete: not an event id: ${bad.join(', ')} (use N, #N or E#N) — nothing was deleted`,
177
+ );
178
+ return;
179
+ }
180
+ const ids = [...new Set(tokens.map(eventIdOf))];
162
181
  if (ids.length === 0) {
163
182
  fail('[mem] activity delete: no valid IDs provided (must be positive integers)');
164
183
  return;
@@ -188,16 +207,23 @@ export async function cmdActivity(db, args) {
188
207
  return;
189
208
  }
190
209
 
210
+ // A pre-image first, as deleteObservations takes for observations (E2E 2026-10-16 round 2:
211
+ // this hard delete had none). Best-effort; outside the transaction (VACUUM INTO).
212
+ snapshotDb(db, { tag: 'pre-delete' });
191
213
  // The opt-in search telemetry keeps a title per returned result; an explicit delete drops the
192
214
  // 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
- })();
215
+ const result = db
216
+ .transaction(() => {
217
+ if (
218
+ db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()
219
+ ) {
220
+ db.prepare(
221
+ `UPDATE search_results SET snapshot_label = NULL WHERE source = 'event' AND result_id IN (${placeholders})`,
222
+ ).run(...ids);
223
+ }
224
+ return db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
225
+ })
226
+ .immediate(); // IMMEDIATE: it starts with a read (E2E 2026-10-16 round 3; see delete-core)
201
227
  out(`[mem] Deleted ${result.changes} event(s).`);
202
228
  return;
203
229
  }
@@ -206,7 +232,9 @@ export async function cmdActivity(db, args) {
206
232
  // P2(b): promote insight-bearing events (body present, importance>=N) into
207
233
  // searchable observations. Preview by default; --execute applies. One-time
208
234
  // backfill — the source events are marked promoted (idempotent re-runs).
209
- const minImp = flags['min-importance'] !== undefined ? parseInt(flags['min-importance'], 10) : 2;
235
+ // Whole digits only: a bare parseInt read `2x` as 2.
236
+ const rawMin = flags['min-importance'];
237
+ const minImp = rawMin === undefined ? 2 : /^\d+$/.test(String(rawMin)) ? Number(rawMin) : NaN;
210
238
  if (isNaN(minImp) || minImp < 1 || minImp > 3) {
211
239
  fail('[mem] activity promote: --min-importance must be 1, 2, or 3.');
212
240
  return;
package/cli/common.mjs CHANGED
@@ -8,6 +8,7 @@
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 ────────────────────────────────────────────────────────
@@ -17,7 +18,7 @@ import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from '../forma
17
18
  * `--key value` → flags.key = value; `--flag` (no value) → flags.key = true.
18
19
  * `-h` → flags.help = true.
19
20
  */
20
- export function parseArgs(argv) {
21
+ export function parseArgs(argv, { track = true } = {}) {
21
22
  const positional = [];
22
23
  const flags = {};
23
24
  // Canonical flag name for a raw `--key`. Two normalizations, both aimed at the
@@ -53,6 +54,13 @@ export function parseArgs(argv) {
53
54
  let i = 0;
54
55
  while (i < argv.length) {
55
56
  const arg = argv[i];
57
+ // POSIX end of options: everything after a bare `--` is positional, so text that starts
58
+ // with `--` can be saved (`save -- "--dry-run is ignored by deploy.sh"`). It parsed as an
59
+ // empty-named flag and the text after it as a flag of its own (E2E 2026-10-16).
60
+ if (arg === '--') {
61
+ positional.push(...argv.slice(i + 1));
62
+ break;
63
+ }
56
64
  if (arg.startsWith('--')) {
57
65
  const body = arg.slice(2);
58
66
  // `--key=value` (GNU long-option form). Split on the FIRST '=' so values that
@@ -83,7 +91,9 @@ export function parseArgs(argv) {
83
91
  i++;
84
92
  }
85
93
  }
86
- return { positional, flags: trackFlagReads(flags) };
94
+ // `track: false` for the dispatcher's own peeks (typo hints, the unknown-project note): a read
95
+ // there is not the command consuming the flag, and recording it hid the inert-flag notice.
96
+ return { positional, flags: track ? trackFlagReads(flags) : flags };
87
97
  }
88
98
 
89
99
  // ─── Inert selection flags ───────────────────────────────────────────────────
@@ -269,7 +279,11 @@ export function fail(text) {
269
279
  export function rejectBareStringFlags(flags, keys) {
270
280
  for (const key of keys) {
271
281
  if (flags[key] === true) {
272
- fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
282
+ // A value that itself starts with `-` parses as a flag of its own and lands here; the
283
+ // `=` form is the way to pass one, and nothing said so (E2E 2026-10-16 round 2).
284
+ fail(
285
+ `[mem] --${key} requires a value (received a bare flag with no value). A value that starts with - is written --${key}="…".`,
286
+ );
273
287
  return true;
274
288
  }
275
289
  }
@@ -436,26 +450,6 @@ export const COMMAND_SCOPED_FLAGS = new Map([
436
450
  ['undo', 'verify-apply'],
437
451
  ]);
438
452
 
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
453
  /**
460
454
  * Detect likely-typo flags: names NOT in KNOWN_CLI_FLAGS but within edit distance 2 of
461
455
  * 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}`);