claude-mem-lite 6.30.0 → 6.30.1

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.1",
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.1",
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/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
  }
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.`,
@@ -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);
@@ -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.
@@ -3932,12 +3932,14 @@ const MUTATING_FILTER_READS = {
3932
3932
  // A command not in MUTATING_FILTER_READS names here when its run writes (round-2 pre-tag review
3933
3933
  // P3-2: defer drop and fts-check rebuild ignored --dry-run silently; verify-apply --apply is left
3934
3934
  // 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`).
3935
3937
  const DRY_RUN_UNREAD = {
3936
- compress: { preview: 'compress without --execute' },
3938
+ compress: { without: '--execute' },
3937
3939
  maintain: { preview: 'maintain scan' },
3938
- optimize: { preview: 'optimize without --run' },
3940
+ optimize: { without: '--run or --run-all' },
3939
3941
  delete: { preview: null },
3940
- 'activity promote': { preview: 'activity promote without --execute' },
3942
+ 'activity promote': { without: '--execute' },
3941
3943
  'activity delete': { preview: null },
3942
3944
  'import-jsonl': { preview: null, writes: () => true },
3943
3945
  'defer drop': { preview: null, writes: () => true },
@@ -4062,11 +4064,11 @@ async function runDispatch(argv) {
4062
4064
 
4063
4065
  if (Object.hasOwn(DRY_RUN_UNREAD, mutatingKey)) {
4064
4066
  const parsed = parseArgs(cmdArgs, { track: false });
4065
- const { preview, writes } = DRY_RUN_UNREAD[mutatingKey];
4067
+ const { preview, without, writes } = DRY_RUN_UNREAD[mutatingKey];
4066
4068
  if ((mutating ? mutating.writes(parsed) : writes(parsed)) && asksDryRun(parsed.flags['dry-run'])) {
4067
4069
  fail(
4068
4070
  `[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.'}`,
4071
+ `written. ${without ? `Run it again without ${without} to preview.` : preview ? `Preview with: \`${CLI_INVOKE} ${preview}\`.` : 'It has no preview.'}`,
4070
4072
  );
4071
4073
  return;
4072
4074
  }
@@ -4279,6 +4281,8 @@ async function runDispatch(argv) {
4279
4281
  }
4280
4282
  out(`[mem] Error: Cannot open database: ${e.message}`);
4281
4283
  out(`[mem] DB path: ${DB_PATH}`);
4284
+ // The remedy overwrites the database, so it is doctor's to print, not this line's (D#488).
4285
+ if (isDbUnusableError(e)) out(`[mem] Next: run \`${CLI_INVOKE} doctor\`.`);
4282
4286
  process.exitCode = 1;
4283
4287
  return;
4284
4288
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.30.0",
3
+ "version": "6.30.1",
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.1",
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.1",
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/scripts/setup.sh CHANGED
@@ -425,15 +425,16 @@ 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.
432
433
  RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55$(marker_suffix "$CC_CONFIG_DIR/settings.json" "$CC_DEFAULT_CONFIG_DIR/settings.json")"
433
434
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
434
435
  SETTINGS="$CC_CONFIG_DIR/settings.json"
435
436
  if [[ -f "$SETTINGS" ]]; then
436
- SETTINGS_PATH="$SETTINGS" node -e '
437
+ SETTINGS_PATH="$SETTINGS" CFG_DIR="$CC_CONFIG_DIR" node -e '
437
438
  const fs = require("fs");
438
439
  try {
439
440
  const raw = fs.readFileSync(process.env.SETTINGS_PATH, "utf8");
@@ -459,7 +460,13 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
459
460
  process.stderr.write("\x1b[33m⚠\x1b[0m Legacy direct-install hooks detected in " + process.env.SETTINGS_PATH + "\n");
460
461
  process.stderr.write(" Events with stale entries: " + [...new Set(found)].join(", ") + "\n");
461
462
  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");
463
+ // A shell word, single-quoted unless plain (cli-path.mjs shellWord); \x27 is a quote here.
464
+ const q = (w) => (/^[\w@%+=:,.\/-]+$/.test(w) ? w : "\x27" + w.split("\x27").join("\x27\\\x27\x27") + "\x27");
465
+ // The config home this warning is about, named even when it is the default one: the shell
466
+ // the user types into may carry a CLAUDE_CONFIG_DIR that names another profile.
467
+ const env = "CLAUDE_CONFIG_DIR=" + q(process.env.CFG_DIR) + " ";
468
+ const fix = env + "node " + q(process.env.CLAUDE_PLUGIN_ROOT + "/cli.mjs") + " cleanup-hooks";
469
+ process.stderr.write(" Fix: run \x1b[1m" + fix + "\x1b[0m to clear settings.json,\n");
463
470
  process.stderr.write(" then keep using the plugin install. (One-time warning.)\n\n");
464
471
  process.exit(2);
465
472
  }
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:
@@ -684,7 +713,7 @@ function cutOffKeyEnd(text, b, be) {
684
713
  // Under an opener whose prefix does not match the diff lines below it (`'+`, `rb'+`,
685
714
  // `+KEY = '''`) the line still starts with its `+`; the magic is tested without it (D#167).
686
715
  const body = first.replace(/^[-+]/, '');
687
- const magic = KEY_MAGIC_RE.test(body) || (text.slice(b, be).includes('PGP') && PGP_MAGIC_RE.test(body));
716
+ const magic = startsLikeKey(body) || (text.slice(b, be).includes('PGP') && PGP_MAGIC_RE.test(body));
688
717
  return longs >= 2 || armors > 0 || longest >= 40 || magic ? end : -1;
689
718
  }
690
719
 
@@ -797,7 +826,7 @@ function keyTailSpan(text, e, ee, floor) {
797
826
  // 16-39 characters over an END named in prose is not (F7).
798
827
  if (longs === 0) return null;
799
828
  const b64ish = /[0-9+]|=$/.test(topCore);
800
- if (!(longs >= 2 || longest >= 40 || crc || (clean && b64ish) || KEY_MAGIC_RE.test(topCore))) return null;
829
+ if (!(longs >= 2 || longest >= 40 || crc || (clean && b64ish) || startsLikeKey(topCore))) return null;
801
830
  return [top, sentence ? bottom : ee];
802
831
  }
803
832