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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -0
- package/README.zh-CN.md +14 -0
- package/hook-optimize.mjs +46 -5
- package/install.mjs +54 -150
- package/lib/doctor-hook-interpreter.mjs +175 -0
- package/lib/doctor-stale-temp.mjs +111 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/secret-scrub.mjs +63 -3
- package/source-files.mjs +2 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
-
|
|
1744
|
-
findReenrichCandidates(db,
|
|
1782
|
+
fillCap,
|
|
1783
|
+
findReenrichCandidates(db, fillCap, { scope: 'aliases', project }).length,
|
|
1745
1784
|
);
|
|
1746
1785
|
const conceptsBudget = Math.min(
|
|
1747
|
-
|
|
1748
|
-
findReenrichCandidates(db, Math.max(0,
|
|
1749
|
-
|
|
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
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
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 (
|
|
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
|
|
3022
|
+
const now = Date.now();
|
|
3112
3023
|
let inFlight = 0;
|
|
3113
3024
|
for (const f of readdirSync(runtimeDir)) {
|
|
3114
|
-
if (
|
|
3115
|
-
//
|
|
3116
|
-
//
|
|
3117
|
-
|
|
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
|
|
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
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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.
|
|
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:
|
|
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 (
|
|
284
|
-
|
|
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.
|