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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.24.0",
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.24.0",
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 (96 packages / 57 MB instead of 298 / 541 MB). A confirmed
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. `ab` gives each session one of two wordings, the older "system-injected context, continue your planned action" or a plain statement of source, so their cite-rates can be compared in one run (`benchmark/citation-live-replay.mjs --by-framing`); `legacy` / `factual` pin one. | `ab` |
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
- !/\b[1-9]\d*\s+(fail|failed|failures|failing)\b|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
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 normal update instead: floored to importance 1 and stamped, so it is not
488
- // re-sent. That update keeps its stored title and narrative in both scopes (keepStoredText).
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 || scoredZero;
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, and so does a row whose stored
615
- // text a 0 reply left in place. The writer's session row is best-effort.
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 user's ~/.claude.json with
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
- return (await lookupLatestRelease()).release;
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 lookup clears it
327
- // (pre-tag review of cd9f1ab7). `error` says why there is no release, for doctor (D#255).
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
- for (const relPath of installed.reverse()) {
910
- try {
911
- rmSync(join(targetDir, relPath), { recursive: true, force: true });
912
- } catch {
913
- /* best-effort */
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
- for (const relPath of backedUp.reverse()) {
917
- const backupPath = join(backupDir, relPath);
918
- const targetPath = join(targetDir, relPath);
919
- try {
920
- if (existsSync(backupPath)) {
921
- mkdirSync(dirname(targetPath), { recursive: true });
922
- renameSync(backupPath, targetPath);
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 { claudeConfigDir, claudeConfigDirFor, claudeStatePath } from './lib/data-paths.mjs';
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
- if (existsSync(DB_PATH)) {
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}) — the checks below ` +
1961
- `read it as missing. Fix: ${dataDirAccessRemedy()}`,
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 (!existsSync(DB_PATH)) {
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
- fail('server.mjs: missing');
2134
- fail('hook.mjs: missing');
2135
- issues += 2;
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
- if (existsSync(brokenMarker)) {
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
- try {
2191
- const { listSnapshots, backupBudgetBytes } = await import('./lib/db-backup.mjs');
2192
- const dbFile = join(MEM_DATA_DIR, 'claude-mem-lite.db');
2193
- const dbBytes = existsSync(dbFile) ? statSync(dbFile).size : 0;
2194
- const snaps = listSnapshots(dbFile);
2195
- const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
2196
- const mb = (n) => (n / (1024 * 1024)).toFixed(1);
2197
- // Warn threshold = the REAL eviction budget (pre-release review 2026-08-16) —
2198
- // warning below it promised an eviction enforceBackupBudget would never do.
2199
- if (backupBytes > backupBudgetBytes()) {
2200
- dwarn(
2201
- `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`,
2202
- );
2203
- } else {
2204
- ok(
2205
- `Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`,
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 (existsSync(DB_PATH)) {
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
- if (existsSync(stateFile)) {
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. Dynamic, as
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
- const { pendingCachedUpdate } = await import('./hook-update.mjs');
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(dir = MEM_DATA_DIR) {
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, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
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
 
@@ -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().
@@ -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;
@@ -1,4 +1,4 @@
1
- // lib/recall-framing.mjs — the first line of a PreToolUse / PostToolUse recall block, in two arms.
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") and the two arms run side by side, assigned
17
- // per SESSION, so one walk over one corpus compares them (doctrine rule 2: never diff two runs
18
- // taken at different times). benchmark/citation-live-replay.mjs `--by-framing` reads the arm
19
- // back from the injected text itself, which stays correct across the deploy boundary.
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
- // CLAUDE_MEM_RECALL_FRAMING = ab (default) | legacy | factual.
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 || 'ab')
51
+ const mode = String(env.CLAUDE_MEM_RECALL_FRAMING || 'legacy')
43
52
  .trim()
44
53
  .toLowerCase();
45
- if (mode === 'legacy' || mode === 'factual') return 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;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.24.0",
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.24.0",
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.24.0",
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",
@@ -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
- case "$CLAUDE_CONFIG_DIR" in
32
- /* | [A-Za-z]:[\\/]*) _mem_settings_file="${CLAUDE_CONFIG_DIR}/settings.json" ;;
33
- *) _mem_settings_file="${HOME}/.claude/settings.json" ;;
34
- esac
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
- case "${CLAUDE_CONFIG_DIR:-}" in
43
- /* | [A-Za-z]:[\\/]*)
44
- CC_CONFIG_DIR="$CLAUDE_CONFIG_DIR"
45
- CC_STATE_FILE="$CLAUDE_CONFIG_DIR/.claude.json"
46
- ;;
47
- *)
48
- CC_CONFIG_DIR="$HOME/.claude"
49
- CC_STATE_FILE="$HOME/.claude.json"
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 THIS MACHINE's install — a ~/.claude.json edit and a
175
- # settings.json warning, not state a hook hands to another component — so they stay under
176
- # CODE_DIR. Read lib/resolve-data-dir.mjs's MOVES/STAYS list before relocating either:
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 ~/.claude.json,
318
- # which is machine state, not per-data-dir state. Relocating it would re-run that edit.
319
- MCP_MIGRATION="$CODE_DIR/runtime/.mcp-dedup-v2.78"
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 ~/.claude/settings.json. A user who installed via global
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
- # ~/.claude/settings.json — one machine, one warning, regardless of where the data lives.
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 data-dir.
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