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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.30.1",
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.1",
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 <new id>`.
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 <old id>`).
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
  - **其他变化:** 同一项目同时开两个会话,各自的交接、摘要和未保存的工具记录不再混在一起;会话里的
@@ -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 <project> [--from <date>] > <scratch>/memories.json
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 <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 <project> --apply --digest <digest>
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. Set the broken file aside so a fresh ` +
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): ${remedy.command} ` +
490
- `— move the broken file aside first if you want to keep it for inspection.`
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
 
@@ -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) lines.push(` ${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');
@@ -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 { join, dirname, isAbsolute } from 'node:path';
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
- const OUR_ENTRY_PATH_RE = /claude-mem-lite[\w.-]*[\\/](?:hook\.mjs|scripts[\\/][\w.-]+\.(?:mjs|js|sh))/;
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 OUR_ENTRY_PATH_RE.test(cmd) || isOurLauncher(cmd) || isOurPrefilter(cmd, roots);
232
+ return isOurEntryPath(cmd) || isOurLauncher(cmd) || isOurPrefilter(cmd, roots);
202
233
  }
203
234
 
204
235
  /**
@@ -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 = args.includes('--run');
3687
- const runAll = args.includes('--run-all');
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 = args.includes('--verbose') || args.includes('-v');
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 (the user's other flags stay),
3936
- // or another command (`preview`).
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: { preview: 'maintain scan' },
3944
+ maintain: { swap: 'scan in place of execute' },
3940
3945
  optimize: { without: '--run or --run-all' },
3941
- delete: { preview: null },
3946
+ delete: { without: '--confirm' },
3942
3947
  'activity promote': { without: '--execute' },
3943
- 'activity delete': { preview: null },
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. ${without ? `Run it again without ${without} to preview.` : preview ? `Preview with: \`${CLI_INVOKE} ${preview}\`.` : 'It has no preview.'}`,
4083
+ `written. ${how}`,
4072
4084
  );
4073
4085
  return;
4074
4086
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.30.1",
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.1",
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.1",
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
- try {
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
- SETTINGS_PATH="$SETTINGS" CFG_DIR="$CC_CONFIG_DIR" node -e '
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
- try {
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
- const cmd = String(h?.command || "");
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
- } catch {}
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
- if (u > 0 && ch === '\\') {
476
- let j = i;
477
- while (j < n && text[j] === '\\') j++;
478
- const r = j - i;
479
- const c = text[j];
480
- if ((c === 'n' || c === 'r') && isEscBreak(r, u)) {
481
- let next = j + 1;
482
- if (c === 'r' && text.startsWith('\\'.repeat(u) + 'n', next)) next += u + 1;
483
- if (u === 1) {
484
- CONCAT_AFTER_RE.lastIndex = next;
485
- const m = CONCAT_AFTER_RE.exec(text);
486
- if (m) next += m[0].length;
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
- return { end: j - u, next };
512
+ i = j + 1;
513
+ continue;
489
514
  }
490
- i = j + 1;
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 && line[q - 1] === '>' ? /<\/[A-Za-z][\w:.-]{0,40}>$/.exec(line.slice(a, q)) : null;
559
- if (tag) b = q - tag[0].length;
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;