claude-mem-lite 6.30.1 → 6.30.3
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/commands/verify.md +3 -3
- package/install.mjs +7 -4
- package/lib/db-unusable.mjs +6 -1
- package/lib/hook-prune.mjs +35 -4
- package/lib/maintain-core.mjs +3 -0
- package/mem-cli.mjs +22 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schema.mjs +38 -10
- package/scripts/setup.sh +9 -5
- package/secret-scrub.mjs +105 -21
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.30.
|
|
12
|
+
"version": "6.30.3",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.30.
|
|
3
|
+
"version": "6.30.3",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -362,7 +362,7 @@ rollback recipe under "Trust model per install path".
|
|
|
362
362
|
schema-version change: 6.20.0 still opens the database after this release has. Pin
|
|
363
363
|
`claude-mem-lite@6.20.0` before upgrading to avoid the move; after it, 6.20.0 names these
|
|
364
364
|
directories by their old ids again, so their moved rows are listed only with
|
|
365
|
-
`--project
|
|
365
|
+
`--project=<new id>`.
|
|
366
366
|
|
|
367
367
|
- **Which projects.** Every character outside ASCII letters, digits and `_.-` used to become `-`,
|
|
368
368
|
so `~/projects/博客` and `~/projects/商城` were both `projects----` and shared one memory.
|
|
@@ -374,7 +374,7 @@ directories by their old ids again, so their moved rows are listed only with
|
|
|
374
374
|
directory using the old id, the project takes everything under it, deferred items and session
|
|
375
375
|
history included. Otherwise only the memories whose file paths lie inside the directory move;
|
|
376
376
|
the rest stay under the old id. A one-time notice says what moved and how to list what stayed
|
|
377
|
-
(`claude-mem-lite recent 50 --project
|
|
377
|
+
(`claude-mem-lite recent 50 --project=<old id>`).
|
|
378
378
|
- **Not in this release:** directories with the same parent and name in different repositories
|
|
379
379
|
(`~/a/packages/api` and `~/b/packages/api`) still share one id.
|
|
380
380
|
- **Also changed:** two sessions open in one project keep separate memory sessions (handoffs,
|
package/README.zh-CN.md
CHANGED
|
@@ -277,7 +277,7 @@ README "Trust model per install path" 一节的回退步骤操作。
|
|
|
277
277
|
|
|
278
278
|
**名字不是纯 ASCII 的项目会换一个新标识,它们存下的内容会搬一次。** 没有 schema 版本变更:
|
|
279
279
|
本版本打开过的数据库,6.20.0 仍能打开。想避免搬迁,请在**升级之前**固定 `claude-mem-lite@6.20.0`;
|
|
280
|
-
升级之后再回退,6.20.0 会按旧标识称呼这些目录,搬走的行只能用 `--project
|
|
280
|
+
升级之后再回退,6.20.0 会按旧标识称呼这些目录,搬走的行只能用 `--project=<新标识>` 列出。
|
|
281
281
|
|
|
282
282
|
- **哪些项目。** 以前 ASCII 字母、数字和 `_.-` 以外的字符都变成 `-`,所以 `~/projects/博客` 和
|
|
283
283
|
`~/projects/商城` 都是 `projects----`,共用一份记忆。现在任何文字的字母、组合符和数字都会保留
|
|
@@ -285,7 +285,7 @@ README "Trust model per install path" 一节的回退步骤操作。
|
|
|
285
285
|
组合符或数字,或有基本多文种平面以外的字符(如 `🚀`)时,标识会变。
|
|
286
286
|
- **项目第一次启动会话时搬迁数据。** 如果旧标识下没有任何文件路径显示另一个同样使用该旧标识的目录,
|
|
287
287
|
就把旧标识下的全部内容搬过来,包括待办和会话历史。否则只搬文件路径落在本目录里的记忆,其余留在
|
|
288
|
-
旧标识下。会提示一次搬了什么,以及怎么列出留下的内容(`claude-mem-lite recent 50 --project
|
|
288
|
+
旧标识下。会提示一次搬了什么,以及怎么列出留下的内容(`claude-mem-lite recent 50 --project=<旧标识>`)。
|
|
289
289
|
- **本版本不包含:** 不同仓库里父目录和目录名都相同的目录(`~/a/packages/api` 与 `~/b/packages/api`)
|
|
290
290
|
仍共用一个标识。
|
|
291
291
|
- **其他变化:** 同一项目同时开两个会话,各自的交接、摘要和未保存的工具记录不再混在一起;会话里的
|
package/commands/verify.md
CHANGED
|
@@ -33,7 +33,7 @@ otherwise ask. Always pass that exact name — `export --project` matches loosel
|
|
|
33
33
|
neighbouring project from a short name.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" export --project
|
|
36
|
+
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" export --project=<project> [--from <date>] > <scratch>/memories.json
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Every row carries `id`, `project`, `type`, `title`, `narrative`, `facts`, `lesson_learned`,
|
|
@@ -113,7 +113,7 @@ Write `<scratch>/proposals.json` — a JSON array, one entry per memory to chang
|
|
|
113
113
|
## Step 4 — Show the plan and get approval
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project
|
|
116
|
+
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project=<project>
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
This is a dry run: it validates every entry against the database, writes nothing, prints each
|
|
@@ -130,7 +130,7 @@ the old one will be refused.
|
|
|
130
130
|
Run exactly the command the dry run printed:
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
|
-
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project
|
|
133
|
+
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project=<project> --apply --digest <digest>
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
It refuses if the proposals or the memories changed since the dry run. Otherwise it backs up
|
package/install.mjs
CHANGED
|
@@ -478,16 +478,19 @@ export async function dbCheckRemedy(dbPath, err) {
|
|
|
478
478
|
const remedy = dbUnusableRemedy(dbPath, err);
|
|
479
479
|
if (remedy.kind === 'unknown') return remedy.note;
|
|
480
480
|
if (remedy.kind === 'schema') return `SQLite cannot read the database's schema. ${remedy.note}`;
|
|
481
|
+
// Both commands start by deleting the -wal and -shm and then replace or move the database file,
|
|
482
|
+
// under any session still holding the store (D#534). The SessionStart notice says the same before
|
|
483
|
+
// its copy of the command.
|
|
481
484
|
if (remedy.kind === 'set-aside') {
|
|
482
485
|
return (
|
|
483
|
-
`No backup snapshot exists beside the database.
|
|
484
|
-
`store is created on the next session: ${remedy.command} ` +
|
|
486
|
+
`No backup snapshot exists beside the database. Once every Claude Code session is closed, ` +
|
|
487
|
+
`set the broken file aside so a fresh store is created on the next session: ${remedy.command} ` +
|
|
485
488
|
`— memories in that file are not recoverable without a backup.`
|
|
486
489
|
);
|
|
487
490
|
}
|
|
488
491
|
return (
|
|
489
|
-
`Restore the newest of ${remedy.snapshotCount} backup snapshot(s)
|
|
490
|
-
|
|
492
|
+
`Restore the newest of ${remedy.snapshotCount} backup snapshot(s) once every Claude Code session ` +
|
|
493
|
+
`is closed: ${remedy.command} — move the broken file aside first if you want to keep it for inspection.`
|
|
491
494
|
);
|
|
492
495
|
}
|
|
493
496
|
|
package/lib/db-unusable.mjs
CHANGED
|
@@ -229,7 +229,12 @@ export function formatDbUnusableNotice({ dbPath, remedy }) {
|
|
|
229
229
|
? ` ${dbPath} exists but SQLite cannot read its schema.`
|
|
230
230
|
: ` ${dbPath} exists but SQLite does not recognise it as a database.`,
|
|
231
231
|
];
|
|
232
|
-
if (remedy.command)
|
|
232
|
+
if (remedy.command) {
|
|
233
|
+
// Before the command: it deletes the -wal and -shm, then replaces or moves the database file,
|
|
234
|
+
// under any session still holding the store (D#534). doctor says the same.
|
|
235
|
+
lines.push(' Once every Claude Code session is closed, this one included, run:');
|
|
236
|
+
lines.push(` ${remedy.command}`);
|
|
237
|
+
}
|
|
233
238
|
if (remedy.note) lines.push(` ${remedy.note}`);
|
|
234
239
|
lines.push(` Saves and recall are disabled. \`${CLI_INVOKE} doctor\` re-checks.`);
|
|
235
240
|
return lines.join('\n');
|
package/lib/hook-prune.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// path, which must stay cheap and must not drag install.mjs's dependency graph in.
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
-
import {
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, dirname, isAbsolute, win32 } from 'node:path';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Identify a settings hook as one of OURS (to replace on install / strip on uninstall).
|
|
@@ -36,8 +37,13 @@ export function isMemHook(cfg, roots = null) {
|
|
|
36
37
|
// under `.claude-mem-lite`, the pre-v0.5 `claude-mem-lite`, or a checkout named after it
|
|
37
38
|
// (`claude-mem-lite-main` from a zip), with either separator. A bare substring also took
|
|
38
39
|
// `claude-mem-lite export > ~/backups/…` and `~/tools/claude-mem-lite-prompt-audit.py`, which
|
|
39
|
-
// install and uninstall then deleted.
|
|
40
|
-
|
|
40
|
+
// install and uninstall then deleted. The directory name alone still took a user's own
|
|
41
|
+
// `node ~/claude-mem-lite-notes/scripts/fmt.js` (D#479), so the path counts as the launcher's
|
|
42
|
+
// does (isOurEntryPath): its root carries the product, or it names one of our entries and is gone.
|
|
43
|
+
const ENTRY_PATH = String.raw`claude-mem-lite[\w.-]*[\\/](?:hook\.mjs|scripts[\\/][\w.-]+\.(?:mjs|js|sh))`;
|
|
44
|
+
const OUR_ENTRY_PATH_RE = new RegExp(
|
|
45
|
+
`(?:"([^"]*${ENTRY_PATH})"|([^\\s"]*${ENTRY_PATH})(?=\\s|$))(?:\\s+([\\w-]+))?`,
|
|
46
|
+
);
|
|
41
47
|
|
|
42
48
|
// The launcher as every install writes it: `<dir>/scripts/hook-launcher.mjs` followed by one of
|
|
43
49
|
// our entry shapes (`hook.mjs <event>` or `scripts/<x>.js|mjs`). Matching the bare file name took
|
|
@@ -68,6 +74,16 @@ const OUR_LAUNCHER_SCRIPTS = new Set([
|
|
|
68
74
|
'pre-agent-inject.js',
|
|
69
75
|
'pre-skill-bridge.js',
|
|
70
76
|
]);
|
|
77
|
+
// Every scripts/ entry a settings.json hook or hooks/hooks.json has run directly or through the
|
|
78
|
+
// launcher, for a path under a claude-mem-lite* directory that is gone (isOurEntryPath).
|
|
79
|
+
const OUR_ENTRY_SCRIPTS = new Set([
|
|
80
|
+
...OUR_LAUNCHER_SCRIPTS,
|
|
81
|
+
'hook-launcher.mjs',
|
|
82
|
+
'setup.sh',
|
|
83
|
+
'post-tool-use.sh',
|
|
84
|
+
'pre-agent-inject.sh',
|
|
85
|
+
'pre-tool-recall-bash.sh',
|
|
86
|
+
]);
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
89
|
* Whether an install root is the product's own: the plugin's `${CLAUDE_PLUGIN_ROOT}`, or a
|
|
@@ -85,6 +101,21 @@ function isProductRoot(root) {
|
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
// `<root>/hook.mjs <event>` or `<root>/scripts/<x>` under a claude-mem-lite* directory. Either
|
|
105
|
+
// separator, since the install it names may be a Windows one, and `~/` as the user's home.
|
|
106
|
+
function isOurEntryPath(cmd) {
|
|
107
|
+
const m = OUR_ENTRY_PATH_RE.exec(cmd);
|
|
108
|
+
if (!m) return false;
|
|
109
|
+
const raw = m[1] ?? m[2];
|
|
110
|
+
const path = /^~[\\/]/.test(raw) ? join(homedir(), raw.slice(2)) : raw;
|
|
111
|
+
if (!isAbsolute(path) && !win32.isAbsolute(path)) return false;
|
|
112
|
+
if (isProductRoot(path.replace(/[\\/](?:hook\.mjs|scripts[\\/][^\\/]+)$/, ''))) return true;
|
|
113
|
+
const ours = /[\\/]hook\.mjs$/.test(path)
|
|
114
|
+
? OUR_HOOK_EVENTS.has(m[3])
|
|
115
|
+
: OUR_ENTRY_SCRIPTS.has(path.slice(path.search(/[^\\/]+$/)));
|
|
116
|
+
return ours && !existsSync(path);
|
|
117
|
+
}
|
|
118
|
+
|
|
88
119
|
function isOurLauncher(cmd) {
|
|
89
120
|
const m = OUR_LAUNCHER_RE.exec(cmd);
|
|
90
121
|
if (!m) return false;
|
|
@@ -198,7 +229,7 @@ export function unconfirmedDeadPrefilters(settings, env = process.env) {
|
|
|
198
229
|
*/
|
|
199
230
|
export function isMemHookCommand(command, roots = null) {
|
|
200
231
|
const cmd = typeof command === 'string' ? command : '';
|
|
201
|
-
return
|
|
232
|
+
return isOurEntryPath(cmd) || isOurLauncher(cmd) || isOurPrefilter(cmd, roots);
|
|
202
233
|
}
|
|
203
234
|
|
|
204
235
|
/**
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -855,6 +855,9 @@ export function findDuplicates(
|
|
|
855
855
|
if (!titles[i] || !minhashes[i]) continue;
|
|
856
856
|
for (let j = i + 1; j < recent.length; j++) {
|
|
857
857
|
if (!titles[j] || !minhashes[j]) continue;
|
|
858
|
+
// mergeDuplicates skips a pair from two projects, so a scan over every project printed
|
|
859
|
+
// pairs its own command would not merge, and they took slots from real ones (D#464).
|
|
860
|
+
if (recent[i].project !== recent[j].project) continue;
|
|
858
861
|
if (estimateJaccardFromMinHash(minhashes[i], minhashes[j]) < MINHASH_PRE_THRESHOLD) continue;
|
|
859
862
|
const sim = jaccardSimilarity(titles[i], titles[j]);
|
|
860
863
|
if (sim > SIMILARITY_THRESHOLD) {
|
package/mem-cli.mjs
CHANGED
|
@@ -3679,12 +3679,16 @@ async function cmdOptimize(db, args) {
|
|
|
3679
3679
|
// the default budget (tasks/project undefined → run-everything). Normalize
|
|
3680
3680
|
// `--flag=value` into `--flag value` up front so both forms parse identically;
|
|
3681
3681
|
// the `--execute=` special-case below already anticipated this for one flag.
|
|
3682
|
+
// On/off flags are read as parseArgs and the dispatcher's dry-run check read them (isTrue): after
|
|
3683
|
+
// the split below, `args.includes('--run')` took `--run=false` and `--run false` as a run, which
|
|
3684
|
+
// that check had let through as no write (D#514).
|
|
3685
|
+
const { flags } = parseArgs(args, { track: false });
|
|
3682
3686
|
args = args.flatMap((a) => {
|
|
3683
3687
|
const m = /^(--[a-z][a-z-]*)=([\s\S]*)$/.exec(a);
|
|
3684
3688
|
return m ? [m[1], m[2]] : [a];
|
|
3685
3689
|
});
|
|
3686
|
-
const run =
|
|
3687
|
-
const runAll =
|
|
3690
|
+
const run = isTrue(flags.run);
|
|
3691
|
+
const runAll = isTrue(flags['run-all']);
|
|
3688
3692
|
// Sibling-command flag footgun: optimize executes with --run (compress uses
|
|
3689
3693
|
// --execute, maintain uses positional `execute`). A bare --execute previously fell
|
|
3690
3694
|
// through to a silent preview, so the user thought they ran a mutation but didn't.
|
|
@@ -3697,7 +3701,7 @@ async function cmdOptimize(db, args) {
|
|
|
3697
3701
|
);
|
|
3698
3702
|
return;
|
|
3699
3703
|
}
|
|
3700
|
-
const verbose =
|
|
3704
|
+
const verbose = isTrue(flags.verbose) || args.includes('-v');
|
|
3701
3705
|
// T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
|
|
3702
3706
|
const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
|
|
3703
3707
|
const taskIdx = args.indexOf('--task');
|
|
@@ -3932,15 +3936,16 @@ const MUTATING_FILTER_READS = {
|
|
|
3932
3936
|
// A command not in MUTATING_FILTER_READS names here when its run writes (round-2 pre-tag review
|
|
3933
3937
|
// P3-2: defer drop and fts-check rebuild ignored --dry-run silently; verify-apply --apply is left
|
|
3934
3938
|
// to its digest, since --apply is verify-apply's own flag and the dispatcher may not read it).
|
|
3935
|
-
// A preview is the same command without the flag that makes it write
|
|
3936
|
-
//
|
|
3939
|
+
// A preview is the same command without the flag that makes it write, or with its previewing
|
|
3940
|
+
// subcommand in place of the writing one (`swap`), so the user's other flags stay (`maintain scan`
|
|
3941
|
+
// alone previewed every project, D#514); else another command (`preview`).
|
|
3937
3942
|
const DRY_RUN_UNREAD = {
|
|
3938
3943
|
compress: { without: '--execute' },
|
|
3939
|
-
maintain: {
|
|
3944
|
+
maintain: { swap: 'scan in place of execute' },
|
|
3940
3945
|
optimize: { without: '--run or --run-all' },
|
|
3941
|
-
delete: {
|
|
3946
|
+
delete: { without: '--confirm' },
|
|
3942
3947
|
'activity promote': { without: '--execute' },
|
|
3943
|
-
'activity delete': {
|
|
3948
|
+
'activity delete': { without: '--confirm' },
|
|
3944
3949
|
'import-jsonl': { preview: null, writes: () => true },
|
|
3945
3950
|
'defer drop': { preview: null, writes: () => true },
|
|
3946
3951
|
'fts-check': { preview: 'fts-check check', writes: ({ positional }) => positional[0] === 'rebuild' },
|
|
@@ -4064,11 +4069,18 @@ async function runDispatch(argv) {
|
|
|
4064
4069
|
|
|
4065
4070
|
if (Object.hasOwn(DRY_RUN_UNREAD, mutatingKey)) {
|
|
4066
4071
|
const parsed = parseArgs(cmdArgs, { track: false });
|
|
4067
|
-
const { preview, without, writes } = DRY_RUN_UNREAD[mutatingKey];
|
|
4072
|
+
const { preview, without, swap, writes } = DRY_RUN_UNREAD[mutatingKey];
|
|
4068
4073
|
if ((mutating ? mutating.writes(parsed) : writes(parsed)) && asksDryRun(parsed.flags['dry-run'])) {
|
|
4074
|
+
const how = without
|
|
4075
|
+
? `Run it again without ${without} to preview.`
|
|
4076
|
+
: swap
|
|
4077
|
+
? `Run it again with ${swap} to preview.`
|
|
4078
|
+
: preview
|
|
4079
|
+
? `Preview with: \`${CLI_INVOKE} ${preview}\`.`
|
|
4080
|
+
: 'It has no preview.';
|
|
4069
4081
|
fail(
|
|
4070
4082
|
`[mem] ${mutatingKey} does not read --dry-run — nothing was changed, since the run would have ` +
|
|
4071
|
-
`written. ${
|
|
4083
|
+
`written. ${how}`,
|
|
4072
4084
|
);
|
|
4073
4085
|
return;
|
|
4074
4086
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.30.
|
|
3
|
+
"version": "6.30.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.30.
|
|
9
|
+
"version": "6.30.3",
|
|
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.30.
|
|
3
|
+
"version": "6.30.3",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/schema.mjs
CHANGED
|
@@ -1704,15 +1704,18 @@ export function ensureDb() {
|
|
|
1704
1704
|
try {
|
|
1705
1705
|
chmodSync(DB_PATH, 0o600);
|
|
1706
1706
|
} catch {}
|
|
1707
|
-
db.pragma('journal_mode = WAL');
|
|
1708
|
-
// 5000ms matches the MCP server (server.mjs) — 3000ms wasn't enough under realistic
|
|
1709
|
-
// concurrency (parallel CLI saves + a long-running FTS rebuild can push individual
|
|
1710
|
-
// transactions past 3s, triggering SQLITE_BUSY on the third caller).
|
|
1711
|
-
db.pragma(`busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
1712
|
-
db.pragma('synchronous = NORMAL');
|
|
1713
|
-
db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
|
|
1714
1707
|
|
|
1708
|
+
// The pragmas sit inside the try that closes the handle: `journal_mode = WAL` throws on a
|
|
1709
|
+
// damaged WAL, and a handle leaked there was closed by better-sqlite3 at exit, which wrote the
|
|
1710
|
+
// damaged frame into the main file (D#438, D#506).
|
|
1715
1711
|
try {
|
|
1712
|
+
db.pragma('journal_mode = WAL');
|
|
1713
|
+
// 5000ms matches the MCP server (server.mjs) — 3000ms wasn't enough under realistic
|
|
1714
|
+
// concurrency (parallel CLI saves + a long-running FTS rebuild can push individual
|
|
1715
|
+
// transactions past 3s, triggering SQLITE_BUSY on the third caller).
|
|
1716
|
+
db.pragma(`busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
1717
|
+
db.pragma('synchronous = NORMAL');
|
|
1718
|
+
db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
|
|
1716
1719
|
const ready = initSchema(db);
|
|
1717
1720
|
// P1-5: sentinel-gated data cleanups must run on EVERY open (schema.mjs:766).
|
|
1718
1721
|
// They were extracted out of initSchema into runDeferredCleanups but never
|
|
@@ -1726,13 +1729,38 @@ export function ensureDb() {
|
|
|
1726
1729
|
}
|
|
1727
1730
|
return ready;
|
|
1728
1731
|
} catch (e) {
|
|
1729
|
-
|
|
1730
|
-
db.close();
|
|
1731
|
-
} catch {}
|
|
1732
|
+
closeWithoutCheckpoint(db);
|
|
1732
1733
|
throw e;
|
|
1733
1734
|
}
|
|
1734
1735
|
}
|
|
1735
1736
|
|
|
1737
|
+
/**
|
|
1738
|
+
* Close a connection whose open failed, without letting the close checkpoint. The last connection
|
|
1739
|
+
* to close a WAL database copies the WAL into the main file first and checks no page, so a failed
|
|
1740
|
+
* open over a damaged frame wrote that frame into the main file (D#506). A read-only connection
|
|
1741
|
+
* that has run a statement holds the shared lock, so the close below is not the last, and a
|
|
1742
|
+
* read-only close never checkpoints; one only opened holds nothing. Counts on the corrupt-WAL
|
|
1743
|
+
* fixture: docs/audits/20261021-d506-wal-recovery-experiments.md, Q1. The statement may fail on
|
|
1744
|
+
* the damaged WAL; it takes the lock all the same. With no guard, the close is the plain
|
|
1745
|
+
* one it always was.
|
|
1746
|
+
*/
|
|
1747
|
+
function closeWithoutCheckpoint(db) {
|
|
1748
|
+
let guard = null;
|
|
1749
|
+
try {
|
|
1750
|
+
guard = new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
|
1751
|
+
if (busyDeadline !== null) capBusyWaits(guard);
|
|
1752
|
+
guard.pragma('schema_version');
|
|
1753
|
+
} catch {
|
|
1754
|
+
/* no guard, or a read that failed on the damaged WAL and took the lock all the same */
|
|
1755
|
+
}
|
|
1756
|
+
try {
|
|
1757
|
+
db.close();
|
|
1758
|
+
} catch {}
|
|
1759
|
+
try {
|
|
1760
|
+
guard?.close();
|
|
1761
|
+
} catch {}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1736
1764
|
/**
|
|
1737
1765
|
* Give the query planner statistics (sqlite_stat1/4) the way SQLite's docs recommend:
|
|
1738
1766
|
* `PRAGMA optimize=0x10002` analyzes only tables whose indexes have no statistics yet or
|
package/scripts/setup.sh
CHANGED
|
@@ -430,16 +430,21 @@ fi
|
|
|
430
430
|
# settings.json.
|
|
431
431
|
# CODE_DIR/runtime, same reason: the residue it warns about is stale hook entries in the config
|
|
432
432
|
# home's settings.json — one warning per config home, regardless of where the data lives.
|
|
433
|
+
# The test for a stale hook is cleanup-hooks' classifier, lib/hook-prune.mjs beside this script.
|
|
434
|
+
# A substring of the product name listed a user's own hook kept under ~/.claude-mem-lite/ (D#513).
|
|
433
435
|
RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55$(marker_suffix "$CC_CONFIG_DIR/settings.json" "$CC_DEFAULT_CONFIG_DIR/settings.json")"
|
|
434
436
|
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
435
437
|
SETTINGS="$CC_CONFIG_DIR/settings.json"
|
|
436
438
|
if [[ -f "$SETTINGS" ]]; then
|
|
437
|
-
|
|
439
|
+
HOOK_PRUNE="$(dirname "${BASH_SOURCE[0]}")/../lib/hook-prune.mjs" \
|
|
440
|
+
SETTINGS_PATH="$SETTINGS" CFG_DIR="$CC_CONFIG_DIR" node -e '
|
|
438
441
|
const fs = require("fs");
|
|
439
|
-
|
|
442
|
+
const { pathToFileURL } = require("url");
|
|
443
|
+
import(pathToFileURL(process.env.HOOK_PRUNE).href).then(({ isMemHookCommand, ourInstallRoots }) => {
|
|
440
444
|
const raw = fs.readFileSync(process.env.SETTINGS_PATH, "utf8");
|
|
441
445
|
const data = JSON.parse(raw);
|
|
442
446
|
const hooks = data.hooks || {};
|
|
447
|
+
const roots = ourInstallRoots(data);
|
|
443
448
|
const events = Object.keys(hooks);
|
|
444
449
|
const found = [];
|
|
445
450
|
for (const ev of events) {
|
|
@@ -447,8 +452,7 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
|
447
452
|
for (const entry of list) {
|
|
448
453
|
const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
449
454
|
for (const h of inner) {
|
|
450
|
-
|
|
451
|
-
if (cmd.includes(".claude-mem-lite/") || cmd.includes("claude-mem-lite/scripts") || cmd.includes("claude-mem-lite/hook.mjs")) {
|
|
455
|
+
if (isMemHookCommand(h?.command, roots)) {
|
|
452
456
|
found.push(ev);
|
|
453
457
|
break;
|
|
454
458
|
}
|
|
@@ -470,7 +474,7 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
|
470
474
|
process.stderr.write(" then keep using the plugin install. (One-time warning.)\n\n");
|
|
471
475
|
process.exit(2);
|
|
472
476
|
}
|
|
473
|
-
}
|
|
477
|
+
}).catch(() => {});
|
|
474
478
|
' || true
|
|
475
479
|
fi
|
|
476
480
|
# Mark the warning as shown regardless of result — silence is fine if no
|
package/secret-scrub.mjs
CHANGED
|
@@ -464,35 +464,62 @@ const isEscBreak = (r, u) => u > 0 && r % (2 * u) === u;
|
|
|
464
464
|
// An unescaped quote at depth u ends the string: r % 2u < u.
|
|
465
465
|
const isStringEnd = (r, u) => u > 0 && r % (2 * u) < u;
|
|
466
466
|
|
|
467
|
+
// The characters an escaped line acts on: breaks and backslashes; every other character only moves
|
|
468
|
+
// it on. Steps past LINE_STEP_BUDGET of them are searched natively: a line that runs to the end of
|
|
469
|
+
// the text, read once per BEGIN at its depth and once per pass, was 94.6% of the time of the
|
|
470
|
+
// escape-depth shape (D#511). A line at depth 0 keeps the plain loop.
|
|
471
|
+
const ESC_LINE_STOP_RE = /[\n\r\\]/g;
|
|
472
|
+
const LINE_STEP_BUDGET = 32;
|
|
473
|
+
|
|
467
474
|
/** The line starting at s: its end, and where the next line starts (-1 at the end of the text). */
|
|
468
475
|
function nextLine(text, s, u) {
|
|
476
|
+
if (u > 0) return nextEscapedLine(text, s, u);
|
|
469
477
|
const n = text.length;
|
|
470
|
-
let i = s;
|
|
471
|
-
while (i < n) {
|
|
478
|
+
for (let i = s; i < n; i++) {
|
|
472
479
|
const ch = text[i];
|
|
473
480
|
if (ch === '\n') return { end: i, next: i + 1 };
|
|
474
481
|
if (ch === '\r') return { end: i, next: text[i + 1] === '\n' ? i + 2 : i + 1 };
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
482
|
+
}
|
|
483
|
+
return { end: n, next: -1 };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** nextLine at break depth u > 0. */
|
|
487
|
+
function nextEscapedLine(text, s, u) {
|
|
488
|
+
const n = text.length;
|
|
489
|
+
let i = s;
|
|
490
|
+
let lim = Math.min(n, s + LINE_STEP_BUDGET);
|
|
491
|
+
for (;;) {
|
|
492
|
+
while (i < lim) {
|
|
493
|
+
const ch = text[i];
|
|
494
|
+
if (ch === '\n') return { end: i, next: i + 1 };
|
|
495
|
+
if (ch === '\r') return { end: i, next: text[i + 1] === '\n' ? i + 2 : i + 1 };
|
|
496
|
+
if (ch === '\\') {
|
|
497
|
+
let j = i;
|
|
498
|
+
while (j < n && text[j] === '\\') j++;
|
|
499
|
+
const r = j - i;
|
|
500
|
+
const c = text[j];
|
|
501
|
+
if ((c === 'n' || c === 'r') && isEscBreak(r, u)) {
|
|
502
|
+
let next = j + 1;
|
|
503
|
+
if (c === 'r' && text.startsWith('\\'.repeat(u) + 'n', next)) next += u + 1;
|
|
504
|
+
// The pattern starts with a quote, so no other character can begin a match.
|
|
505
|
+
if (u === 1 && (text[next] === '"' || text[next] === "'")) {
|
|
506
|
+
CONCAT_AFTER_RE.lastIndex = next;
|
|
507
|
+
const m = CONCAT_AFTER_RE.exec(text);
|
|
508
|
+
if (m) next += m[0].length;
|
|
509
|
+
}
|
|
510
|
+
return { end: j - u, next };
|
|
487
511
|
}
|
|
488
|
-
|
|
512
|
+
i = j + 1;
|
|
513
|
+
continue;
|
|
489
514
|
}
|
|
490
|
-
i
|
|
491
|
-
continue;
|
|
515
|
+
i++;
|
|
492
516
|
}
|
|
493
|
-
i
|
|
517
|
+
if (i >= n) return { end: n, next: -1 };
|
|
518
|
+
ESC_LINE_STOP_RE.lastIndex = i;
|
|
519
|
+
if (!ESC_LINE_STOP_RE.test(text)) return { end: n, next: -1 };
|
|
520
|
+
i = ESC_LINE_STOP_RE.lastIndex - 1;
|
|
521
|
+
lim = Math.min(n, i + LINE_STEP_BUDGET);
|
|
494
522
|
}
|
|
495
|
-
return { end: n, next: -1 };
|
|
496
523
|
}
|
|
497
524
|
|
|
498
525
|
/** The line that ends at the break before `ls`, or null when `ls` starts the text or string. */
|
|
@@ -534,6 +561,31 @@ function prefixShape(prefix) {
|
|
|
534
561
|
return new RegExp(`^[ \\t]*${src}`);
|
|
535
562
|
}
|
|
536
563
|
|
|
564
|
+
const isTagNameCode = (c) =>
|
|
565
|
+
(c >= 65 && c <= 90) ||
|
|
566
|
+
(c >= 97 && c <= 122) ||
|
|
567
|
+
(c >= 48 && c <= 57) ||
|
|
568
|
+
c === 95 ||
|
|
569
|
+
c === 58 ||
|
|
570
|
+
c === 46 ||
|
|
571
|
+
c === 45;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The length of a closing tag `</name>` (a letter, then up to 40 of `[\w:.-]`) that ends `line` at
|
|
575
|
+
* q, starting at a or after; 0 when there is none. `/<\/[A-Za-z][\w:.-]{0,40}>$/` on
|
|
576
|
+
* `line.slice(a, q)`, read from its end: the name is the run of name characters before the `>`,
|
|
577
|
+
* since a `/` cannot be one of them.
|
|
578
|
+
*/
|
|
579
|
+
function closingTagLength(line, a, q) {
|
|
580
|
+
if (line[q - 1] !== '>') return 0;
|
|
581
|
+
let r = q - 1;
|
|
582
|
+
while (r > a && q - 1 - r <= 41 && isTagNameCode(line.charCodeAt(r - 1))) r--;
|
|
583
|
+
const len = q - 1 - r;
|
|
584
|
+
if (len < 1 || len > 41 || r - 2 < a || line[r - 1] !== '/' || line[r - 2] !== '<') return 0;
|
|
585
|
+
const c = line.charCodeAt(r);
|
|
586
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ? q - (r - 2) : 0;
|
|
587
|
+
}
|
|
588
|
+
|
|
537
589
|
/** The judged part of line [s, e): no prefix of the key's shape, no padding, no string quotes. */
|
|
538
590
|
function lineCore(text, s, e, shape) {
|
|
539
591
|
let line = text.slice(s, e);
|
|
@@ -555,8 +607,8 @@ function lineCore(text, s, e, shape) {
|
|
|
555
607
|
// `(?:\\+[rn])*["']…$` retried every start in a backslash run.
|
|
556
608
|
let q = b;
|
|
557
609
|
while (q > a && ' \t+,;)]}'.includes(line[q - 1])) q--;
|
|
558
|
-
const tag = q > a
|
|
559
|
-
if (tag) b = q - tag
|
|
610
|
+
const tag = q > a ? closingTagLength(line, a, q) : 0;
|
|
611
|
+
if (tag) b = q - tag;
|
|
560
612
|
else if (q > a && (line[q - 1] === '"' || line[q - 1] === "'" || line[q - 1] === '`')) {
|
|
561
613
|
q--;
|
|
562
614
|
for (;;) {
|
|
@@ -660,6 +712,12 @@ function cutOffKeyEnd(text, b, be) {
|
|
|
660
712
|
let run = 0;
|
|
661
713
|
while (run < 16 && b - run > ls && isB64Code(text.charCodeAt(b - run - 1))) run++;
|
|
662
714
|
const shape = run >= 16 ? null : prefixShape(text.slice(ls, b));
|
|
715
|
+
// The first character of the prefix after its spaces and tabs: a shape's match can only start a
|
|
716
|
+
// line whose first character after spaces and tabs is this one (a digit, `:` and `-` stand for
|
|
717
|
+
// more, and none of them begins a blank line below).
|
|
718
|
+
let pl = ls;
|
|
719
|
+
while (pl < b && (text.charCodeAt(pl) === 32 || text.charCodeAt(pl) === 9)) pl++;
|
|
720
|
+
const shapeLead = shape ? text.charCodeAt(pl) : -1;
|
|
663
721
|
|
|
664
722
|
let armors = 0;
|
|
665
723
|
let shorts = 0;
|
|
@@ -668,6 +726,32 @@ function cutOffKeyEnd(text, b, be) {
|
|
|
668
726
|
// Another BEGIN line is never part of this key; stop before reading it whole.
|
|
669
727
|
if (!shape && text.startsWith('-----BEGIN ', next)) break;
|
|
670
728
|
const line = nextLine(text, next, u);
|
|
729
|
+
// Blank lines read here without lineCore, where the runs a BEGIN reads through spent a third of
|
|
730
|
+
// their time (D#511). Spaces and tabs, at most one quote or backtick, spaces and tabs: blank
|
|
731
|
+
// whatever the shape, since a shape that matches takes a prefix of it and lineCore takes leading
|
|
732
|
+
// blanks, one leading quote and trailing blanks from the rest. Spaces and tabs, at most one
|
|
733
|
+
// quote, then exactly a closing tag and blanks: blank too, unless the shape can start where the
|
|
734
|
+
// line does (lineCore takes the tag whole, but a shape could take part of it).
|
|
735
|
+
const le = line.end;
|
|
736
|
+
let k = next;
|
|
737
|
+
while (k < le && (text.charCodeAt(k) === 32 || text.charCodeAt(k) === 9)) k++;
|
|
738
|
+
const c0 = text.charCodeAt(k);
|
|
739
|
+
if (k < le && (c0 === 34 || c0 === 39 || c0 === 96)) k++;
|
|
740
|
+
let blank = k === le;
|
|
741
|
+
if (!blank && text.charCodeAt(k) === 60 && c0 !== shapeLead) {
|
|
742
|
+
let e2 = le;
|
|
743
|
+
while (e2 > k && (text.charCodeAt(e2 - 1) === 32 || text.charCodeAt(e2 - 1) === 9)) e2--;
|
|
744
|
+
blank = closingTagLength(text, k, e2) === e2 - k;
|
|
745
|
+
} else if (!blank && k > next && (c0 === 34 || c0 === 39 || c0 === 96)) {
|
|
746
|
+
while (k < le && (text.charCodeAt(k) === 32 || text.charCodeAt(k) === 9)) k++;
|
|
747
|
+
blank = k === le;
|
|
748
|
+
}
|
|
749
|
+
if (blank) {
|
|
750
|
+
next = line.next;
|
|
751
|
+
if (shorts > 0) break;
|
|
752
|
+
pendingBlank = true;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
671
755
|
const { core, start, end: coreEnd } = lineCore(text, next, line.end, shape);
|
|
672
756
|
const kind = classify(core);
|
|
673
757
|
next = line.next;
|