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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +35 -4
- package/README.zh-CN.md +28 -5
- package/cli/common.mjs +11 -1
- package/hook-context.mjs +14 -8
- package/hook-episode.mjs +75 -7
- package/hook-handoff.mjs +26 -0
- package/hook-llm.mjs +11 -10
- package/hook-memory.mjs +4 -8
- package/hook-optimize.mjs +27 -5
- package/hook-shared.mjs +85 -1
- package/hook-update.mjs +5 -2
- package/hook.mjs +203 -35
- package/install.mjs +187 -30
- package/lib/citation-tracker.mjs +71 -2
- package/lib/cooldown-path.mjs +13 -0
- package/lib/deferred-work.mjs +1 -1
- package/lib/delete-core.mjs +34 -5
- package/lib/export-columns.mjs +1 -0
- package/lib/handoff-constants.mjs +4 -0
- package/lib/hook-prune.mjs +53 -17
- package/lib/hook-stdin.mjs +7 -1
- package/lib/llm-provider-probe.mjs +50 -1
- package/lib/maintain-core.mjs +109 -38
- package/lib/mcp-ownership.mjs +23 -0
- package/lib/observation-write.mjs +6 -1
- package/lib/private-strip.mjs +31 -19
- package/lib/project-rekey.mjs +178 -0
- package/lib/prompt-admission.mjs +68 -0
- package/lib/recall-core.mjs +36 -7
- package/lib/save-nudge.mjs +3 -2
- package/lib/search-core.mjs +4 -0
- package/lib/tmp-fixture-sweep.mjs +2 -1
- package/lib/verify-apply-core.mjs +9 -2
- package/mem-cli.mjs +49 -20
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -1
- package/project-utils.mjs +24 -4
- package/schema.mjs +32 -1
- package/scripts/post-tool-recall.js +5 -3
- package/scripts/post-tool-use.sh +46 -20
- package/scripts/pre-tool-recall.js +12 -5
- package/scripts/prompt-search-utils.mjs +5 -27
- package/scripts/setup.sh +7 -4
- package/search-scoring.mjs +13 -6
- package/server.mjs +58 -9
- package/source-files.mjs +5 -0
- package/tool-schemas.mjs +14 -1
- package/utils.mjs +13 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
584
|
-
|
|
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,
|
|
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
|
-
-
|
|
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
|
-
-
|
|
483
|
-
|
|
484
|
-
-
|
|
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
|
|
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} (
|
|
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
|
-
|
|
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
|
|
962
|
-
|
|
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
|
-
|
|
967
|
-
|
|
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
|
|
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-${
|
|
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
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1617
|
+
lessons.length > 0 ||
|
|
1618
|
+
keyDecisions.length > 0);
|
|
1612
1619
|
if (hasSummaryContent) {
|
|
1613
1620
|
const now = new Date();
|
|
1614
|
-
const lessonsJson =
|
|
1615
|
-
|
|
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
|
|
284
|
-
//
|
|
285
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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}"`);
|