claude-mem-lite 6.24.0 → 6.25.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 +19 -2
- package/README.zh-CN.md +29 -0
- package/bash-utils.mjs +36 -3
- package/hook-optimize.mjs +30 -10
- package/hook-shared.mjs +2 -1
- package/hook-update.mjs +46 -19
- package/install.mjs +144 -43
- package/lib/data-paths.mjs +11 -0
- package/lib/maintain-core.mjs +8 -1
- package/lib/recall-framing.mjs +18 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-use.sh +13 -4
- package/scripts/setup.sh +50 -21
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.25.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.25.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
|
@@ -237,6 +237,22 @@ 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.25.0
|
|
241
|
+
|
|
242
|
+
**Every session gets the same first line on a file-recall block again.** No schema-version
|
|
243
|
+
change: 6.24.0 still opens the database after this release has.
|
|
244
|
+
|
|
245
|
+
- **What changes.** Since 6.16.0 half of sessions got a plain statement of where the notes come
|
|
246
|
+
from instead of "system-injected context, continue your planned action", so the two could be
|
|
247
|
+
compared by how often the agent cited a lesson. On the maintainer's own sessions the comparison
|
|
248
|
+
found no difference it could detect, and a scan of the agent's replies found neither line
|
|
249
|
+
surfaced to the user, so the split is closed: unset now means the older line in every session.
|
|
250
|
+
`CLAUDE_MEM_RECALL_FRAMING=factual` keeps the plain statement; `=ab` restores the split.
|
|
251
|
+
- **Also in this release:** when the data directory cannot be read, `doctor` says "not checked"
|
|
252
|
+
for what it could not look at and `status` names the directory, instead of reporting a missing
|
|
253
|
+
or empty store. `maintain execute --ops dedup` names the merge pairs it skips. Details in
|
|
254
|
+
CHANGELOG.md.
|
|
255
|
+
|
|
240
256
|
## Upgrading to 6.24.0
|
|
241
257
|
|
|
242
258
|
**If you set `CLAUDE_CONFIG_DIR`, the installer, updates and hooks now use that directory, as
|
|
@@ -253,7 +269,8 @@ release has. Without `CLAUDE_CONFIG_DIR` nothing in this item changes.
|
|
|
253
269
|
entries an older version wrote into `~/.claude/settings.json` stay there; if no other profile
|
|
254
270
|
uses `~/.claude`, `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` removes them.
|
|
255
271
|
- **Also in this release:** `npm install -g claude-mem-lite` and `npx` no longer install the
|
|
256
|
-
development toolchain
|
|
272
|
+
development toolchain. As a dependency install it went from 298 packages / 541 MB to 96 / 57 MB;
|
|
273
|
+
after the release, `-g` and `npx` measured the same 96 packages. A confirmed
|
|
257
274
|
`maintain execute --ops purge_stale` now deletes every row its preview counted in one run,
|
|
258
275
|
behind one snapshot. `doctor` warns about a failing update check and about an
|
|
259
276
|
`ANTHROPIC_BASE_URL` that ends in `/v1`. Details in CHANGELOG.md.
|
|
@@ -1234,7 +1251,7 @@ and names can change between releases.
|
|
|
1234
1251
|
|----------|-------------|---------|
|
|
1235
1252
|
| `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
|
|
1236
1253
|
| `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
|
|
1237
|
-
| `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. `
|
|
1254
|
+
| `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. Unset or `legacy` = "system-injected context, continue your planned action"; `factual` = a plain statement of where the notes come from; `ab` = each session gets one of the two, for `benchmark/citation-live-replay.mjs --by-framing`. Any other value is `legacy`. | `legacy` |
|
|
1238
1255
|
| `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
|
|
1239
1256
|
| `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
|
|
1240
1257
|
| `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
|
package/README.zh-CN.md
CHANGED
|
@@ -199,6 +199,35 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
|
|
|
199
199
|
repos/ # 浅克隆的源代码仓库
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
+
## 升级到 6.25.0
|
|
203
|
+
|
|
204
|
+
**文件召回块的第一行,所有会话重新用同一种写法。** 没有 schema 版本变更:本版本打开过的数据库,6.24.0 仍能打开。
|
|
205
|
+
|
|
206
|
+
- **变了什么。** 从 6.16.0 起,一半会话的第一行是说明笔记来源的事实陈述,另一半保留 "system-injected context,
|
|
207
|
+
continue your planned action",用来比较 agent 引用教训的频率。在维护者自己的会话上,两种写法看不出差别,
|
|
208
|
+
按回复文本扫描,两组里 agent 也都没有把这一行当作可疑内容告诉用户,所以结束分组:
|
|
209
|
+
不设变量时,所有会话都用旧的那一行。
|
|
210
|
+
`CLAUDE_MEM_RECALL_FRAMING=factual` 保留事实陈述的写法,`=ab` 恢复分组。
|
|
211
|
+
- **本版本还有:** 数据目录读不了时,`doctor` 对看不到的项目显示“not checked”,`status` 直接指出这个目录
|
|
212
|
+
无法访问,不再报成数据库缺失或为空。`maintain execute --ops dedup` 会列出跳过的合并对。详见 CHANGELOG.md。
|
|
213
|
+
|
|
214
|
+
## 升级到 6.24.0
|
|
215
|
+
|
|
216
|
+
**如果你设置了 `CLAUDE_CONFIG_DIR`,安装程序、更新和 hook 现在都使用这个目录,和 Claude Code 一致。**
|
|
217
|
+
没有 schema 版本变更:本版本打开过的数据库,6.23.3 仍能打开。没设 `CLAUDE_CONFIG_DIR` 时,这一条不带来任何变化。
|
|
218
|
+
|
|
219
|
+
- **受影响的是谁。** 设了 `CLAUDE_CONFIG_DIR` 时,Claude Code 把 `settings.json`、`plugins/` 和 `.claude.json`
|
|
220
|
+
都放在那个目录里,插件却一直用 `~/.claude` 和 `~/.claude.json`:`install` 把 hook 写到了 Claude Code 不读的
|
|
221
|
+
地方;有两个配置目录时,会话启动的整理步骤和 `self-update` 会清掉另一个配置目录的插件缓存,并改动它的
|
|
222
|
+
`.claude.json`。
|
|
223
|
+
- **要做什么。** 用 npm 或 npx 安装的:在设好这个变量的情况下再运行一次 `claude-mem-lite install`,让 hook 写进
|
|
224
|
+
你的配置目录。插件安装不用做什么。旧版本写进 `~/.claude/settings.json` 的 hook 条目会留在那里;如果没有别的
|
|
225
|
+
配置目录在用 `~/.claude`,可以用 `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` 清掉。
|
|
226
|
+
- **本版本还有:** `npm install -g claude-mem-lite` 和 `npx` 不再安装开发工具链。按依赖方式安装,从 298 个包 /
|
|
227
|
+
541 MB 降到 96 个包 / 57 MB;发布后实测 `-g` 和 `npx` 也是同样的 96 个包。确认执行的
|
|
228
|
+
`maintain execute --ops purge_stale` 现在一次运行就删掉预览里统计的全部行,只做一次快照。`doctor` 会对失败的
|
|
229
|
+
更新检查、以及以 `/v1` 结尾的 `ANTHROPIC_BASE_URL` 发出警告。详见 CHANGELOG.md。
|
|
230
|
+
|
|
202
231
|
## 升级到 6.23.1
|
|
203
232
|
|
|
204
233
|
**如果你设置了 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY`,后台 LLM 调用现在会发往那个网关。**
|
package/bash-utils.mjs
CHANGED
|
@@ -632,6 +632,28 @@ function withoutDiffHunks(text) {
|
|
|
632
632
|
// Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
|
|
633
633
|
// uniformly to both command-parsed paths and direct file_path/path/filePath fields.
|
|
634
634
|
|
|
635
|
+
// A line a test runner prints for one test (`# Subtest:` heads a failing one too): it carries the
|
|
636
|
+
// test's name, not a summary.
|
|
637
|
+
const PASSED_TEST_LINE_RE = /^[ \t]*(?:✔|✓|ok[ \t]+\d+[ \t]+-|# Subtest:)/;
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Whether test-runner output carries a red summary or failure banner (detectBashSignificance's
|
|
641
|
+
* veto on a green "0 fail" marker; D#179).
|
|
642
|
+
* @param {string} scan
|
|
643
|
+
* @returns {boolean}
|
|
644
|
+
*/
|
|
645
|
+
function hasRedTestSummary(scan) {
|
|
646
|
+
const lines = scan
|
|
647
|
+
.split('\n')
|
|
648
|
+
.filter((l) => !PASSED_TEST_LINE_RE.test(l))
|
|
649
|
+
.join('\n');
|
|
650
|
+
return (
|
|
651
|
+
/\b[1-9]\d*[ \t]+(?:fail|failed|failures|failing|errors?)\b(?=[ \t]*(?:$|[,;|(]|\.(?:[ \t]|$)|in[ \t]+\S))|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
|
|
652
|
+
lines,
|
|
653
|
+
) || /^[ \t]*(?:--- )?FAIL(?:ED)?(?::|[ \t]|$)/m.test(lines)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
635
657
|
/**
|
|
636
658
|
* Detect significance signals in a Bash command and its response.
|
|
637
659
|
* Checks for errors, test runs, builds, git operations, and deployments.
|
|
@@ -675,12 +697,23 @@ export function detectBashSignificance(input, response) {
|
|
|
675
697
|
// suites prints one summary each, and a node:test run whose failures were all timeouts says
|
|
676
698
|
// "fail 0" next to "cancelled 1" (pre-tag defect review, item 7 F1). Red = a nonzero fail /
|
|
677
699
|
// cancelled count in either summary form.
|
|
700
|
+
// D#179: a count-first red must sit where a summary puts it — end of line, or before `,` `;`
|
|
701
|
+
// `|` `(` `. ` or `in …` — on ONE line, and never on a line a runner prints for a PASSED test
|
|
702
|
+
// (node:test `✔ name (1ms)`, vitest / bun `✓ name`, TAP `ok 1 - name` and its `# Subtest:`),
|
|
703
|
+
// which carries the test's NAME. Matched anywhere and across newlines, a passing run whose test
|
|
704
|
+
// name or log said "3 failed" / "2 failed attempts" read red, and "want 2\nFAIL" read as
|
|
705
|
+
// "2 FAIL". `errors?` is red too (pytest "1 passed, 1 error", tsc "Found 1 error in …",
|
|
706
|
+
// eslint "(2 errors, 0 warnings)"). go / jest / vitest / shell-suite failures carry a
|
|
707
|
+
// case-sensitive FAIL / FAILED banner at line start ("--- FAIL:", "FAILED: 3 case(s)", pytest
|
|
708
|
+
// "FAILED test_x.py::…"), read on its own. The green marker still
|
|
709
|
+
// spans lines on purpose: made one-line, 17 of this machine's 77,819 recorded results flipped
|
|
710
|
+
// to errors, and 15 of them were passing batteries, file reads and reports that only ever
|
|
711
|
+
// matched "0\nFAIL" by accident; 2 were real failures (measured 2026-10-07, recounted by the
|
|
712
|
+
// pre-tag delta review).
|
|
678
713
|
const hasGreenTestSummary =
|
|
679
714
|
looksLikeError &&
|
|
680
715
|
/\b0\s+(fail|failed|failures)\b|^[ \t]*(?:ℹ|#)[ \t]*fail[ \t]+0[ \t]*$/im.test(scan) &&
|
|
681
|
-
|
|
682
|
-
scan,
|
|
683
|
-
);
|
|
716
|
+
!hasRedTestSummary(scan);
|
|
684
717
|
// NOTE: do not add `\bFAIL\s` here — with /i flag it would re-match the
|
|
685
718
|
// very `0 fail\n` token green-summary is trying to exempt. A real test
|
|
686
719
|
// failure produces "N fail" (N≥1) which never triggers hasGreenTestSummary,
|
package/hook-optimize.mjs
CHANGED
|
@@ -484,8 +484,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
484
484
|
// clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
|
|
485
485
|
// A compression keeper is never hidden: hiding it hides every member compressed into it
|
|
486
486
|
// (the same rule NOT_COMPRESSION_KEEPER_SQL enforces on the maintenance writers). It falls
|
|
487
|
-
// through to the
|
|
488
|
-
//
|
|
487
|
+
// through instead: in narrow scope to the stamp-only write below (floored to importance 1
|
|
488
|
+
// and stamped, so it is not re-sent), in wide scope to the normal update.
|
|
489
489
|
const isKeeper = !!db
|
|
490
490
|
.prepare('SELECT 1 FROM observations WHERE compressed_into = ? LIMIT 1')
|
|
491
491
|
.get(cand.id);
|
|
@@ -518,6 +518,31 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
518
518
|
processed++;
|
|
519
519
|
continue;
|
|
520
520
|
}
|
|
521
|
+
// A narrow 0 reply reaches this point only for a row that may not be hidden (a compression
|
|
522
|
+
// keeper, or an importance a person set). It judged the row worthless, so it is no basis for
|
|
523
|
+
// any of the row's text either: the stored title and narrative stay (D#207: 'Weekly summary:
|
|
524
|
+
// auth refactor' became 'Weekly summary', narrative 'x'), and so do type, lesson, concepts,
|
|
525
|
+
// facts, aliases and scope (D#268). Written instead: what hiding would have written bar the
|
|
526
|
+
// hide, i.e. the stamp, so the row is not re-sent, plus the importance floor below. Wide
|
|
527
|
+
// scope stays on the normal path: its rows are substantive by construction, so a 0 there
|
|
528
|
+
// reads as a misjudgment of importance, not of the row.
|
|
529
|
+
if (scoredZero && scope !== 'wide') {
|
|
530
|
+
const res = db
|
|
531
|
+
.prepare(
|
|
532
|
+
`UPDATE observations SET
|
|
533
|
+
importance = CASE WHEN importance_set_at IS NOT NULL THEN importance
|
|
534
|
+
ELSE MAX(?, COALESCE(NULLIF(importance, 0), 1)) END,
|
|
535
|
+
optimized_at = ?
|
|
536
|
+
WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL`,
|
|
537
|
+
)
|
|
538
|
+
.run(clampImportance(parsed.importance), Date.now(), cand.id);
|
|
539
|
+
if (res.changes === 0) {
|
|
540
|
+
skipped++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
processed++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
521
546
|
|
|
522
547
|
// Enrichment ("add a lesson") must not reclassify a specific type down to the generic
|
|
523
548
|
// 'change' (lower TYPE_QUALITY + faster decay); keep the stored type on that downgrade.
|
|
@@ -558,13 +583,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
558
583
|
// For `narrow`, the truncate stays on LLM output but NOT on the preserve-on-empty
|
|
559
584
|
// fallback — truncating the row's own stored narrative to buy nothing was the same
|
|
560
585
|
// bug in miniature.
|
|
561
|
-
//
|
|
562
|
-
// A reply that scored the row 0 reaches this point only for a row that may not be hidden
|
|
563
|
-
// (a compression keeper, or an importance a person set). It judged the row worthless, so
|
|
564
|
-
// its title and narrative do not replace the stored ones either (D#207: 'Weekly summary:
|
|
565
|
-
// auth refactor' became 'Weekly summary', narrative 'x').
|
|
566
586
|
const isWide = scope === 'wide';
|
|
567
|
-
const keepStoredText = isWide
|
|
587
|
+
const keepStoredText = isWide;
|
|
568
588
|
const title = keepStoredText ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
|
|
569
589
|
const narrative = keepStoredText
|
|
570
590
|
? cand.narrative
|
|
@@ -611,8 +631,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
611
631
|
// Narrow replaces title and narrative with model text, so an explicit save it rewrites (one
|
|
612
632
|
// whose save-time enrich failed) moves to the re-enrich writer's id, as a cluster-merge
|
|
613
633
|
// keeper does (D#146, D#138). Wide keeps the stored title and narrative (it fills the lesson
|
|
614
|
-
// and the side fields), so the row stays an explicit save
|
|
615
|
-
//
|
|
634
|
+
// and the side fields), so the row stays an explicit save; a narrow 0 reply never gets here.
|
|
635
|
+
// The writer's session row is best-effort.
|
|
616
636
|
let rewriteSessionId = null;
|
|
617
637
|
if (!keepStoredText) {
|
|
618
638
|
const cur = db.prepare('SELECT memory_session_id FROM observations WHERE id = ?').get(cand.id);
|
package/hook-shared.mjs
CHANGED
|
@@ -281,7 +281,8 @@ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
|
281
281
|
// out. Both are version-keyed one-shot migration sentinels written by
|
|
282
282
|
// scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
|
|
283
283
|
// its migration. `.mcp-dedup-v2.78` gates a block that removes
|
|
284
|
-
// mcpServers.mem / mcpServers["mem-lite"] from the
|
|
284
|
+
// mcpServers.mem / mcpServers["mem-lite"] from the config home's .claude.json (one marker per
|
|
285
|
+
// .claude.json since D#270, the same prefix) with
|
|
285
286
|
// a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
|
|
286
287
|
// documents as intentionally one-shot: "If a user later runs `claude mcp add
|
|
287
288
|
// mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
|
package/hook-update.mjs
CHANGED
|
@@ -318,13 +318,28 @@ function shouldCheck(state) {
|
|
|
318
318
|
// ── GitHub API ─────────────────────────────────────────────
|
|
319
319
|
// Try releases/latest first, fallback to tags (some repos only use tags)
|
|
320
320
|
export async function fetchLatestRelease() {
|
|
321
|
-
|
|
321
|
+
const { release } = await lookupLatestRelease();
|
|
322
|
+
if (release) clearRecordedLookupFailure();
|
|
323
|
+
return release;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// repair looks up through fetchLatestRelease, so a lookup that succeeds there is evidence against
|
|
327
|
+
// the failure doctor reports; without this the warning outlived the repair by a full check interval
|
|
328
|
+
// (D#266). Only the two fields that decide whether doctor warns. lastCheck, latestVersion and
|
|
329
|
+
// rateLimited belong to checkForUpdate (the background check and self-update): rateLimited picks
|
|
330
|
+
// its interval (6 h, else 24 h), so clearing it here pushed the next check back by up to 18 h
|
|
331
|
+
// (post-release review). No write when there is nothing to clear.
|
|
332
|
+
function clearRecordedLookupFailure() {
|
|
333
|
+
const state = readState();
|
|
334
|
+
if (!state.lookupError && !state.lookupFailingSince) return;
|
|
335
|
+
saveState({ ...state, lookupError: null, lookupFailingSince: null });
|
|
322
336
|
}
|
|
323
337
|
|
|
324
338
|
// One lookup's outcome, returned rather than kept in module state: a module flag let a second
|
|
325
339
|
// lookup running at the same time read the first one's 403 (D#260). `rateLimited` is THIS
|
|
326
|
-
// lookup's refusal; the persisted flag is not that, since only a successful
|
|
327
|
-
// (pre-tag review of cd9f1ab7). `error` says why there
|
|
340
|
+
// lookup's refusal; the persisted flag is not that, since only a successful checkForUpdate (the
|
|
341
|
+
// background check or self-update) clears it (pre-tag review of cd9f1ab7). `error` says why there
|
|
342
|
+
// is no release, for doctor (D#255).
|
|
328
343
|
async function lookupLatestRelease() {
|
|
329
344
|
const headers = {
|
|
330
345
|
Accept: 'application/vnd.github+json',
|
|
@@ -905,25 +920,33 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
905
920
|
return recovered;
|
|
906
921
|
}
|
|
907
922
|
|
|
923
|
+
// A rollback swaps a file SET back just as the install swapped it in, so it holds the same
|
|
924
|
+
// marker for the same reason (D#239 g). After the MED-5 smoke gate the loop has already cleared
|
|
925
|
+
// it; after a throw inside the loop it is still set, and this call takes it over.
|
|
908
926
|
function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
927
|
+
markSwapStart();
|
|
928
|
+
try {
|
|
929
|
+
for (const relPath of installed.reverse()) {
|
|
930
|
+
try {
|
|
931
|
+
rmSync(join(targetDir, relPath), { recursive: true, force: true });
|
|
932
|
+
} catch {
|
|
933
|
+
/* best-effort */
|
|
934
|
+
}
|
|
914
935
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
936
|
+
for (const relPath of backedUp.reverse()) {
|
|
937
|
+
const backupPath = join(backupDir, relPath);
|
|
938
|
+
const targetPath = join(targetDir, relPath);
|
|
939
|
+
try {
|
|
940
|
+
if (existsSync(backupPath)) {
|
|
941
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
942
|
+
renameSync(backupPath, targetPath);
|
|
943
|
+
}
|
|
944
|
+
} catch (restoreErr) {
|
|
945
|
+
debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
|
|
923
946
|
}
|
|
924
|
-
} catch (restoreErr) {
|
|
925
|
-
debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
|
|
926
947
|
}
|
|
948
|
+
} finally {
|
|
949
|
+
clearSwapMarker();
|
|
927
950
|
}
|
|
928
951
|
}
|
|
929
952
|
|
|
@@ -1049,6 +1072,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1049
1072
|
}
|
|
1050
1073
|
|
|
1051
1074
|
markSwapStart();
|
|
1075
|
+
// Cleared here only when the loop finished. If a rename throws, the half-swapped tree keeps
|
|
1076
|
+
// the marker through the catch below until rollbackInstall has put the old files back.
|
|
1077
|
+
let swapped = false;
|
|
1052
1078
|
try {
|
|
1053
1079
|
for (const relPath of switchablePaths) {
|
|
1054
1080
|
const stagedPath = join(stagingDir, relPath);
|
|
@@ -1070,8 +1096,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1070
1096
|
journalSwap(backupDir, backedUp, installed);
|
|
1071
1097
|
renameSync(stagedPath, targetPath);
|
|
1072
1098
|
}
|
|
1099
|
+
swapped = true;
|
|
1073
1100
|
} finally {
|
|
1074
|
-
clearSwapMarker();
|
|
1101
|
+
if (swapped) clearSwapMarker();
|
|
1075
1102
|
}
|
|
1076
1103
|
|
|
1077
1104
|
// MED-5: before discarding the rollback backup, prove the switched code boots.
|
package/install.mjs
CHANGED
|
@@ -99,7 +99,12 @@ import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/nat
|
|
|
99
99
|
import { acquireLock } from './lib/proc-lock.mjs';
|
|
100
100
|
import { atomicWriteFileSync, atomicCopyFileSync } from './lib/atomic-write.mjs';
|
|
101
101
|
import { shellWord } from './cli-path.mjs';
|
|
102
|
-
import {
|
|
102
|
+
import {
|
|
103
|
+
claudeConfigDir,
|
|
104
|
+
claudeConfigDirFor,
|
|
105
|
+
claudeStatePath,
|
|
106
|
+
ignoredClaudeConfigDir,
|
|
107
|
+
} from './lib/data-paths.mjs';
|
|
103
108
|
import { isMemHook, isMemHookCommand, stripMemHooks, launcherEntryPath } from './lib/hook-prune.mjs';
|
|
104
109
|
|
|
105
110
|
// Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
|
|
@@ -1736,8 +1741,17 @@ async function status() {
|
|
|
1736
1741
|
});
|
|
1737
1742
|
}
|
|
1738
1743
|
|
|
1739
|
-
// Database
|
|
1740
|
-
|
|
1744
|
+
// Database. A data dir this process cannot enter reads as an empty one (D#199), and
|
|
1745
|
+
// `exists: false` would be a guess — null says nobody could look.
|
|
1746
|
+
const dataDirDenied = dataDirAccessError();
|
|
1747
|
+
if (dataDirDenied) {
|
|
1748
|
+
push(
|
|
1749
|
+
'fail',
|
|
1750
|
+
'database',
|
|
1751
|
+
`Database: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — Fix: ${dataDirAccessRemedy()}`,
|
|
1752
|
+
{ exists: null, error: dataDirDenied },
|
|
1753
|
+
);
|
|
1754
|
+
} else if (existsSync(DB_PATH)) {
|
|
1741
1755
|
try {
|
|
1742
1756
|
const Database = (await import('better-sqlite3')).default;
|
|
1743
1757
|
const db = new Database(DB_PATH, { readonly: true });
|
|
@@ -1954,14 +1968,37 @@ async function doctor() {
|
|
|
1954
1968
|
issues++;
|
|
1955
1969
|
}
|
|
1956
1970
|
|
|
1971
|
+
// A relative CLAUDE_CONFIG_DIR: Claude Code reads it against the directory it starts in, this
|
|
1972
|
+
// code ignores it and uses ~/.claude, so hooks, settings and the plugin cache can sit in a home
|
|
1973
|
+
// the host does not read (D#269). Followed only as an absolute path, on every face.
|
|
1974
|
+
const ignoredConfigDir = ignoredClaudeConfigDir();
|
|
1975
|
+
if (ignoredConfigDir !== null) {
|
|
1976
|
+
dwarn(
|
|
1977
|
+
`Claude Code config home: CLAUDE_CONFIG_DIR=${JSON.stringify(ignoredConfigDir)} is a relative path — Claude Code resolves it against the directory it starts in, claude-mem-lite uses ${claudeConfigDir()}`,
|
|
1978
|
+
);
|
|
1979
|
+
log('Set CLAUDE_CONFIG_DIR to an absolute path so both use the same directory.');
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1957
1982
|
const dataDirDenied = dataDirAccessError();
|
|
1983
|
+
// D#199: a directory this process cannot ENTER or LIST reads as an empty one to
|
|
1984
|
+
// existsSync/statSync, so the checks that look inside it said "no database yet" (✓), "DB 0.0MB"
|
|
1985
|
+
// (✓) and "not found". Only those checks stand down, and only then: a readable directory that
|
|
1986
|
+
// cannot be written (the usual 755 left by a `sudo` run) still reads correctly.
|
|
1987
|
+
const readMode = fsConstants.R_OK | fsConstants.X_OK;
|
|
1988
|
+
const dataDirUnreadable = dataDirDenied ? dataDirAccessError(MEM_DATA_DIR, readMode) : null;
|
|
1958
1989
|
if (dataDirDenied) {
|
|
1959
1990
|
fail(
|
|
1960
|
-
`Data directory: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) —
|
|
1961
|
-
|
|
1991
|
+
`Data directory: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — ` +
|
|
1992
|
+
(dataDirUnreadable ? 'checks that read it say "not checked"' : 'it can be read but not written') +
|
|
1993
|
+
`. Fix: ${dataDirAccessRemedy()}`,
|
|
1962
1994
|
);
|
|
1963
1995
|
issues++;
|
|
1964
1996
|
}
|
|
1997
|
+
const notCheckedDenied = (what, dir = MEM_DATA_DIR) =>
|
|
1998
|
+
dwarn(`${what}: not checked — ${dir} is not accessible`);
|
|
1999
|
+
// The code dir is the data dir in the default shape; under CLAUDE_MEM_DIR it is checked apart.
|
|
2000
|
+
const codeIsDataDir = sameDir(INSTALL_DIR, MEM_DATA_DIR);
|
|
2001
|
+
const codeDirDenied = codeIsDataDir ? dataDirUnreadable : dataDirAccessError(INSTALL_DIR, readMode);
|
|
1965
2002
|
|
|
1966
2003
|
// Which code homes does this machine actually run? A machine can hold three
|
|
1967
2004
|
// at once (plugin cache / ~/.claude-mem-lite / npm-global) and each owns its
|
|
@@ -2023,7 +2060,9 @@ async function doctor() {
|
|
|
2023
2060
|
// too-new file is still a real number; a checkmark on it is not.
|
|
2024
2061
|
let dbWriteBlocked = null;
|
|
2025
2062
|
let dbUnusableHere = false;
|
|
2026
|
-
if (
|
|
2063
|
+
if (dataDirUnreadable) {
|
|
2064
|
+
notCheckedDenied('DB schema');
|
|
2065
|
+
} else if (!existsSync(DB_PATH)) {
|
|
2027
2066
|
ok('DB schema: no database yet — nothing to compare');
|
|
2028
2067
|
} else if (rootProbes.length === 0) {
|
|
2029
2068
|
// The fourth outcome the first cut had and did not print. The `fail` above already tells
|
|
@@ -2130,9 +2169,20 @@ async function doctor() {
|
|
|
2130
2169
|
}
|
|
2131
2170
|
}
|
|
2132
2171
|
} else {
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2172
|
+
// A locked code dir reads as one with no code in it (D#199). In the default shape it IS the
|
|
2173
|
+
// data dir, already a ✗ above; under CLAUDE_MEM_DIR it is not, and this is the only line.
|
|
2174
|
+
if (codeDirDenied && codeIsDataDir) {
|
|
2175
|
+
notCheckedDenied('Entry points', INSTALL_DIR);
|
|
2176
|
+
} else if (codeDirDenied) {
|
|
2177
|
+
fail(
|
|
2178
|
+
`Entry points: ${INSTALL_DIR} is not accessible (${codeDirDenied}) — Fix: chmod u+rwx ${shellWord(INSTALL_DIR)}`,
|
|
2179
|
+
);
|
|
2180
|
+
issues++;
|
|
2181
|
+
} else {
|
|
2182
|
+
fail('server.mjs: missing');
|
|
2183
|
+
fail('hook.mjs: missing');
|
|
2184
|
+
issues += 2;
|
|
2185
|
+
}
|
|
2136
2186
|
}
|
|
2137
2187
|
|
|
2138
2188
|
// Hook self-heal runtime: the launcher (scripts/hook-launcher.mjs) degrades a
|
|
@@ -2140,7 +2190,12 @@ async function doctor() {
|
|
|
2140
2190
|
// fire. That silence is intentional but hides failure — it drops a breakage
|
|
2141
2191
|
// marker so this check can surface the otherwise-invisible degraded state.
|
|
2142
2192
|
const brokenMarker = join(MEM_RUNTIME_DIR, 'hook-launcher-broken');
|
|
2143
|
-
|
|
2193
|
+
// The marker lives in the runtime dir, which is inside the data dir unless CLAUDE_MEM_RUNTIME_DIR
|
|
2194
|
+
// moves it elsewhere.
|
|
2195
|
+
const runtimeInDataDir = (resolve(MEM_RUNTIME_DIR) + sep).startsWith(resolve(MEM_DATA_DIR) + sep);
|
|
2196
|
+
if (dataDirUnreadable && runtimeInDataDir) {
|
|
2197
|
+
notCheckedDenied('Hook self-heal');
|
|
2198
|
+
} else if (existsSync(brokenMarker)) {
|
|
2144
2199
|
let detail = '';
|
|
2145
2200
|
try {
|
|
2146
2201
|
const b = JSON.parse(readFileSync(brokenMarker, 'utf8'));
|
|
@@ -2187,32 +2242,34 @@ async function doctor() {
|
|
|
2187
2242
|
// check anywhere. Cheap probes only (DB file + .bak aggregate, no tree walk).
|
|
2188
2243
|
// The budget itself is enforced by lib/db-backup on every new snapshot; this
|
|
2189
2244
|
// check surfaces stores that predate the budget or exceed it between snapshots.
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2245
|
+
if (dataDirUnreadable) notCheckedDenied('Disk footprint');
|
|
2246
|
+
else
|
|
2247
|
+
try {
|
|
2248
|
+
const { listSnapshots, backupBudgetBytes } = await import('./lib/db-backup.mjs');
|
|
2249
|
+
const dbFile = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
2250
|
+
const dbBytes = existsSync(dbFile) ? statSync(dbFile).size : 0;
|
|
2251
|
+
const snaps = listSnapshots(dbFile);
|
|
2252
|
+
const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
|
|
2253
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
2254
|
+
// Warn threshold = the REAL eviction budget (pre-release review 2026-08-16) —
|
|
2255
|
+
// warning below it promised an eviction enforceBackupBudget would never do.
|
|
2256
|
+
if (backupBytes > backupBudgetBytes()) {
|
|
2257
|
+
dwarn(
|
|
2258
|
+
`Disk footprint: ${snaps.length} backup snapshot(s) hold ${mb(backupBytes)}MB, over the ${mb(backupBudgetBytes())}MB budget (CLAUDE_MEM_BACKUP_BUDGET_MB) — the next maintain/save snapshot evicts oldest snapshots past the 7d undo grace`,
|
|
2259
|
+
);
|
|
2260
|
+
} else {
|
|
2261
|
+
ok(
|
|
2262
|
+
`Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`,
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
} catch (e) {
|
|
2266
|
+
// "Informational" was the reason this was silent, and silence is the one thing it must
|
|
2267
|
+
// not be: the import above is of a sibling module, so the run where it fails is a broken
|
|
2268
|
+
// install — doctor's entire audience. The line vanished with no trace, which reads
|
|
2269
|
+
// identically to a check that was never written (R12 audit P3-7). Every other "I could
|
|
2270
|
+
// not look" in this file has its own sentence; this is the same shape as the four.
|
|
2271
|
+
dwarn('Disk footprint: check failed — ' + e.message);
|
|
2207
2272
|
}
|
|
2208
|
-
} catch (e) {
|
|
2209
|
-
// "Informational" was the reason this was silent, and silence is the one thing it must
|
|
2210
|
-
// not be: the import above is of a sibling module, so the run where it fails is a broken
|
|
2211
|
-
// install — doctor's entire audience. The line vanished with no trace, which reads
|
|
2212
|
-
// identically to a check that was never written (R12 audit P3-7). Every other "I could
|
|
2213
|
-
// not look" in this file has its own sentence; this is the same shape as the four.
|
|
2214
|
-
dwarn('Disk footprint: check failed — ' + e.message);
|
|
2215
|
-
}
|
|
2216
2273
|
|
|
2217
2274
|
// Plugin/hook lifecycle state
|
|
2218
2275
|
//
|
|
@@ -2360,7 +2417,9 @@ async function doctor() {
|
|
|
2360
2417
|
// and a check that reports on a thing you do not have is noise.
|
|
2361
2418
|
|
|
2362
2419
|
// Database
|
|
2363
|
-
if (
|
|
2420
|
+
if (dataDirUnreadable) {
|
|
2421
|
+
notCheckedDenied('Database');
|
|
2422
|
+
} else if (existsSync(DB_PATH)) {
|
|
2364
2423
|
try {
|
|
2365
2424
|
const Database = (await import('better-sqlite3')).default;
|
|
2366
2425
|
const db = new Database(DB_PATH, { readonly: true });
|
|
@@ -2493,20 +2552,38 @@ async function doctor() {
|
|
|
2493
2552
|
// Update state
|
|
2494
2553
|
try {
|
|
2495
2554
|
const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json'); // runtime-dir:stays-put — installation identity
|
|
2496
|
-
|
|
2555
|
+
// Dynamic, as elsewhere in doctor, so a hook-update that cannot load costs only these judgements.
|
|
2556
|
+
let hookUpdate = null;
|
|
2557
|
+
try {
|
|
2558
|
+
hookUpdate = await import('./hook-update.mjs');
|
|
2559
|
+
} catch {
|
|
2560
|
+
/* cannot judge — report the recorded state as it stands */
|
|
2561
|
+
}
|
|
2562
|
+
// While checks are off neither the background check nor self-update looks up; only a repair
|
|
2563
|
+
// does (README: a repair still runs), and since D#266 its successful lookup is the one thing
|
|
2564
|
+
// that clears a failure recorded before they were turned off. Without this branch that failure
|
|
2565
|
+
// stayed a warning, and a missing state file read "first run?". Say they are off instead.
|
|
2566
|
+
const checksOff = hookUpdate?.updateCheckDisabledReason() ?? null;
|
|
2567
|
+
if (checksOff) {
|
|
2568
|
+
const why =
|
|
2569
|
+
checksOff === 'CLAUDE_MEM_SKIP_UPDATE'
|
|
2570
|
+
? 'CLAUDE_MEM_SKIP_UPDATE is set'
|
|
2571
|
+
: `${INSTALL_DIR} is a development install (a git checkout or symlinks)`;
|
|
2572
|
+
ok(`Update state: not checked — ${why}`);
|
|
2573
|
+
} else if (dataDirUnreadable) {
|
|
2574
|
+
notCheckedDenied('Update state');
|
|
2575
|
+
} else if (existsSync(stateFile)) {
|
|
2497
2576
|
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
2498
2577
|
const parts = [];
|
|
2499
2578
|
if (state.lastCheck) parts.push(`last check: ${state.lastCheck}`);
|
|
2500
2579
|
if (state.latestVersion) parts.push(`latest: v${state.latestVersion}`);
|
|
2501
2580
|
if (state.lastUpdate) parts.push(`last update: ${state.lastUpdate}`);
|
|
2502
2581
|
// Judged against the version running now, like the banner (#35): in plugin mode
|
|
2503
|
-
// nothing clears the cached flag once Claude Code has applied the update.
|
|
2504
|
-
// elsewhere in doctor, so a hook-update that cannot load costs only this judgement.
|
|
2582
|
+
// nothing clears the cached flag once Claude Code has applied the update.
|
|
2505
2583
|
if (state.updateAvailable) {
|
|
2506
2584
|
let pending = true;
|
|
2507
2585
|
try {
|
|
2508
|
-
|
|
2509
|
-
pending = pendingCachedUpdate(state) !== null;
|
|
2586
|
+
if (hookUpdate) pending = hookUpdate.pendingCachedUpdate(state) !== null;
|
|
2510
2587
|
} catch {
|
|
2511
2588
|
/* cannot judge — report the cached flag as it stands */
|
|
2512
2589
|
}
|
|
@@ -2525,6 +2602,11 @@ async function doctor() {
|
|
|
2525
2602
|
log(
|
|
2526
2603
|
'Newer releases cannot be detected until a lookup succeeds; self-update and repair use the same lookup.',
|
|
2527
2604
|
);
|
|
2605
|
+
// doctor sees only its own environment: a CLAUDE_MEM_SKIP_UPDATE set in Claude Code's
|
|
2606
|
+
// settings `env` turns the checks off for the hooks but not here (post-release review).
|
|
2607
|
+
log(
|
|
2608
|
+
"If CLAUDE_MEM_SKIP_UPDATE is set in Claude Code's settings, background checks are off there; this record may be older, or from a run without it (a terminal self-update).",
|
|
2609
|
+
);
|
|
2528
2610
|
} else {
|
|
2529
2611
|
ok(`Update state: ${parts.join(', ') || 'empty'}`);
|
|
2530
2612
|
}
|
|
@@ -2596,6 +2678,8 @@ async function doctor() {
|
|
|
2596
2678
|
ok(
|
|
2597
2679
|
'Managed files: n/a (plugin-only install — code is served from the plugin cache, so ~/.claude-mem-lite holds data only)',
|
|
2598
2680
|
);
|
|
2681
|
+
} else if (codeDirDenied) {
|
|
2682
|
+
notCheckedDenied('Managed files', INSTALL_DIR);
|
|
2599
2683
|
} else if (r.devMode) {
|
|
2600
2684
|
const parts = [];
|
|
2601
2685
|
if (r.plainCount > 0) {
|
|
@@ -2690,6 +2774,8 @@ async function doctor() {
|
|
|
2690
2774
|
: `claude-mem-lite self-update (or: node ${shellWord(join(INSTALL_DIR, 'cli.mjs'))} repair)`;
|
|
2691
2775
|
if (skipScripts) {
|
|
2692
2776
|
ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
|
|
2777
|
+
} else if (codeDirDenied) {
|
|
2778
|
+
notCheckedDenied('Hook scripts', INSTALL_DIR);
|
|
2693
2779
|
} else if (!h.present) {
|
|
2694
2780
|
issueWarn(
|
|
2695
2781
|
`Hook scripts: ${join(INSTALL_DIR, 'scripts')} ` +
|
|
@@ -3600,15 +3686,30 @@ async function rebuildBinding() {
|
|
|
3600
3686
|
* returns for a permission error and a live peer alike — as "Another install/repair is in
|
|
3601
3687
|
* progress" and exited 0. Returns the error code, or null when accessible or absent.
|
|
3602
3688
|
*/
|
|
3603
|
-
function dataDirAccessError(
|
|
3689
|
+
function dataDirAccessError(
|
|
3690
|
+
dir = MEM_DATA_DIR,
|
|
3691
|
+
mode = fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK,
|
|
3692
|
+
) {
|
|
3604
3693
|
try {
|
|
3605
|
-
accessSync(dir,
|
|
3694
|
+
accessSync(dir, mode);
|
|
3606
3695
|
return null;
|
|
3607
3696
|
} catch (e) {
|
|
3608
3697
|
return e.code === 'ENOENT' ? null : e.code || 'EACCES';
|
|
3609
3698
|
}
|
|
3610
3699
|
}
|
|
3611
3700
|
|
|
3701
|
+
// Same directory however it is spelled (a trailing slash, a symlinked HOME). stat works on a
|
|
3702
|
+
// locked directory itself, so dev + inode decide; resolve() when either cannot be stat'ed.
|
|
3703
|
+
function sameDir(a, b) {
|
|
3704
|
+
try {
|
|
3705
|
+
const x = statSync(a);
|
|
3706
|
+
const y = statSync(b);
|
|
3707
|
+
return x.dev === y.dev && x.ino === y.ino;
|
|
3708
|
+
} catch {
|
|
3709
|
+
return resolve(a) === resolve(b);
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3612
3713
|
const dataDirAccessRemedy = () =>
|
|
3613
3714
|
`chmod u+rwx ${shellWord(MEM_DATA_DIR)} (or chown it back to your user if a sudo run created it)`;
|
|
3614
3715
|
|
package/lib/data-paths.mjs
CHANGED
|
@@ -44,6 +44,17 @@ export function claudeConfigDir() {
|
|
|
44
44
|
return d && isAbsolute(d) ? d : join(homedir(), '.claude');
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The CLAUDE_CONFIG_DIR value claudeConfigDir() does not follow (set, not empty, not absolute),
|
|
49
|
+
* else null. Claude Code resolves such a value against the directory it starts in (2.1.293), so
|
|
50
|
+
* the two can then use different config homes; doctor names it (D#269).
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
export function ignoredClaudeConfigDir() {
|
|
54
|
+
const d = process.env.CLAUDE_CONFIG_DIR;
|
|
55
|
+
return d && !isAbsolute(d) ? d : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
/**
|
|
48
59
|
* The config home for a caller that may name a HOME: `<home>/.claude` when it does (tests and
|
|
49
60
|
* fixtures pass one), else the host's own, claudeConfigDir().
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -630,6 +630,10 @@ export function mergeDuplicates(db, groups, skipped = null) {
|
|
|
630
630
|
continue;
|
|
631
631
|
}
|
|
632
632
|
if (!redirect.has(removeId)) redirect.set(removeId, keepId);
|
|
633
|
+
// D#200: a second, different keeper for the same row is a request that cannot also be
|
|
634
|
+
// honoured; say so. The same pair repeated verbatim merged as asked and stays quiet.
|
|
635
|
+
else if (redirect.get(removeId) !== keepId)
|
|
636
|
+
skip(keepId, removeId, `#${removeId} already merges into #${redirect.get(removeId)}`);
|
|
633
637
|
}
|
|
634
638
|
}
|
|
635
639
|
if (redirect.size === 0) return 0;
|
|
@@ -666,8 +670,11 @@ export function mergeDuplicates(db, groups, skipped = null) {
|
|
|
666
670
|
let merged = 0;
|
|
667
671
|
for (const removeId of redirect.keys()) {
|
|
668
672
|
const keeper = resolveKeeper(removeId);
|
|
669
|
-
if (keeper === removeId) continue; // cycle canonical: this row survives
|
|
670
673
|
const keepId = redirect.get(removeId);
|
|
674
|
+
if (keeper === removeId) {
|
|
675
|
+
skip(keepId, removeId, `cycle: #${removeId} survives`); // cycle canonical: this row survives
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
671
678
|
if (!isLive.get(keeper)) {
|
|
672
679
|
skip(keepId, removeId, `no live row #${keeper}`); // keeper not live -> never orphan
|
|
673
680
|
continue;
|
package/lib/recall-framing.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/recall-framing.mjs — the first line of a PreToolUse / PostToolUse recall block, in two
|
|
1
|
+
// lib/recall-framing.mjs — the first line of a PreToolUse / PostToolUse recall block, in two wordings.
|
|
2
2
|
//
|
|
3
3
|
// The legacy line announces itself as "system-injected context, continue your planned
|
|
4
4
|
// action". It was added in v2.40.0, modelled on the #7758 fix (a handoff injection misread
|
|
@@ -13,12 +13,21 @@
|
|
|
13
13
|
//
|
|
14
14
|
// PreToolUse is the best-cited face on record (56.2% over 7 days, 2026-09-27), so the wording
|
|
15
15
|
// is not swapped on the documentation's say-so. The factual arm keeps that line's point as a
|
|
16
|
-
// statement ("the tool call proceeds as planned")
|
|
17
|
-
// per SESSION, so one walk over one corpus
|
|
18
|
-
// taken at different times)
|
|
19
|
-
//
|
|
16
|
+
// statement ("the tool call proceeds as planned"). From 6.16.0 to 6.24.x the two ran side by
|
|
17
|
+
// side by default, assigned per SESSION, so one walk over one corpus compared them (doctrine rule
|
|
18
|
+
// 2: never diff two runs taken at different times); `ab` still does that.
|
|
19
|
+
// benchmark/citation-live-replay.mjs `--by-framing` reads the arm back from the injected text
|
|
20
|
+
// itself, which stays correct across the deploy boundary.
|
|
20
21
|
//
|
|
21
|
-
//
|
|
22
|
+
// D#104 readout (2026-10-07, docs/audits/20261007-d104-d111-readout.md): no difference the
|
|
23
|
+
// corpus can show. On ack-directive opus-5-5 sessions only, legacy cited 6/162 pretool pairs
|
|
24
|
+
// (19 sessions) and factual 2/158 (22 sessions), session-level permutation p = 0.21; the
|
|
25
|
+
// failure mode the doc names (the agent surfacing the block) matched no reply in the 20 legacy
|
|
26
|
+
// and 22 factual sessions, by a pattern scan whose positive control is weak. A no-margin rule
|
|
27
|
+
// passing at that n says nothing, so the incumbent stays: legacy is the default again and the
|
|
28
|
+
// split is opt-in.
|
|
29
|
+
//
|
|
30
|
+
// CLAUDE_MEM_RECALL_FRAMING = legacy (default) | factual | ab. Any other value is legacy.
|
|
22
31
|
|
|
23
32
|
const LEGACY_TAIL = 'system-injected context, continue your planned action:';
|
|
24
33
|
// The substring classifyRecallFraming keys on. Part of the factual line only.
|
|
@@ -39,11 +48,11 @@ const FRAMING_LINE_RE = /^\[mem\] (?:PreToolUse|PostToolUse) recall — /;
|
|
|
39
48
|
* @returns {'legacy'|'factual'}
|
|
40
49
|
*/
|
|
41
50
|
export function recallFramingArm(sessionId, env = process.env) {
|
|
42
|
-
const mode = String(env.CLAUDE_MEM_RECALL_FRAMING || '
|
|
51
|
+
const mode = String(env.CLAUDE_MEM_RECALL_FRAMING || 'legacy')
|
|
43
52
|
.trim()
|
|
44
53
|
.toLowerCase();
|
|
45
|
-
if (mode === '
|
|
46
|
-
if (!sessionId) return 'legacy';
|
|
54
|
+
if (mode === 'factual') return mode;
|
|
55
|
+
if (mode !== 'ab' || !sessionId) return 'legacy';
|
|
47
56
|
// FNV-1a, 32-bit: stable across processes and Node versions, unlike anything keyed on
|
|
48
57
|
// object identity or Math.random.
|
|
49
58
|
let h = 0x811c9dc5;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.25.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.
|
|
9
|
+
"version": "6.25.0",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.25.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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -28,10 +28,19 @@
|
|
|
28
28
|
# to the key, so only that key's own value can satisfy it.
|
|
29
29
|
# Deliberately NOT applied to the Node handoff at the tail: hook.mjs already self-guards
|
|
30
30
|
# there, so a bash false positive could only lose data, never save work.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
# "Absolute" as Node's path.isAbsolute reads it on this platform (D#269): a leading / anywhere,
|
|
32
|
+
# and in a Windows shell (Git Bash, MSYS, Cygwin) also a leading \ (UNC, root of the drive) or a
|
|
33
|
+
# drive letter and a slash. The same body sits in scripts/setup.sh.
|
|
34
|
+
_mem_is_abs() {
|
|
35
|
+
case "$1" in /*) return 0 ;; esac
|
|
36
|
+
case "${OSTYPE:-}" in
|
|
37
|
+
msys* | cygwin*) case "$1" in \\* | [A-Za-z]:[\\/]*) return 0 ;; esac ;;
|
|
38
|
+
esac
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
_mem_config_home="${HOME}/.claude"
|
|
42
|
+
_mem_is_abs "${CLAUDE_CONFIG_DIR:-}" && _mem_config_home="$CLAUDE_CONFIG_DIR"
|
|
43
|
+
_mem_settings_file="$_mem_config_home/settings.json"
|
|
35
44
|
_mem_plugin_disabled() {
|
|
36
45
|
[[ -r "$_mem_settings_file" ]] || return 1
|
|
37
46
|
local _settings
|
package/scripts/setup.sh
CHANGED
|
@@ -39,16 +39,44 @@ DB_DIR="$CODE_DIR"
|
|
|
39
39
|
# Claude Code's config home, as lib/data-paths.mjs claudeConfigDir resolves it: an absolute
|
|
40
40
|
# CLAUDE_CONFIG_DIR, else ~/.claude (a relative value is ignored). The plugin cache, settings.json
|
|
41
41
|
# and .claude.json all live there; with the variable set, ~/.claude is another profile's.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
esac
|
|
42
|
+
# The defaults are spelled once: the default arm below and the marker keys after it both read them.
|
|
43
|
+
CC_DEFAULT_CONFIG_DIR="$HOME/.claude"
|
|
44
|
+
CC_DEFAULT_STATE_FILE="$HOME/.claude.json"
|
|
45
|
+
# "Absolute" as Node's path.isAbsolute reads it on this platform (D#269): a leading / anywhere,
|
|
46
|
+
# and in a Windows shell (Git Bash, MSYS, Cygwin) also a leading \ (UNC, root of the drive) or a
|
|
47
|
+
# drive letter and a slash. The same body sits in scripts/post-tool-use.sh.
|
|
48
|
+
_mem_is_abs() {
|
|
49
|
+
case "$1" in /*) return 0 ;; esac
|
|
50
|
+
case "${OSTYPE:-}" in
|
|
51
|
+
msys* | cygwin*) case "$1" in \\* | [A-Za-z]:[\\/]*) return 0 ;; esac ;;
|
|
52
|
+
esac
|
|
53
|
+
return 1
|
|
54
|
+
}
|
|
55
|
+
if _mem_is_abs "${CLAUDE_CONFIG_DIR:-}"; then
|
|
56
|
+
CC_CONFIG_DIR="$CLAUDE_CONFIG_DIR"
|
|
57
|
+
# Trailing slashes off, a bare / kept: the marker keys below read this as text, and `cfg/` must
|
|
58
|
+
# key the same file as `cfg` (post-release review).
|
|
59
|
+
while [[ "$CC_CONFIG_DIR" == */ && "$CC_CONFIG_DIR" != / ]]; do CC_CONFIG_DIR="${CC_CONFIG_DIR%/}"; done
|
|
60
|
+
CC_STATE_FILE="$CC_CONFIG_DIR/.claude.json"
|
|
61
|
+
else
|
|
62
|
+
CC_CONFIG_DIR="$CC_DEFAULT_CONFIG_DIR"
|
|
63
|
+
CC_STATE_FILE="$CC_DEFAULT_STATE_FILE"
|
|
64
|
+
fi
|
|
65
|
+
# Steps 7 and 9 each settle a question about one file (.claude.json, settings.json) with a
|
|
66
|
+
# one-shot marker in this data dir, which every CLAUDE_CONFIG_DIR profile shares, so the first
|
|
67
|
+
# profile to start settled it for all of them (D#270). Key each marker by the file it gates. The
|
|
68
|
+
# default files keep the bare names every existing install already has: renaming one re-runs it.
|
|
69
|
+
# Not "is this ~/.claude": CLAUDE_CONFIG_DIR=~/.claude still moves .claude.json into ~/.claude.
|
|
70
|
+
# Another spelling of a path (`cfg/`, `~/./.claude`, a symlinked dir) must key the same file: -ef
|
|
71
|
+
# matches a default that exists, and any other path is hashed by its directory's physical path when
|
|
72
|
+
# that directory exists (it does once Claude Code has run: the plugin runs from its plugins/). So
|
|
73
|
+
# both callers pass the FILE, whose directory is the config dir itself.
|
|
74
|
+
marker_suffix() {
|
|
75
|
+
[[ "$1" == "$2" || "$1" -ef "$2" ]] && return 0
|
|
76
|
+
local key="$1"
|
|
77
|
+
[[ -d "${1%/*}" ]] && key="$(cd "${1%/*}" && pwd -P)/${1##*/}"
|
|
78
|
+
printf -- '-%s' "$(printf '%s' "$key" | cksum | cut -d' ' -f1)"
|
|
79
|
+
}
|
|
52
80
|
RUNTIME_DIR="$CODE_DIR/runtime"
|
|
53
81
|
if [[ -n "${CLAUDE_MEM_DIR:-}" || -n "${CLAUDE_MEM_RUNTIME_DIR:-}" ]] && [[ -f "$ROOT/lib/resolve-data-dir.mjs" ]]; then
|
|
54
82
|
# shellcheck disable=SC2016 # node script single-quoted on purpose; path passed via env, not shell expansion
|
|
@@ -171,9 +199,9 @@ mkdir -p "$CODE_DIR/runtime"
|
|
|
171
199
|
# resolved once at the top of this file.
|
|
172
200
|
#
|
|
173
201
|
# ONLY this marker follows the override. `.mcp-dedup-v2.78` and `.residue-warned-v2.55`
|
|
174
|
-
# below are one-shot state about
|
|
175
|
-
#
|
|
176
|
-
#
|
|
202
|
+
# below are one-shot state about a CONFIG HOME — a .claude.json edit and a settings.json
|
|
203
|
+
# warning, not state a hook hands to another component — so they stay under CODE_DIR, one per
|
|
204
|
+
# file they gate. Read lib/resolve-data-dir.mjs's MOVES/STAYS list before relocating either:
|
|
177
205
|
# moving a run-once marker re-runs what it gated.
|
|
178
206
|
DEPS_FLAG="$RUNTIME_DIR/.deps-broken"
|
|
179
207
|
|
|
@@ -314,9 +342,10 @@ fi
|
|
|
314
342
|
# pre-v2.79.1 — extra node spawn + JSON parse on every SessionStart for a
|
|
315
343
|
# near-always no-op). Bump MCP_MIGRATION name to re-run cleanup in future
|
|
316
344
|
# versions; same shape as the .deps-broken self-heal pattern.
|
|
317
|
-
# CODE_DIR/runtime, not RUNTIME_DIR: this marker gates a one-shot edit of
|
|
318
|
-
# which is
|
|
319
|
-
|
|
345
|
+
# CODE_DIR/runtime, not RUNTIME_DIR: this marker gates a one-shot edit of the config home's
|
|
346
|
+
# .claude.json, which is not per-data-dir state. Relocating it would re-run that edit. One per
|
|
347
|
+
# .claude.json (marker_suffix above).
|
|
348
|
+
MCP_MIGRATION="$CODE_DIR/runtime/.mcp-dedup-v2.78$(marker_suffix "$CC_STATE_FILE" "$CC_DEFAULT_STATE_FILE")"
|
|
320
349
|
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$MCP_MIGRATION" ]]; then
|
|
321
350
|
# shellcheck disable=SC2016 # node script single-quoted on purpose; CLAUDE_JSON passed via env, not shell expansion
|
|
322
351
|
CLAUDE_JSON="$CC_STATE_FILE" node -e '
|
|
@@ -393,14 +422,14 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
|
|
|
393
422
|
fi
|
|
394
423
|
|
|
395
424
|
# 9. Residue detection (plugin mode only): warn once if legacy direct-install
|
|
396
|
-
# hooks remain in
|
|
425
|
+
# hooks remain in the config home's settings.json. A user who installed via global
|
|
397
426
|
# `claude-mem-lite install` and later switched to the marketplace plugin
|
|
398
427
|
# will run every hook twice (direct settings.json hooks AND plugin hooks)
|
|
399
428
|
# until they run `claude-mem-lite uninstall` to clear the settings.json
|
|
400
429
|
# entries. /plugin uninstall does not touch settings.json.
|
|
401
|
-
# CODE_DIR/runtime, same reason: the residue it warns about is stale hook entries in
|
|
402
|
-
#
|
|
403
|
-
RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55"
|
|
430
|
+
# CODE_DIR/runtime, same reason: the residue it warns about is stale hook entries in the config
|
|
431
|
+
# home's settings.json — one warning per config home, regardless of where the data lives.
|
|
432
|
+
RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55$(marker_suffix "$CC_CONFIG_DIR/settings.json" "$CC_DEFAULT_CONFIG_DIR/settings.json")"
|
|
404
433
|
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
405
434
|
SETTINGS="$CC_CONFIG_DIR/settings.json"
|
|
406
435
|
if [[ -f "$SETTINGS" ]]; then
|
|
@@ -438,7 +467,7 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
|
438
467
|
' || true
|
|
439
468
|
fi
|
|
440
469
|
# Mark the warning as shown regardless of result — silence is fine if no
|
|
441
|
-
# residue, and the warning above is one-shot per
|
|
470
|
+
# residue, and the warning above is one-shot per settings.json (RESIDUE_MARKER's key).
|
|
442
471
|
touch "$RESIDUE_MARKER"
|
|
443
472
|
fi
|
|
444
473
|
|