claude-mem-lite 6.30.0 → 6.30.2

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.0",
12
+ "version": "6.30.2",
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.0",
3
+ "version": "6.30.2",
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
  - **其他变化:** 同一项目同时开两个会话,各自的交接、摘要和未保存的工具记录不再混在一起;会话里的
package/cli/common.mjs CHANGED
@@ -650,10 +650,11 @@ export function formatProbeHints(probe) {
650
650
  }
651
651
 
652
652
  /**
653
- * The line under one side of a scan's Needs-review pair (D#461), from reviewRows: type, project,
654
- * date, the lesson or that there is none, and the start of the narrative — enough to tell two rows
655
- * with near-identical titles apart without `get`, which counts as an access.
656
- * @param {{id: number, type?: string, project?: string, created_at_epoch?: number,
653
+ * The line under one side of a scan's Needs-review pair (D#461), from reviewRows: type, importance
654
+ * (update.md keeps the more important row, D#497), project, date, the lesson or that there is none,
655
+ * and the start of the narrative — enough to tell two rows with near-identical titles apart without
656
+ * `get`, which counts as an access.
657
+ * @param {{id: number, type?: string, importance?: number, project?: string, created_at_epoch?: number,
657
658
  * narrative?: string|null, lesson_learned?: string|null}|undefined} row
658
659
  * @returns {string}
659
660
  */
@@ -671,5 +672,5 @@ export function formatReviewSide(row) {
671
672
  ? `lesson: ${flat(row.lesson_learned, 100)}`
672
673
  : 'no lesson';
673
674
  const date = row.created_at_epoch ? new Date(row.created_at_epoch).toISOString().slice(0, 10) : '?';
674
- return `#${row.id} ${row.type || '?'} · ${row.project} · ${date} · ${lesson} · ${flat(row.narrative, 120) || '(no narrative)'}`;
675
+ return `#${row.id} ${row.type || '?'} · importance ${row.importance ?? '?'} · ${row.project} · ${date} · ${lesson} · ${flat(row.narrative, 120) || '(no narrative)'}`;
675
676
  }
@@ -153,7 +153,7 @@ export function cmdVerifyApply(db, args) {
153
153
  out(`[mem] Plan digest: ${digest}`);
154
154
  out('[mem] Dry run — nothing written. After the user approves exactly this plan, run:');
155
155
  out(
156
- ` ${SELF} verify-apply ${shellWord(file)} --project ${shellWord(project)} --apply --digest ${digest}`,
156
+ ` ${SELF} verify-apply ${shellWord(file)} --project=${shellWord(project)} --apply --digest ${digest}`,
157
157
  );
158
158
  return;
159
159
  }
@@ -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/hook.mjs CHANGED
@@ -3004,9 +3004,9 @@ function rekeyProjectOnce(db, project) {
3004
3004
  ? ''
3005
3005
  : left === 1
3006
3006
  ? " 1 other stays under the old id (another directory's, or nothing shows whose); " +
3007
- `list it with \`${CLI_INVOKE} recent 50 --project ${legacy}\`.`
3007
+ `list it with \`${CLI_INVOKE} recent 50 --project=${legacy}\`.`
3008
3008
  : ` ${left} others stay under the old id (another directory's, or nothing shows whose); ` +
3009
- `list them with \`${CLI_INVOKE} recent 50 --project ${legacy}\`.`;
3009
+ `list them with \`${CLI_INVOKE} recent 50 --project=${legacy}\`.`;
3010
3010
  queueHookSystemMessage(
3011
3011
  `claude-mem-lite: this project's memory now has its own id, ${project}. Its old id, ${legacy}, ` +
3012
3012
  'could be shared with other directories whose names are not plain ASCII. ' +
package/install.mjs CHANGED
@@ -564,6 +564,15 @@ export function deployLockfile(projectDir, dataDir) {
564
564
  return shrinkwrap;
565
565
  }
566
566
 
567
+ function devNodeModules() {
568
+ try {
569
+ const pkg = createRequire(join(PROJECT_DIR, 'package.json')).resolve('better-sqlite3/package.json');
570
+ return dirname(dirname(pkg));
571
+ } catch {
572
+ return join(PROJECT_DIR, 'node_modules');
573
+ }
574
+ }
575
+
567
576
  export function deployCodeTree(IS_DEV) {
568
577
  if (IS_DEV) {
569
578
  log('Dev mode — creating symlinks in ~/.claude-mem-lite/...');
@@ -584,10 +593,12 @@ export function deployCodeTree(IS_DEV) {
584
593
  const scriptsLink = join(DATA_DIR, 'scripts');
585
594
  clearLinkPath(scriptsLink);
586
595
  symlinkSync(join(PROJECT_DIR, 'scripts'), scriptsLink);
587
- // Symlink node_modules/
596
+ // Symlink node_modules/: the one this checkout loads its dependencies from. A worktree nested in
597
+ // the main checkout has none of its own and Node finds the one above it; linking
598
+ // PROJECT_DIR/node_modules left the link dangling there (D#489).
588
599
  const nmLink = join(DATA_DIR, 'node_modules');
589
600
  clearLinkPath(nmLink);
590
- symlinkSync(join(PROJECT_DIR, 'node_modules'), nmLink);
601
+ symlinkSync(devNodeModules(), nmLink);
591
602
  // R10 P3-28: the registry/ symlink is gone. The directory left with the skill registry
592
603
  // in v5.0.0, so the existsSync guard was permanently false and the branch was dead —
593
604
  // clearLinkPath still ran, silently removing a stale ~/.claude-mem-lite/registry link
@@ -224,7 +224,7 @@ export function dbUnusableRemedy(dbPath, err) {
224
224
  */
225
225
  export function formatDbUnusableNotice({ dbPath, remedy }) {
226
226
  const lines = [
227
- '⚠️ [claude-mem-lite] Memory is OFF: the database file cannot be opened.',
227
+ '⚠️ [claude-mem-lite] Memory is OFF: the database file cannot be used.',
228
228
  remedy.kind === 'schema'
229
229
  ? ` ${dbPath} exists but SQLite cannot read its schema.`
230
230
  : ` ${dbPath} exists but SQLite does not recognise it as a database.`,
@@ -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
  /**
@@ -809,7 +809,7 @@ export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, re
809
809
  * lets autoBoost lift importance 1 -> 2. A plain SELECT by id touches no counter.
810
810
  * @param {import('better-sqlite3').Database} db
811
811
  * @param {number[]} ids
812
- * @returns {Map<number, {id: number, type: string, project: string, created_at_epoch: number,
812
+ * @returns {Map<number, {id: number, type: string, importance: number, project: string, created_at_epoch: number,
813
813
  * narrative: string|null, lesson_learned: string|null}>}
814
814
  */
815
815
  export function reviewRows(db, ids) {
@@ -817,7 +817,7 @@ export function reviewRows(db, ids) {
817
817
  if (uniq.length === 0) return new Map();
818
818
  const rows = db
819
819
  .prepare(
820
- `SELECT id, type, project, created_at_epoch, narrative, lesson_learned FROM observations
820
+ `SELECT id, type, importance, project, created_at_epoch, narrative, lesson_learned FROM observations
821
821
  WHERE id IN (${uniq.map(() => '?').join(',')})`,
822
822
  )
823
823
  .all(...uniq);
@@ -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) {
@@ -12,7 +12,7 @@ import { isAdopted as isAdoptedClaudeMd, readBlockAt } from '../claudemd.mjs';
12
12
  import { existsSync } from 'fs';
13
13
  import { dirname, join, resolve } from 'path';
14
14
  import { PLUGIN_SLUG } from '../adopt-content.mjs';
15
- import { isSharedAncestor, RULES_MD } from './local-steering.mjs';
15
+ import { isSharedAncestor, RULES_MD, userInstructionFiles } from './local-steering.mjs';
16
16
 
17
17
  // Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
18
18
  // bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
@@ -34,8 +34,12 @@ export function isQuietHooks() {
34
34
  export function isAdoptedHere(cwd) {
35
35
  try {
36
36
  const resolved = cwd || process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
37
- if (isAdoptedClaudeMd(resolved, PLUGIN_SLUG) || isAdoptedMemdir(memdirPath(resolved), PLUGIN_SLUG))
38
- return true;
37
+ if (isAdoptedMemdir(memdirPath(resolved), PLUGIN_SLUG)) return true;
38
+ // Project instructions = managed-only leaves the files below out at launch, so only steering
39
+ // that SessionStart injects counts, as silentAutoAdopt decides there (D#501(2)).
40
+ const managedOnly = userInstructionFiles() === 'managed-only';
41
+ if (managedOnly) return isSteeringInjectedHere(resolved, { managedOnly });
42
+ if (isAdoptedClaudeMd(resolved, PLUGIN_SLUG)) return true;
39
43
  // r3: the block auto-adopt wrote into CLAUDE.local.md loads whatever the switches say
40
44
  // now (MEM_NO_AUTO_ADOPT only stops new writes). A file read, not a git call: this runs
41
45
  // on every quiet check, and the project dir is the top-level in the normal case.
@@ -61,7 +65,7 @@ export function isAdoptedHere(cwd) {
61
65
  * @param {string} cwd resolved project root
62
66
  * @returns {boolean}
63
67
  */
64
- function isSteeringInjectedHere(cwd) {
68
+ function isSteeringInjectedHere(cwd, { managedOnly = false } = {}) {
65
69
  if (process.env.MEM_NO_AUTO_ADOPT === '1') return false;
66
70
  if (isAutoAdoptDisabledFor(cwd)) return false;
67
71
  // silentAutoAdopt holds a subdirectory session to an opt-out recorded for the repository root,
@@ -69,8 +73,9 @@ function isSteeringInjectedHere(cwd) {
69
73
  // gets neither the steering nor the verbose sections.
70
74
  const root = workTreeTop(cwd);
71
75
  if (!root || root === resolve(cwd) || isSharedAncestor(root)) return true;
72
- // Any directory up to the root, as silentAutoAdopt checks (D#177(2)).
73
- for (let d = dirname(resolve(cwd)); ; d = dirname(d)) {
76
+ // Any directory up to the root, as silentAutoAdopt checks (D#177(2)); under managed-only it
77
+ // skips them, as none is loaded at launch.
78
+ for (let d = dirname(resolve(cwd)); !managedOnly; d = dirname(d)) {
74
79
  if (readBlockAt(join(d, 'CLAUDE.md'), PLUGIN_SLUG).body !== null) return true;
75
80
  if (d === root || dirname(d) === d) break;
76
81
  }
@@ -10,6 +10,7 @@ import { readGitState } from './git-state.mjs';
10
10
  import { readProjectTasks } from './task-reader.mjs';
11
11
  import { recentPlans } from './plan-reader.mjs';
12
12
  import { isAdoptedHere } from './quiet-scope.mjs';
13
+ import { userInstructionFiles } from './local-steering.mjs';
13
14
  import {
14
15
  HANDOFF_EXPIRY_EXIT,
15
16
  UNCONSUMED_HANDOFF_SQL,
@@ -160,8 +161,10 @@ export function buildDashboard({
160
161
  function buildAdoptHint({ projectPath, stubs }) {
161
162
  if (process.env.MEM_NO_ADOPT_HINT === '1') return null;
162
163
  if (process.env.MEM_QUIET_HOOKS === '1') return null;
164
+ // `adopt` writes CLAUDE.md, which Project instructions = managed-only leaves out at launch (D#501(1)).
165
+ if (userInstructionFiles() === 'managed-only') return null;
163
166
  // stubs.adopted === true/false lets tests assert both branches without touching FS.
164
167
  const adopted = stubs && 'adopted' in stubs ? stubs.adopted : isAdoptedHere(projectPath);
165
168
  if (adopted) return null;
166
- return `🧷 Invited-memory 未启用:\`${CLI_INVOKE} adopt\` 写入 CLAUDE.md 托管块 → 上下文省 ~40%,MCP 调用率提升;静音 \`MEM_NO_ADOPT_HINT=1\``;
169
+ return `🧷 Invited-memory 未启用:\`${CLI_INVOKE} adopt\` 写入 CLAUDE.md 托管块;静音 \`MEM_NO_ADOPT_HINT=1\``;
167
170
  }
@@ -224,7 +224,7 @@ export function formatQualityReport(data) {
224
224
  const purgeGap = (purgeNum - 10).toFixed(1);
225
225
  const purgeStatus = purgeNum <= 10 ? '✅' : purgeNum <= 30 ? '🟡' : '🔴';
226
226
  lines.push(
227
- ` ${purgeStatus} Pending purge ≤ 10% → currently ${purgePct}% (${purgeRow.pending_purge}/${purgeRow.compressed}) (gap ${purgeGap >= 0 ? '+' : ''}${purgeGap}pp)${purgeNum > 10 ? ` — preview: ${CLI_INVOKE} maintain execute --ops purge_stale${project ? ` --project ${shellWord(project)}` : ''} (add --confirm to delete the rows it counts)` : ''}`,
227
+ ` ${purgeStatus} Pending purge ≤ 10% → currently ${purgePct}% (${purgeRow.pending_purge}/${purgeRow.compressed}) (gap ${purgeGap >= 0 ? '+' : ''}${purgeGap}pp)${purgeNum > 10 ? ` — preview: ${CLI_INVOKE} maintain execute --ops purge_stale${project ? ` --project=${shellWord(project)}` : ''} (add --confirm to delete the rows it counts)` : ''}`,
228
228
  );
229
229
  }
230
230
 
package/mem-cli.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { ensureDbWithWalRecovery, refreshPlannerStats, DB_PATH, DB_DIR, CODE_DIR } from './schema.mjs';
6
6
  import { dirAccessError, walOpenBlocked, dataDirRemedy, WAL_BLOCKED_WHY } from './lib/wal-open-blocked.mjs';
7
7
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
8
- import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
8
+ import { isFtsCorruptionError, isDbUnusableError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
9
9
  import {
10
10
  truncate,
11
11
  queryLabel,
@@ -100,7 +100,7 @@ import {
100
100
  schemaSkewRemedy,
101
101
  formatSchemaSkewNotice,
102
102
  } from './lib/schema-skew.mjs';
103
- import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
103
+ import { CLI_PATH, CLI_INVOKE, shellWord } from './cli-path.mjs';
104
104
  import {
105
105
  parseArgs,
106
106
  out,
@@ -3640,7 +3640,7 @@ async function cmdImportJsonl(db, argv) {
3640
3640
  // — an orphan-only first import must not fall through to the "already imported"
3641
3641
  // no-op branch below.
3642
3642
  if (projectCheck.note) out(`[mem] Imported${projectCheck.note}.`);
3643
- out(`[mem] Try: ${CLI_INVOKE} recent 5 --project ${project}`);
3643
+ out(`[mem] Try: ${CLI_INVOKE} recent 5 --project=${shellWord(project)}`);
3644
3644
  } else if (totalRecognized > 0) {
3645
3645
  // Lines WERE Claude Code transcript events but produced no new rows — the file
3646
3646
  // was already imported (idempotent re-run) or carried no extractable content.
@@ -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,13 +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).
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`).
3935
3942
  const DRY_RUN_UNREAD = {
3936
- compress: { preview: 'compress without --execute' },
3937
- maintain: { preview: 'maintain scan' },
3938
- optimize: { preview: 'optimize without --run' },
3939
- delete: { preview: null },
3940
- 'activity promote': { preview: 'activity promote without --execute' },
3941
- 'activity delete': { preview: null },
3943
+ compress: { without: '--execute' },
3944
+ maintain: { swap: 'scan in place of execute' },
3945
+ optimize: { without: '--run or --run-all' },
3946
+ delete: { without: '--confirm' },
3947
+ 'activity promote': { without: '--execute' },
3948
+ 'activity delete': { without: '--confirm' },
3942
3949
  'import-jsonl': { preview: null, writes: () => true },
3943
3950
  'defer drop': { preview: null, writes: () => true },
3944
3951
  'fts-check': { preview: 'fts-check check', writes: ({ positional }) => positional[0] === 'rebuild' },
@@ -4062,11 +4069,18 @@ async function runDispatch(argv) {
4062
4069
 
4063
4070
  if (Object.hasOwn(DRY_RUN_UNREAD, mutatingKey)) {
4064
4071
  const parsed = parseArgs(cmdArgs, { track: false });
4065
- const { preview, writes } = DRY_RUN_UNREAD[mutatingKey];
4072
+ const { preview, without, swap, writes } = DRY_RUN_UNREAD[mutatingKey];
4066
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.';
4067
4081
  fail(
4068
4082
  `[mem] ${mutatingKey} does not read --dry-run — nothing was changed, since the run would have ` +
4069
- `written. ${preview ? `Preview with: claude-mem-lite ${preview}.` : 'It has no preview.'}`,
4083
+ `written. ${how}`,
4070
4084
  );
4071
4085
  return;
4072
4086
  }
@@ -4279,6 +4293,8 @@ async function runDispatch(argv) {
4279
4293
  }
4280
4294
  out(`[mem] Error: Cannot open database: ${e.message}`);
4281
4295
  out(`[mem] DB path: ${DB_PATH}`);
4296
+ // The remedy overwrites the database, so it is doctor's to print, not this line's (D#488).
4297
+ if (isDbUnusableError(e)) out(`[mem] Next: run \`${CLI_INVOKE} doctor\`.`);
4282
4298
  process.exitCode = 1;
4283
4299
  return;
4284
4300
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.30.0",
3
+ "version": "6.30.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.30.0",
9
+ "version": "6.30.2",
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.0",
3
+ "version": "6.30.2",
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
@@ -425,20 +425,26 @@ fi
425
425
  # hooks remain in the config home's settings.json. A user who installed via global
426
426
  # `claude-mem-lite install` and later switched to the marketplace plugin
427
427
  # will run every hook twice (direct settings.json hooks AND plugin hooks)
428
- # until they run `claude-mem-lite uninstall` to clear the settings.json
429
- # entries. /plugin uninstall does not touch settings.json.
428
+ # until they run cleanup-hooks to clear the settings.json entries (not
429
+ # uninstall, which removes the plugin too). /plugin uninstall does not touch
430
+ # settings.json.
430
431
  # CODE_DIR/runtime, same reason: the residue it warns about is stale hook entries in the config
431
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).
432
435
  RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55$(marker_suffix "$CC_CONFIG_DIR/settings.json" "$CC_DEFAULT_CONFIG_DIR/settings.json")"
433
436
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
434
437
  SETTINGS="$CC_CONFIG_DIR/settings.json"
435
438
  if [[ -f "$SETTINGS" ]]; then
436
- SETTINGS_PATH="$SETTINGS" node -e '
439
+ HOOK_PRUNE="$(dirname "${BASH_SOURCE[0]}")/../lib/hook-prune.mjs" \
440
+ SETTINGS_PATH="$SETTINGS" CFG_DIR="$CC_CONFIG_DIR" node -e '
437
441
  const fs = require("fs");
438
- try {
442
+ const { pathToFileURL } = require("url");
443
+ import(pathToFileURL(process.env.HOOK_PRUNE).href).then(({ isMemHookCommand, ourInstallRoots }) => {
439
444
  const raw = fs.readFileSync(process.env.SETTINGS_PATH, "utf8");
440
445
  const data = JSON.parse(raw);
441
446
  const hooks = data.hooks || {};
447
+ const roots = ourInstallRoots(data);
442
448
  const events = Object.keys(hooks);
443
449
  const found = [];
444
450
  for (const ev of events) {
@@ -446,8 +452,7 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
446
452
  for (const entry of list) {
447
453
  const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
448
454
  for (const h of inner) {
449
- const cmd = String(h?.command || "");
450
- 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)) {
451
456
  found.push(ev);
452
457
  break;
453
458
  }
@@ -459,11 +464,17 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
459
464
  process.stderr.write("\x1b[33m⚠\x1b[0m Legacy direct-install hooks detected in " + process.env.SETTINGS_PATH + "\n");
460
465
  process.stderr.write(" Events with stale entries: " + [...new Set(found)].join(", ") + "\n");
461
466
  process.stderr.write(" These will fire alongside plugin hooks (each tool call runs twice).\n");
462
- process.stderr.write(" Fix: run \x1b[1mclaude-mem-lite uninstall\x1b[0m to clear settings.json,\n");
467
+ // A shell word, single-quoted unless plain (cli-path.mjs shellWord); \x27 is a quote here.
468
+ const q = (w) => (/^[\w@%+=:,.\/-]+$/.test(w) ? w : "\x27" + w.split("\x27").join("\x27\\\x27\x27") + "\x27");
469
+ // The config home this warning is about, named even when it is the default one: the shell
470
+ // the user types into may carry a CLAUDE_CONFIG_DIR that names another profile.
471
+ const env = "CLAUDE_CONFIG_DIR=" + q(process.env.CFG_DIR) + " ";
472
+ const fix = env + "node " + q(process.env.CLAUDE_PLUGIN_ROOT + "/cli.mjs") + " cleanup-hooks";
473
+ process.stderr.write(" Fix: run \x1b[1m" + fix + "\x1b[0m to clear settings.json,\n");
463
474
  process.stderr.write(" then keep using the plugin install. (One-time warning.)\n\n");
464
475
  process.exit(2);
465
476
  }
466
- } catch {}
477
+ }).catch(() => {});
467
478
  ' || true
468
479
  fi
469
480
  # Mark the warning as shown regardless of result — silence is fine if no
package/secret-scrub.mjs CHANGED
@@ -385,6 +385,35 @@ const PGP_CRC_RE = /^=[A-Za-z0-9+/]{4}$/;
385
385
  // under a PGP header, a secret-key packet in the old or new format (`lQ…`, `xc…`/`xV…`).
386
386
  const KEY_MAGIC_RE = /^(?:MII|MIG|MC4C|MHcC|b3BlbnNzaC1rZXktdjE)/;
387
387
  const PGP_MAGIC_RE = /^(?:lQ|x[cV])/;
388
+ const B64_DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
389
+
390
+ /**
391
+ * Whether base64 `s` starts the way a DER private key starts: a SEQUENCE holding an INTEGER 0 or 1
392
+ * (PKCS#1, PKCS#8, SEC1, DSA) or a SEQUENCE and an OID (encrypted PKCS#8; a public key's
393
+ * SubjectPublicKeyInfo starts that way too, so a public-key line under a PRIVATE KEY marker is
394
+ * erased as well). The prefix list above missed keys whose DER header begins otherwise: secp256k1
395
+ * `MHQC`, P-521 `MIHc`/`MIHu`, Ed448 `MEcC`, X448 `MEYC`, the 192/224-bit curves and others (D#504 F1).
396
+ */
397
+ function isDerKeyStart(s) {
398
+ if (s.length < 12) return false;
399
+ const b = [];
400
+ for (let i = 0; i < 12; i += 4) {
401
+ let n = 0;
402
+ for (let k = 0; k < 4; k++) {
403
+ const v = B64_DIGITS.indexOf(s[i + k]);
404
+ if (v < 0) return false;
405
+ n = n * 64 + v;
406
+ }
407
+ b.push(n >> 16, (n >> 8) & 255, n & 255);
408
+ }
409
+ if (b[0] !== 0x30) return false;
410
+ const o = b[1] < 0x80 ? 2 : b[1] <= 0x83 ? b[1] - 0x7e : -1;
411
+ if (o < 0) return false;
412
+ if (b[o] === 0x02 && b[o + 1] === 0x01 && b[o + 2] <= 1) return true;
413
+ return b[o] === 0x30 && b[o + (b[o + 1] === 0x81 ? 3 : 2)] === 0x06;
414
+ }
415
+
416
+ const startsLikeKey = (s) => KEY_MAGIC_RE.test(s) || isDerKeyStart(s);
388
417
  // RFC 1421 / RFC 4880 armor headers, before the body. Named, not any `Word:`: a `Note:` line is
389
418
  // prose, and taking it for a header made the lone base64 line under it a key.
390
419
  // RFC 1421's full set and tool-written `X-` headers count too (v6.19.2 pre-tag delta review F4:
@@ -435,35 +464,62 @@ const isEscBreak = (r, u) => u > 0 && r % (2 * u) === u;
435
464
  // An unescaped quote at depth u ends the string: r % 2u < u.
436
465
  const isStringEnd = (r, u) => u > 0 && r % (2 * u) < u;
437
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
+
438
474
  /** The line starting at s: its end, and where the next line starts (-1 at the end of the text). */
439
475
  function nextLine(text, s, u) {
476
+ if (u > 0) return nextEscapedLine(text, s, u);
440
477
  const n = text.length;
441
- let i = s;
442
- while (i < n) {
478
+ for (let i = s; i < n; i++) {
443
479
  const ch = text[i];
444
480
  if (ch === '\n') return { end: i, next: i + 1 };
445
481
  if (ch === '\r') return { end: i, next: text[i + 1] === '\n' ? i + 2 : i + 1 };
446
- if (u > 0 && ch === '\\') {
447
- let j = i;
448
- while (j < n && text[j] === '\\') j++;
449
- const r = j - i;
450
- const c = text[j];
451
- if ((c === 'n' || c === 'r') && isEscBreak(r, u)) {
452
- let next = j + 1;
453
- if (c === 'r' && text.startsWith('\\'.repeat(u) + 'n', next)) next += u + 1;
454
- if (u === 1) {
455
- CONCAT_AFTER_RE.lastIndex = next;
456
- const m = CONCAT_AFTER_RE.exec(text);
457
- 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 };
458
511
  }
459
- return { end: j - u, next };
512
+ i = j + 1;
513
+ continue;
460
514
  }
461
- i = j + 1;
462
- continue;
515
+ i++;
463
516
  }
464
- 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);
465
522
  }
466
- return { end: n, next: -1 };
467
523
  }
468
524
 
469
525
  /** The line that ends at the break before `ls`, or null when `ls` starts the text or string. */
@@ -505,6 +561,31 @@ function prefixShape(prefix) {
505
561
  return new RegExp(`^[ \\t]*${src}`);
506
562
  }
507
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
+
508
589
  /** The judged part of line [s, e): no prefix of the key's shape, no padding, no string quotes. */
509
590
  function lineCore(text, s, e, shape) {
510
591
  let line = text.slice(s, e);
@@ -526,8 +607,8 @@ function lineCore(text, s, e, shape) {
526
607
  // `(?:\\+[rn])*["']…$` retried every start in a backslash run.
527
608
  let q = b;
528
609
  while (q > a && ' \t+,;)]}'.includes(line[q - 1])) q--;
529
- const tag = q > a && line[q - 1] === '>' ? /<\/[A-Za-z][\w:.-]{0,40}>$/.exec(line.slice(a, q)) : null;
530
- if (tag) b = q - tag[0].length;
610
+ const tag = q > a ? closingTagLength(line, a, q) : 0;
611
+ if (tag) b = q - tag;
531
612
  else if (q > a && (line[q - 1] === '"' || line[q - 1] === "'" || line[q - 1] === '`')) {
532
613
  q--;
533
614
  for (;;) {
@@ -631,6 +712,12 @@ function cutOffKeyEnd(text, b, be) {
631
712
  let run = 0;
632
713
  while (run < 16 && b - run > ls && isB64Code(text.charCodeAt(b - run - 1))) run++;
633
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;
634
721
 
635
722
  let armors = 0;
636
723
  let shorts = 0;
@@ -639,6 +726,32 @@ function cutOffKeyEnd(text, b, be) {
639
726
  // Another BEGIN line is never part of this key; stop before reading it whole.
640
727
  if (!shape && text.startsWith('-----BEGIN ', next)) break;
641
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
+ }
642
755
  const { core, start, end: coreEnd } = lineCore(text, next, line.end, shape);
643
756
  const kind = classify(core);
644
757
  next = line.next;
@@ -684,7 +797,7 @@ function cutOffKeyEnd(text, b, be) {
684
797
  // Under an opener whose prefix does not match the diff lines below it (`'+`, `rb'+`,
685
798
  // `+KEY = '''`) the line still starts with its `+`; the magic is tested without it (D#167).
686
799
  const body = first.replace(/^[-+]/, '');
687
- const magic = KEY_MAGIC_RE.test(body) || (text.slice(b, be).includes('PGP') && PGP_MAGIC_RE.test(body));
800
+ const magic = startsLikeKey(body) || (text.slice(b, be).includes('PGP') && PGP_MAGIC_RE.test(body));
688
801
  return longs >= 2 || armors > 0 || longest >= 40 || magic ? end : -1;
689
802
  }
690
803
 
@@ -797,7 +910,7 @@ function keyTailSpan(text, e, ee, floor) {
797
910
  // 16-39 characters over an END named in prose is not (F7).
798
911
  if (longs === 0) return null;
799
912
  const b64ish = /[0-9+]|=$/.test(topCore);
800
- if (!(longs >= 2 || longest >= 40 || crc || (clean && b64ish) || KEY_MAGIC_RE.test(topCore))) return null;
913
+ if (!(longs >= 2 || longest >= 40 || crc || (clean && b64ish) || startsLikeKey(topCore))) return null;
801
914
  return [top, sentence ? bottom : ee];
802
915
  }
803
916