claude-mem-lite 6.10.3 → 6.11.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.10.3",
12
+ "version": "6.11.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. Hybrid FTS5 + TF-IDF 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.10.3",
3
+ "version": "6.11.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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,25 @@ 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.11.0
241
+
242
+ **One default changes: re-enrich stops leaving part of its budget idle.** It reserves half of
243
+ each run's budget for two backfill passes and gives its main scope the rest. When the main
244
+ scope had fewer rows to enrich than its share, the remainder went unspent; it now goes to the
245
+ backfills. The daily unattended pass runs once per machine per day, over all projects
246
+ together, so with an empty main pool it now makes up to 6 of these LLM calls a day where it
247
+ made 3. The ceiling of 6 is unchanged — it was always the declared budget — and a separately
248
+ budgeted scope-classification pass, also unchanged, can add up to 6 more short calls. A manual
249
+ `claude-mem-lite optimize --run` or `mem_optimize` behaves the same way. No schema change and
250
+ no migration: an older build still opens the database, so reverting is pinning
251
+ `claude-mem-lite@6.10.3`.
252
+
253
+ **One security fix does not reach data you already have.** In some combinations of two
254
+ labelled credentials on one line — `token: <v> secret: <v>` is one, when the first value ends
255
+ in a letter — earlier versions redacted the first and stored the second as typed. Values on
256
+ separate lines were never affected. This release fixes the write path; nothing already in
257
+ your database is rewritten.
258
+
240
259
  <!-- normalize-per-project-note:start -->
241
260
  ## Upgrading to 6.8.0
242
261
 
package/README.zh-CN.md CHANGED
@@ -199,6 +199,20 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.11.0
203
+
204
+ **只有一个默认行为变化:re-enrich 不再让一部分预算空着。** 它为两个回填任务预留每次运行一半的
205
+ 预算,其余给主范围。以前主范围待处理的行数少于它那一份时,剩下的额度就空着不用;现在转给回填
206
+ 任务。每日后台任务是**每台机器每天一次、对所有项目合并运行**,所以主池为空时,它现在每天最多
207
+ 做 6 次这类 LLM 调用,而以前是 3 次。上限 6 没变——这一直是声明的预算;另有一个单独计预算的
208
+ 「范围分类」任务,本版未改动,最多还会再加 6 次简短调用。手动运行 `claude-mem-lite optimize --run`
209
+ 或 `mem_optimize` 行为相同。没有 schema 变更、没有迁移:旧版本仍能打开数据库,回退就是固定到
210
+ `claude-mem-lite@6.10.3`。
211
+
212
+ **有一个安全修复不会作用于你已有的数据。** 同一行里两个带标签的凭据,在某些组合下——例如
213
+ `token: <v> secret: <v>`,且第一个值以字母结尾——以前的版本会脱敏第一个、把第二个按原样存下来。
214
+ 分行写的值从未受影响。本版本修的是写入路径;数据库里已有的内容不会被改写。
215
+
202
216
  <!-- normalize-per-project-note:start -->
203
217
  ## 升级到 6.8.0
204
218
 
package/hook-optimize.mjs CHANGED
@@ -1739,14 +1739,55 @@ export async function optimizeRun(
1739
1739
  // Both pools drain (each is idempotent via the column it fills), so the
1740
1740
  // ordering decides which drains first, not which gets served at all.
1741
1741
  const half = Math.max(1, Math.floor(budget.reenrich / 2));
1742
+ // D#51: `half` is the fill passes' CAP, not their entitlement — and the main
1743
+ // scope's remainder used to evaporate whenever main's own pool held fewer rows
1744
+ // than its share. The comment above states the symmetric case ("a
1745
+ // zero-candidate aliases pass costs nothing and the main scope keeps its full
1746
+ // budget") and neither stated nor implemented the reverse.
1747
+ //
1748
+ // The unit is ONE RUN, not one project. The daily path (handleLLMOptimize)
1749
+ // calls this once per machine per day with no `project`, so every pool here is
1750
+ // a union over all projects; only normalize fans out per project. Measured
1751
+ // read-only on the live DB 2026-09-22: the union read wide 0 / aliases 0 against
1752
+ // a concepts backlog of 74 (78 on a re-read later that day — every session adds
1753
+ // rows), so the daily run idled 3 of its 6 slots and the backlog drained at 3 a
1754
+ // day; it now drains at 6. (A first draft of this comment summed eight
1755
+ // per-project shares into "26 slots a day" — arithmetic about eight runs that
1756
+ // never happen. The ledger's original union reading was the right one.)
1757
+ //
1758
+ // Main is MEASURED first and still RUNS first. That distinction is the whole
1759
+ // safety argument: this is a SELECT, and the execution order below — which is
1760
+ // load-bearing for a reason the next comment gives — is untouched.
1761
+ //
1762
+ // Nor can this reopen the starvation the ordering comment forbids. The fill
1763
+ // passes take at most `fillCap`, so mainBudget >= budget.reenrich - fillCap =
1764
+ // min(budget.reenrich - half, mainPool): when main's pool is at or below its old
1765
+ // floor it now receives ALL of it, and when the pool is larger the arithmetic is
1766
+ // byte-for-byte what it was. So THIS CHANGE introduces no input on which a fill
1767
+ // pass takes a slot the main scope could have spent — which is the comparative
1768
+ // claim, and the only one that holds. An earlier draft said it absolutely ("there
1769
+ // is no input..."), and pre-ship review brute-forced 129,654 inputs and found
1770
+ // 98,713 counter-examples to the absolute: at R=6 with mainPool=4, half=3 and
1771
+ // fillCap=3, main could have spent 4 and gets 3. That is PRE-EXISTING — the old
1772
+ // arithmetic gives 3 there too — so the comparative reading is sound and the
1773
+ // unqualified one was never true of this code. Pinned by "does not take the main
1774
+ // scope below what its own pool can use" and by "measures the main pool with the
1775
+ // scope it is about to RUN".
1776
+ const mainPool = findReenrichCandidates(db, budget.reenrich, {
1777
+ scope: reenrichScope,
1778
+ project,
1779
+ }).length;
1780
+ const fillCap = Math.max(half, budget.reenrich - mainPool);
1742
1781
  const aliasBudget = Math.min(
1743
- half,
1744
- findReenrichCandidates(db, half, { scope: 'aliases', project }).length,
1782
+ fillCap,
1783
+ findReenrichCandidates(db, fillCap, { scope: 'aliases', project }).length,
1745
1784
  );
1746
1785
  const conceptsBudget = Math.min(
1747
- half - aliasBudget,
1748
- findReenrichCandidates(db, Math.max(0, half - aliasBudget), { scope: 'concepts', project })
1749
- .length,
1786
+ fillCap - aliasBudget,
1787
+ findReenrichCandidates(db, Math.max(0, fillCap - aliasBudget), {
1788
+ scope: 'concepts',
1789
+ project,
1790
+ }).length,
1750
1791
  );
1751
1792
  const scopesBudget = Math.min(
1752
1793
  budget.reenrich,
package/install.mjs CHANGED
@@ -65,6 +65,17 @@ const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
65
65
  // imports — this pair used to be typed out in each.
66
66
  import { MARKETPLACE_KEY, PLUGIN_KEY, PLUGIN_NAME, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
67
67
  import { doctorDbModeHint } from './lib/doctor-modes.mjs';
68
+ // Static, matching doctor-modes above. Safe here where it would not be for a heavier
69
+ // module: this one is a leaf over node:fs + node:child_process, so it adds no load graph
70
+ // to the entry point that has to survive a broken install.
71
+ import { checkHookInterpreter } from './lib/doctor-hook-interpreter.mjs';
72
+ import {
73
+ classifyEpisodeFile,
74
+ EPISODE_AGE_LABEL,
75
+ isEpisodeResidue,
76
+ isUpdateResidue,
77
+ scanStaleTempFiles,
78
+ } from './lib/doctor-stale-temp.mjs';
68
79
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
69
80
 
70
81
  import {
@@ -84,7 +95,6 @@ import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/
84
95
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
85
96
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
86
97
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
87
- import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
88
98
  import { acquireLock } from './lib/proc-lock.mjs';
89
99
  import { atomicWriteFileSync } from './lib/atomic-write.mjs';
90
100
  import { isMemHook, launcherEntryPath } from './lib/hook-prune.mjs';
@@ -279,59 +289,6 @@ export function buildDoctorSummary(issues, warnings) {
279
289
  return `${issues} issue(s) found.${warnSuffix}`;
280
290
  }
281
291
 
282
- /**
283
- * How many LIVE hook commands invoke `bash`, and which scripts they are.
284
- *
285
- * There are two hook registrations and only one is live per install shape, which is what
286
- * the first cut of doctor's interpreter check got wrong (pre-ship review P1-1). The plugin
287
- * shape reads `hooks/hooks.json` out of the plugin cache. The npm / npx / `git clone` shape
288
- * has no such file — `hooks/hooks.json` is in RELEASE_SIGNED_FILES but NOT in SOURCE_FILES,
289
- * so nothing deploys it to ~/.claude-mem-lite/ — and registers its hooks in settings.json
290
- * instead. Reading only the manifest therefore answered "zero bash hooks" on the one shape
291
- * where two of them are live.
292
- *
293
- * Returns THREE outcomes, never two. `count: null` means no registration could be read, and
294
- * that is deliberately distinct from a count of zero: zero is an answer, null is the absence
295
- * of one, and a diagnostic that reports them identically tells the reader to stop looking.
296
- *
297
- * @param {{manifestPath: string, settingsCommands?: string[], installDir: string}} opts
298
- * @returns {{count: number|null, source: 'manifest'|'settings'|null, scripts: string[]}}
299
- */
300
- export function resolveBashHookCount({ manifestPath, settingsCommands = [], installDir }) {
301
- const basenames = (commands) =>
302
- commands
303
- .map((c) => {
304
- const m = c.match(/([^/"\s]+\.sh)/);
305
- return m ? m[1] : c;
306
- })
307
- .sort();
308
-
309
- if (existsSync(manifestPath)) {
310
- try {
311
- const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
312
- const commands = [];
313
- for (const matchers of Object.values(parsed?.hooks || {})) {
314
- for (const m of matchers || []) {
315
- for (const h of m?.hooks || []) commands.push(String(h?.command || ''));
316
- }
317
- }
318
- const bash = commands.filter((c) => c.startsWith('bash '));
319
- return { count: bash.length, source: 'manifest', scripts: basenames(bash) };
320
- } catch {
321
- // A torn manifest is not evidence of zero bash hooks. Fall through to settings.json,
322
- // and if that says nothing about us either, the caller gets null.
323
- }
324
- }
325
- // Only OUR entries: settings.json is shared with every other tool the user installs, so a
326
- // foreign `bash "…"` line is not ours to report on, and — the discriminating half — a
327
- // settings.json that names nothing of ours is not evidence that no hook needs bash. It is
328
- // evidence we are reading the wrong registration.
329
- const ours = settingsCommands.filter((c) => c.includes(installDir));
330
- if (ours.length === 0) return { count: null, source: null, scripts: [] };
331
- const bash = ours.filter((c) => c.startsWith('bash '));
332
- return { count: bash.length, source: 'settings', scripts: basenames(bash) };
333
- }
334
-
335
292
  // Dev installs symlink server.mjs → the project's source file. Used to suppress
336
293
  // misleading "first run" messages since hook-update.mjs skips state-writes in
337
294
  // this mode (see hook-update.mjs isDevMode).
@@ -2586,95 +2543,50 @@ async function doctor() {
2586
2543
  dwarn('Hook scripts: check failed — ' + e.message);
2587
2544
  }
2588
2545
 
2589
- // Hook interpreter. Some hook commands are `bash "<script>"` (the PostToolUse and
2590
- // Agent prefilters, plus setup.sh in the plugin manifest) — the rest are `node`. If bash
2591
- // cannot run, those commands fail and nothing says so; the check above grades whether the
2592
- // FILES are present, which they are.
2593
- //
2594
- // Keyed on whether bash runs, not on process.platform === 'win32'. A Windows user with
2595
- // Git for Windows on PATH — the normal case, since Claude Code shells out to bash for its
2596
- // own Bash tool — has a working configuration and must not be warned; a stripped
2597
- // container with no bash has a broken one and must be, whatever its platform. This is
2598
- // also what issue #28's P3-19 intent asked for: `os: [darwin, linux]` was added so a
2599
- // Windows user "should be told rather than handed a string of silent catch blocks", and
2600
- // blocking the install told them nothing. This is the telling.
2601
- try {
2602
- const {
2603
- count: bashCommands,
2604
- source: countSource,
2605
- scripts: bashScripts,
2606
- } = resolveBashHookCount({
2546
+ // Hook interpreter — see lib/doctor-hook-interpreter.mjs for what it grades and why it
2547
+ // keys on whether bash RUNS rather than on process.platform. The two registration paths
2548
+ // are passed in rather than recomputed there, because they appear verbatim in the
2549
+ // "could not read either registration" message and that text is asserted on.
2550
+ checkHookInterpreter(
2551
+ { ok, dwarn },
2552
+ {
2607
2553
  manifestPath: join(PROJECT_DIR, 'hooks', 'hooks.json'),
2554
+ settingsPath: join(homedir(), '.claude', 'settings.json'),
2608
2555
  settingsCommands: settingsHookCommands(homedir()),
2609
2556
  installDir: INSTALL_DIR,
2610
- });
2611
- if (bashCommands === null) {
2612
- // NOT `ok`. Pre-ship review (P1-1) found the first cut printing "no hook command needs
2613
- // bash" here, on a shape where two of them are registered — a green line that ends the
2614
- // reader's search is worse than the silence this check exists to remove.
2615
- dwarn(
2616
- 'Hook interpreter: could not read either hook registration — neither ' +
2617
- `${join(PROJECT_DIR, 'hooks', 'hooks.json')} nor a claude-mem-lite entry in ` +
2618
- `${join(homedir(), '.claude', 'settings.json')} — so whether any hook needs bash is unknown.`,
2619
- );
2620
- } else if (bashCommands === 0) {
2621
- ok(`Hook interpreter: no hook command needs bash (per the ${countSource})`);
2622
- } else {
2623
- let bashOk = false;
2624
- try {
2625
- execFileSync('bash', ['-c', 'exit 0'], { stdio: 'ignore', timeout: 5000 });
2626
- bashOk = true;
2627
- } catch {
2628
- /* not resolvable, or not runnable — either way the hooks that need it cannot fire */
2629
- }
2630
- if (bashOk) {
2631
- ok(`Hook interpreter: bash present (${bashCommands} hook command(s) need it)`);
2632
- } else {
2633
- // dwarn, not an issue: everything else works. Saying "broken" about an install
2634
- // whose MCP server and node hooks are fine would be the mirror of the defect that
2635
- // sent this round's reporter looking at their disk and their network.
2636
- // The scripts are NAMED from the live registration rather than described from
2637
- // memory — the first cut wrote "(episode Read-tracking and the subagent prefilter)",
2638
- // a two-item gloss on a count of three (P3-1).
2639
- dwarn(
2640
- `Hook interpreter: bash not found on PATH — the ${bashCommands} hook command(s) that ` +
2641
- `invoke it cannot fire (${bashScripts.join(', ')}). The MCP server and the node ` +
2642
- 'hooks are unaffected. On Windows, install Git for Windows or use WSL; elsewhere ' +
2643
- 'this means a stripped PATH.',
2644
- );
2645
- }
2646
- }
2647
- } catch (e) {
2648
- dwarn('Hook interpreter: check failed — ' + e.message);
2649
- }
2557
+ },
2558
+ );
2650
2559
 
2651
- // Stale temp files
2560
+ // Stale temp files. The rules live in lib/doctor-stale-temp.mjs because this scanner and
2561
+ // cleanup's deleter are the same question asked twice and had drifted twice — see that
2562
+ // file. Counting is all that differs here; the classification is shared, so "what doctor
2563
+ // calls stale" and "what cleanup removes" agree on the age gate, which is the axis they
2564
+ // last diverged on. Not on every axis: cleanup skips update residue entirely while
2565
+ // install.lock is held and the scanner has no such gate, so mid-self-update doctor still
2566
+ // counts a file cleanup will decline. That one is milder than D#53 — cleanup SAYS it is
2567
+ // skipping rather than answering "No stale files found" — and it predates this change.
2652
2568
  try {
2653
- // hook-update + the episode workers write runtime/ + staging under DB_DIR
2654
- // (= MEM_DATA_DIR, env-aware), NOT the homedir code dir — scan there so doctor
2655
- // sees the real residue under relocation. MEM_RUNTIME_DIR rather than
2656
- // join(MEM_DATA_DIR,'runtime'): `pending-*` / `ep-flush-*` are written through
2657
- // hook-shared.mjs's override-aware RUNTIME_DIR, and `cleanup()` below deletes them from
2658
- // MEM_RUNTIME_DIR — v3.93.0 moved the deleter and left this scanner behind, so under the
2659
- // override doctor reported "none" while the cleanup it recommends removed files.
2660
- const runtimeDir = MEM_RUNTIME_DIR;
2661
- let staleCount = 0;
2662
- const stalePatterns = ['.update-staging-', '.update-backup-'];
2663
- if (existsSync(MEM_DATA_DIR)) {
2664
- for (const f of readdirSync(MEM_DATA_DIR)) {
2665
- if (stalePatterns.some((p) => f.startsWith(p))) staleCount++;
2666
- }
2667
- }
2668
- if (existsSync(runtimeDir)) {
2669
- for (const f of readdirSync(runtimeDir)) {
2670
- if (f.startsWith('pending-') || f.startsWith('ep-flush-')) staleCount++;
2671
- }
2672
- }
2673
- if (staleCount > 0) {
2674
- dwarn(`Stale temp files: ${staleCount} found (run: node install.mjs cleanup)`);
2569
+ const { stale, inFlight } = scanStaleTempFiles({
2570
+ dataDir: MEM_DATA_DIR,
2571
+ runtimeDir: MEM_RUNTIME_DIR,
2572
+ });
2573
+ if (stale > 0) {
2574
+ dwarn(`Stale temp files: ${stale} found (run: node install.mjs cleanup)`);
2675
2575
  } else {
2676
2576
  ok('Stale temp files: none');
2677
2577
  }
2578
+ // D#53: reported as a DETAIL, not a warning. An episode file younger than the gate is
2579
+ // work in progress — after a Stop that hands an episode to the summarizer it exists for
2580
+ // up to ~60s, the worst-case round trip — so warning about it
2581
+ // put a permanent ⚠ on healthy machines and sent them to a command that answers "No
2582
+ // stale files found." Still said out loud rather than hidden, because a bare "none"
2583
+ // next to a runtime dir that visibly holds files is the kind of green line that ends
2584
+ // the reader's search. Mirrors cleanup's own "Kept N …" line.
2585
+ if (inFlight > 0) {
2586
+ log(
2587
+ ` ${inFlight} episode file(s) newer than ${EPISODE_AGE_LABEL} are in flight, not stale — cleanup keeps these.`,
2588
+ );
2589
+ }
2678
2590
  } catch {
2679
2591
  dwarn('Stale temp files: check failed');
2680
2592
  }
@@ -3070,13 +2982,12 @@ function cleanup() {
3070
2982
  // and the window is long — it spans the source-compile fallback, up to five minutes —
3071
2983
  // while doctor is actively telling the user to run cleanup. Non-blocking: if an installer
3072
2984
  // holds the lock we skip only these two patterns, not the rest of cleanup.
3073
- const stalePatterns = ['.update-staging-', '.update-backup-'];
3074
2985
  const updateLock = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
3075
2986
  if (!updateLock) {
3076
2987
  warn('Update residue skipped: install in progress (install.lock held)');
3077
2988
  } else if (existsSync(MEM_DATA_DIR)) {
3078
2989
  for (const f of readdirSync(MEM_DATA_DIR)) {
3079
- if (stalePatterns.some((p) => f.startsWith(p))) {
2990
+ if (isUpdateResidue(f)) {
3080
2991
  if (dryRun) {
3081
2992
  ok(`Would remove: ${f}`);
3082
2993
  removed++;
@@ -3108,20 +3019,13 @@ function cleanup() {
3108
3019
  // the conservative one.
3109
3020
  const runtimeDir = MEM_RUNTIME_DIR;
3110
3021
  if (existsSync(runtimeDir)) {
3111
- const epCutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
3022
+ const now = Date.now();
3112
3023
  let inFlight = 0;
3113
3024
  for (const f of readdirSync(runtimeDir)) {
3114
- if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
3115
- // Unreadable mtime → treat as in-flight and skip. Failing safe here costs one
3116
- // stale file until the next sweep; failing open costs an episode.
3117
- let mtimeMs;
3118
- try {
3119
- mtimeMs = statSync(join(runtimeDir, f)).mtimeMs;
3120
- } catch {
3121
- inFlight++;
3122
- continue;
3123
- }
3124
- if (mtimeMs > epCutoff) {
3025
+ if (isEpisodeResidue(f)) {
3026
+ // The gate itself lives in lib/doctor-stale-temp.mjs, so doctor's count and this
3027
+ // deletion cannot disagree about which files are in flight (D#53).
3028
+ if (classifyEpisodeFile(runtimeDir, f, { now }) === 'in-flight') {
3125
3029
  inFlight++;
3126
3030
  continue;
3127
3031
  }
@@ -3141,7 +3045,7 @@ function cleanup() {
3141
3045
  }
3142
3046
  if (inFlight > 0) {
3143
3047
  log(
3144
- ` Kept ${inFlight} episode file(s) newer than 1h — possibly in flight, they sweep automatically once stale.`,
3048
+ ` Kept ${inFlight} episode file(s) newer than ${EPISODE_AGE_LABEL} — possibly in flight, they sweep automatically once stale.`,
3145
3049
  );
3146
3050
  }
3147
3051
  }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * doctor's hook-interpreter check, extracted from install.mjs.
3
+ *
4
+ * Why it moved, since "it was long" is not one of this repo's `lib/` criteria: the
5
+ * second criterion is a unit carved out of an entry file so COVERAGE reaches it.
6
+ * `install.mjs` is excluded from the coverage population by name (vitest.config.mjs's
7
+ * coverage `exclude`; the population is a denylist, so staying out costs a named entry),
8
+ * so code living in it has no coverage reading at all — not a low one, NONE — while
9
+ * `doctor()` alone is 1025 of
10
+ * its ~3595 lines and holds the criteria a user is shown when their install is broken.
11
+ * Those criteria were graded almost entirely by subprocess E2E, and the 2026-09 audit
12
+ * found two P1s among them.
13
+ *
14
+ * (`vitest.config.mjs` records a spot reading of 11.67% statements from a run that
15
+ * temporarily added the file to the population. That is a STAMP, not a current number:
16
+ * it is dated 2026-09-03 and predates all three caliber breaks CLAUDE.md names — the
17
+ * 2026-09-05 reformat, the vitest 5.0.0 coverage recalibration, and the 2026-09-07
18
+ * `include` inversion. An earlier draft of this docblock quoted it as if it were
19
+ * current, which is the carried-cell shape this repo files as a defect.)
20
+ *
21
+ * What is measured on THIS tree, and is the argument that actually carries: the
22
+ * dispatch below reads 88.88% statements with its own cases, where in `install.mjs` it
23
+ * had no reading to improve on. Joining lib/doctor-modes.mjs, lib/doctor-drift.mjs and
24
+ * lib/doctor-benchmark.mjs, which started this split.
25
+ *
26
+ * A LEAF on purpose — `node:fs` and `node:child_process`, nothing from this project.
27
+ * Doctor is the surface a BROKEN install is diagnosed from, and this repo has already
28
+ * paid for the alternative: one import edge, taken for two path constants, put the
29
+ * signature-verified repair out of reach on exactly the installs it existed to repair.
30
+ * Everything else arrives as an argument.
31
+ */
32
+
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import { execFileSync } from 'node:child_process';
35
+
36
+ /**
37
+ * How many LIVE hook commands invoke `bash`, and which scripts they are.
38
+ *
39
+ * There are two hook registrations and only one is live per install shape, which is what
40
+ * the first cut of doctor's interpreter check got wrong (pre-ship review P1-1). The plugin
41
+ * shape reads `hooks/hooks.json` out of the plugin cache. The npm / npx / `git clone` shape
42
+ * has no such file — `hooks/hooks.json` is in RELEASE_SIGNED_FILES but NOT in SOURCE_FILES,
43
+ * so nothing deploys it to ~/.claude-mem-lite/ — and registers its hooks in settings.json
44
+ * instead. Reading only the manifest therefore answered "zero bash hooks" on the one shape
45
+ * where two of them are live.
46
+ *
47
+ * Returns THREE outcomes, never two. `count: null` means no registration could be read, and
48
+ * that is deliberately distinct from a count of zero: zero is an answer, null is the absence
49
+ * of one, and a diagnostic that reports them identically tells the reader to stop looking.
50
+ *
51
+ * @param {{manifestPath: string, settingsCommands?: string[], installDir: string}} opts
52
+ * @returns {{count: number|null, source: 'manifest'|'settings'|null, scripts: string[]}}
53
+ */
54
+ export function resolveBashHookCount({ manifestPath, settingsCommands = [], installDir }) {
55
+ const basenames = (commands) =>
56
+ commands
57
+ .map((c) => {
58
+ const m = c.match(/([^/"\s]+\.sh)/);
59
+ return m ? m[1] : c;
60
+ })
61
+ .sort();
62
+
63
+ if (existsSync(manifestPath)) {
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
66
+ const commands = [];
67
+ for (const matchers of Object.values(parsed?.hooks || {})) {
68
+ for (const m of matchers || []) {
69
+ for (const h of m?.hooks || []) commands.push(String(h?.command || ''));
70
+ }
71
+ }
72
+ const bash = commands.filter((c) => c.startsWith('bash '));
73
+ return { count: bash.length, source: 'manifest', scripts: basenames(bash) };
74
+ } catch {
75
+ // A torn manifest is not evidence of zero bash hooks. Fall through to settings.json,
76
+ // and if that says nothing about us either, the caller gets null.
77
+ }
78
+ }
79
+ // Only OUR entries: settings.json is shared with every other tool the user installs, so a
80
+ // foreign `bash "…"` line is not ours to report on, and — the discriminating half — a
81
+ // settings.json that names nothing of ours is not evidence that no hook needs bash. It is
82
+ // evidence we are reading the wrong registration.
83
+ const ours = settingsCommands.filter((c) => c.includes(installDir));
84
+ if (ours.length === 0) return { count: null, source: null, scripts: [] };
85
+ const bash = ours.filter((c) => c.startsWith('bash '));
86
+ return { count: bash.length, source: 'settings', scripts: basenames(bash) };
87
+ }
88
+
89
+ /**
90
+ * Can bash actually be run? Separated from the dispatch below, and injectable there,
91
+ * because shelling out is the entire reason that dispatch had no unit coverage: one of
92
+ * its four outcomes needs a machine where bash is absent, which no in-process test can
93
+ * arrange without rewriting PATH for the whole worker. The dispatch's outer catch is
94
+ * unreachable through THIS function — it catches everything and returns false — so the
95
+ * real probe never drives it. Other things in that try block can: a non-string entry in
96
+ * `settingsCommands`, or a reporter (`ok` / `dwarn`) that throws. The shipped caller
97
+ * passes only strings, and the tests drive the catch with an injected throwing probe.
98
+ *
99
+ * @returns {boolean}
100
+ */
101
+ function probeBash() {
102
+ try {
103
+ execFileSync('bash', ['-c', 'exit 0'], { stdio: 'ignore', timeout: 5000 });
104
+ return true;
105
+ } catch {
106
+ /* not resolvable, or not runnable — either way the hooks that need it cannot fire */
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Grade the hook interpreter, reporting through doctor's own helpers.
113
+ *
114
+ * Some hook commands are `bash "<script>"` (the PostToolUse and Agent prefilters, plus
115
+ * setup.sh in the plugin manifest) — the rest are `node`. If bash cannot run, those
116
+ * commands fail and nothing says so; the file-presence check grades whether the FILES are
117
+ * there, which they are.
118
+ *
119
+ * Keyed on whether bash RUNS, not on `process.platform === 'win32'`. A Windows user with
120
+ * Git for Windows on PATH — the normal case, since Claude Code shells out to bash for its
121
+ * own Bash tool — has a working configuration and must not be warned; a stripped container
122
+ * with no bash has a broken one and must be, whatever its platform. This is also what issue
123
+ * #28's P3-19 intent asked for: `os: [darwin, linux]` was added so a Windows user "should be
124
+ * told rather than handed a string of silent catch blocks", and blocking the install told
125
+ * them nothing. This is the telling.
126
+ *
127
+ * @param {{ok: (m: string) => void, dwarn: (m: string) => void}} report doctor's helpers
128
+ * @param {object} ctx
129
+ * @param {string} ctx.manifestPath plugin-shape registration (hooks/hooks.json)
130
+ * @param {string} ctx.settingsPath npm-shape registration, named in the unknown message
131
+ * @param {string[]} ctx.settingsCommands
132
+ * @param {string} ctx.installDir
133
+ * @param {() => boolean} [ctx.bashPresent]
134
+ */
135
+ export function checkHookInterpreter(
136
+ { ok, dwarn },
137
+ { manifestPath, settingsPath, settingsCommands = [], installDir, bashPresent = probeBash },
138
+ ) {
139
+ try {
140
+ const {
141
+ count: bashCommands,
142
+ source: countSource,
143
+ scripts: bashScripts,
144
+ } = resolveBashHookCount({ manifestPath, settingsCommands, installDir });
145
+ if (bashCommands === null) {
146
+ // NOT `ok`. Pre-ship review (P1-1) found the first cut printing "no hook command needs
147
+ // bash" here, on a shape where two of them are registered — a green line that ends the
148
+ // reader's search is worse than the silence this check exists to remove.
149
+ dwarn(
150
+ 'Hook interpreter: could not read either hook registration — neither ' +
151
+ `${manifestPath} nor a claude-mem-lite entry in ` +
152
+ `${settingsPath} — so whether any hook needs bash is unknown.`,
153
+ );
154
+ } else if (bashCommands === 0) {
155
+ ok(`Hook interpreter: no hook command needs bash (per the ${countSource})`);
156
+ } else if (bashPresent()) {
157
+ ok(`Hook interpreter: bash present (${bashCommands} hook command(s) need it)`);
158
+ } else {
159
+ // dwarn, not an issue: everything else works. Saying "broken" about an install
160
+ // whose MCP server and node hooks are fine would be the mirror of the defect that
161
+ // sent this round's reporter looking at their disk and their network.
162
+ // The scripts are NAMED from the live registration rather than described from
163
+ // memory — the first cut wrote "(episode Read-tracking and the subagent prefilter)",
164
+ // a two-item gloss on a count of three (P3-1).
165
+ dwarn(
166
+ `Hook interpreter: bash not found on PATH — the ${bashCommands} hook command(s) that ` +
167
+ `invoke it cannot fire (${bashScripts.join(', ')}). The MCP server and the node ` +
168
+ 'hooks are unaffected. On Windows, install Git for Windows or use WSL; elsewhere ' +
169
+ 'this means a stripped PATH.',
170
+ );
171
+ }
172
+ } catch (e) {
173
+ dwarn('Hook interpreter: check failed — ' + e.message);
174
+ }
175
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What counts as stale temp residue, and — the half that kept drifting — what does NOT.
3
+ *
4
+ * Extracted from install.mjs because the scanner (`doctor`) and the deleter (`cleanup`)
5
+ * were two hand-kept copies of the same rules, and they have now diverged twice:
6
+ *
7
+ * • v3.93.0 moved the deleter to MEM_RUNTIME_DIR and left the scanner on
8
+ * join(MEM_DATA_DIR,'runtime'), so under a runtime override doctor reported "none"
9
+ * while the cleanup it recommends removed files. Fixed by moving the scanner.
10
+ * • D#53 (measured 2026-09-22): the deleter age-gates `pending-*` / `ep-flush-*` at
11
+ * ORPHAN_EPISODE_AGE_MS and the scanner did not, so with three in-flight episode
12
+ * files doctor printed "Stale temp files: 3 found (run: node install.mjs cleanup)"
13
+ * and cleanup answered "Kept 3 episode file(s) newer than 1h" then "No stale files
14
+ * found." Two faces of one install contradicting each other, verbatim.
15
+ *
16
+ * Both were the same defect class on different axes — first the directory, then the age
17
+ * gate — which is why this module exports the CLASSIFIER and not just the prefixes.
18
+ * Sharing the prefixes alone would leave the age gate implemented twice, which is the
19
+ * shape that produced D#53 in the first place.
20
+ *
21
+ * A leaf: node:fs, node:path, and one constants module that imports nothing.
22
+ */
23
+
24
+ import { existsSync, readdirSync, statSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { ORPHAN_EPISODE_AGE_MS } from './time-constants.mjs';
27
+
28
+ /** Residue from an interrupted self-update, written under the data dir by hook-update. */
29
+ const UPDATE_RESIDUE_PREFIXES = ['.update-staging-', '.update-backup-'];
30
+
31
+ /** Episode hand-off files under the runtime dir. Age-gated: see classifyEpisodeFile. */
32
+ const EPISODE_RESIDUE_PREFIXES = ['pending-', 'ep-flush-'];
33
+
34
+ export const isUpdateResidue = (name) => UPDATE_RESIDUE_PREFIXES.some((p) => name.startsWith(p));
35
+
36
+ export const isEpisodeResidue = (name) => EPISODE_RESIDUE_PREFIXES.some((p) => name.startsWith(p));
37
+
38
+ /**
39
+ * How the age gate is SPOKEN, derived from the gate itself rather than hand-written.
40
+ *
41
+ * Both faces print this window to the user. Before it existed, cleanup printed a literal
42
+ * "1h" while the rule lived in ORPHAN_EPISODE_AGE_MS, and this change's own first draft
43
+ * added a second literal to doctor's new in-flight line — a hand-kept twin of the gate, in
44
+ * the change whose headline was that the rule has one home. Pre-ship review counted it.
45
+ *
46
+ * Whole hours only: a 20-minute gate would print "0h". The gate is HOUR_MS today, and
47
+ * tests/doctor-stale-temp-agreement.test.mjs parses this label back and requires it to
48
+ * equal the gate, so a gate that is not a whole number of hours fails there rather than
49
+ * printing a wrong window to the user.
50
+ */
51
+ export const EPISODE_AGE_LABEL = `${Math.round(ORPHAN_EPISODE_AGE_MS / 3600000)}h`;
52
+
53
+ /**
54
+ * Is this episode file residue, or is it work in progress?
55
+ *
56
+ * The single definition of the age gate that doctor and cleanup share. (The automatic
57
+ * sweep in hook-shared.mjs and the summarizer's wait in hook-llm.mjs apply the same
58
+ * constant in their own code.) `ep-flush-<ts>-<id>.json` is the episode handed to the
59
+ * summarizer, not leftovers: the round-trip is up to ~60s, and deleting one mid-flight
60
+ * discards that episode's observations silently. An unreadable mtime counts as in-flight,
61
+ * because failing safe costs one stale file until the next sweep and failing open costs
62
+ * an episode.
63
+ *
64
+ * @returns {'stale'|'in-flight'}
65
+ */
66
+ export function classifyEpisodeFile(
67
+ runtimeDir,
68
+ name,
69
+ { now = Date.now(), episodeAgeMs = ORPHAN_EPISODE_AGE_MS } = {},
70
+ ) {
71
+ let mtimeMs;
72
+ try {
73
+ mtimeMs = statSync(join(runtimeDir, name)).mtimeMs;
74
+ } catch {
75
+ return 'in-flight';
76
+ }
77
+ // `>=`, so a file aged exactly the gate is KEPT — the side hook-shared.mjs's sweep
78
+ // (deletes only `< cutoff`) and hook-llm.mjs (`>= cutoff` is live) already chose. `>`
79
+ // put the tie on cleanup's delete side, making the manual command the aggressive one.
80
+ return mtimeMs >= now - episodeAgeMs ? 'in-flight' : 'stale';
81
+ }
82
+
83
+ /**
84
+ * Count what `cleanup` would actually remove, split from what it would deliberately keep.
85
+ *
86
+ * `stale` is the number doctor may recommend cleanup for; `inFlight` is reported as a
87
+ * detail rather than a warning, because a file that is supposed to exist right now is not
88
+ * a fault. Note the asymmetry, which is deliberate and is NOT the age gate being applied
89
+ * inconsistently: update residue is guarded by install.lock in cleanup, not by age, so
90
+ * there is no age gate here for it to mirror.
91
+ *
92
+ * @returns {{stale: number, inFlight: number}}
93
+ */
94
+ export function scanStaleTempFiles({ dataDir, runtimeDir, now = Date.now(), episodeAgeMs }) {
95
+ let stale = 0;
96
+ let inFlight = 0;
97
+
98
+ if (existsSync(dataDir)) {
99
+ for (const f of readdirSync(dataDir)) if (isUpdateResidue(f)) stale++;
100
+ }
101
+
102
+ if (existsSync(runtimeDir)) {
103
+ for (const f of readdirSync(runtimeDir)) {
104
+ if (!isEpisodeResidue(f)) continue;
105
+ if (classifyEpisodeFile(runtimeDir, f, { now, episodeAgeMs }) === 'in-flight') inFlight++;
106
+ else stale++;
107
+ }
108
+ }
109
+
110
+ return { stale, inFlight };
111
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.10.3",
3
+ "version": "6.11.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.10.3",
9
+ "version": "6.11.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.10.3",
3
+ "version": "6.11.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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",
@@ -74,6 +74,8 @@
74
74
  "lib/startup-dashboard.mjs",
75
75
  "lib/doctor-benchmark.mjs",
76
76
  "lib/doctor-drift.mjs",
77
+ "lib/doctor-hook-interpreter.mjs",
78
+ "lib/doctor-stale-temp.mjs",
77
79
  "lib/cli-project.mjs",
78
80
  "lib/stats-quality.mjs",
79
81
  "lib/low-signal-patterns.mjs",
package/secret-scrub.mjs CHANGED
@@ -78,7 +78,7 @@ export const SECRET_PATTERNS = [
78
78
  /((?:\b|_)(?:password|passwd|passphrase)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)(?![A-Za-z]{1,15}(?=[\s,;'"}\]]|$))[^\s,;'"}\]]{6,}/gi,
79
79
  '$1***',
80
80
  ],
81
- // 1c. `:` separator, prose-ambiguous nouns → keep the lookbehind ("the token: alice"):
81
+ // 1c. `:` separator, prose-ambiguous nouns → keep the lookbehind ("the token: alicebob"):
82
82
  [
83
83
  /((?<![A-Za-z][ \t])(?:\b|_)(?:token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi,
84
84
  '$1***',
@@ -277,11 +277,71 @@ export const SECRET_PATTERNS = [
277
277
  * @param {string} text Input text potentially containing secrets
278
278
  * @returns {string} Text with secrets replaced by '***'
279
279
  */
280
+ // ── D#52 / D#46: one sweep is not a fixed point ─────────────────────────────
281
+ // Three patterns carry the prose lookbehind `(?<![A-Za-z][ \t])` — "preceded by
282
+ // letter + horizontal space means English prose, leave it alone". That guard is
283
+ // load-bearing (#8283 / round-4 / R5): `the token: alicebob` stays readable only
284
+ // because of it, while `token: alicebob` is scrubbed. (Not `alice` — five characters
285
+ // is under the value class's minimum of six, so that phrase is never scrubbed at all
286
+ // and cannot show the guard doing anything.)
287
+ // But a /g match CONSUMES its value, so the NEXT labelled keyword on the same
288
+ // line is preceded by that value's last character plus a space. The lookbehind
289
+ // cannot tell that from a word, so it skipped it: `token: <v> secret: <v>` left
290
+ // the SECOND secret in plaintext. Replacing the first one rewrites that left
291
+ // context to `*** `, and `*` is not [A-Za-z], which is why a second sweep caught
292
+ // what the first missed — the same fact D#46 reported as non-idempotence.
293
+ // The leak and the drift are one defect, and a fixed point closes both; the
294
+ // idempotence is what cmdRestore's re-scrub of five EXPORT_COLUMNS needed.
295
+ //
296
+ // Cost is unchanged on real content: the loop's FIRST iteration is the sweep
297
+ // that used to be the whole function, and text the scrubber does not modify
298
+ // exits on the `===` right after it. Measured 2026-09-22 on the live corpus:
299
+ // of the 287 NON-EMPTY values across text/subtitle/concepts/facts/search_aliases,
300
+ // 0 are modified at all, so the common path pays one string comparison. (A first
301
+ // draft of this line said "0 of 452"; 452 was the NOT-NULL count, ~39% of which
302
+ // are empty strings that nothing could modify — the zero was true and the
303
+ // denominator was not the population.)
304
+ //
305
+ // TERMINATION IS THE CAP, and saying anything stronger would be a guess. A first
306
+ // draft of this comment argued it structurally — "`***` is 3 characters and every
307
+ // value class requires at least 6, so a replacement can never become a new match".
308
+ // Pre-ship review measured that and it is FALSE of six patterns whose value class
309
+ // is `+` or `*`; two of them (the PEM block and the `postgres://` DSN) demonstrably
310
+ // re-match their own `***` output. Convergence is fast in practice — 7 sweeps was
311
+ // the maximum over 40 000 fuzzed inputs — but the only thing that BOUNDS this loop
312
+ // is MAX_SCRUB_PASSES, so that is what the comment is allowed to claim.
313
+ //
314
+ // Convergence rate, stated with the shape it is a property of: N space-adjacent
315
+ // secrets take N+1 sweeps only when each value ends in an ASCII LETTER, because
316
+ // that is what re-arms the prose lookbehind for the next keyword. A value ending
317
+ // in a digit does not re-arm it, so those converge in 2 regardless of N.
318
+ //
319
+ // Hitting the cap leaves labelled secrets unscrubbed past the 32nd, and that is
320
+ // the deliberate choice. The earlier draft instead re-ran the three prose-guarded
321
+ // patterns with the guard STRIPPED — which clears the remainder, and also applies
322
+ // to the whole string rather than the un-converged region, so prose elsewhere in
323
+ // the same input is redacted irreversibly on the write path. Pre-ship review
324
+ // reproduced it: a 33-deep chain turned `Reset the password: instructions are in
325
+ // the onboarding doc` into `Reset the password: *** are in…`, which is verbatim
326
+ // the v3.61.0 regression lines 44-50 of this file record as already undone once.
327
+ // Past the cap the function is also no longer idempotent — the property cmdRestore's
328
+ // re-scrub relies on holds only below it — and a second call scrubs further, which
329
+ // is the safe direction.
330
+ // Reaching the cap needs a deliberately constructed ~447-byte adjacent chain;
331
+ // corrupting prose needs only to be in the same string as one. Between a partial
332
+ // scrub of a crafted credential dump and irreversible damage to a user's text,
333
+ // this repo has twice decided the text matters more.
334
+ const MAX_SCRUB_PASSES = 32;
335
+
280
336
  export function scrubSecrets(text) {
281
337
  if (!text || typeof text !== 'string') return text || '';
282
338
  let result = stripPrivate(text);
283
- for (const [pattern, replacement] of SECRET_PATTERNS) {
284
- result = result.replace(pattern, replacement);
339
+ for (let pass = 1; ; pass++) {
340
+ const before = result;
341
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
342
+ result = result.replace(pattern, replacement);
343
+ }
344
+ if (result === before || pass >= MAX_SCRUB_PASSES) break;
285
345
  }
286
346
  return result;
287
347
  }
package/source-files.mjs CHANGED
@@ -72,6 +72,8 @@ export const SOURCE_FILES = [
72
72
  'lib/startup-dashboard.mjs',
73
73
  'lib/doctor-benchmark.mjs',
74
74
  'lib/doctor-drift.mjs',
75
+ 'lib/doctor-hook-interpreter.mjs',
76
+ 'lib/doctor-stale-temp.mjs',
75
77
  // DB-aware project pick for terminal-invoked CLI commands. Statically imported by
76
78
  // mem-cli.mjs, cli/activity.mjs and cli/doctor.mjs — ship it or every CLI command
77
79
  // throws ERR_MODULE_NOT_FOUND in installed/tarball runtimes.