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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/cli/common.mjs +6 -5
- package/cli/verify-apply.mjs +1 -1
- package/commands/verify.md +3 -3
- package/hook.mjs +2 -2
- package/install.mjs +13 -2
- package/lib/db-unusable.mjs +1 -1
- package/lib/hook-prune.mjs +35 -4
- package/lib/maintain-core.mjs +5 -2
- package/lib/quiet-scope.mjs +11 -6
- package/lib/startup-dashboard.mjs +4 -1
- package/lib/stats-quality.mjs +1 -1
- package/mem-cli.mjs +30 -14
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schema.mjs +38 -10
- package/scripts/setup.sh +19 -8
- package/secret-scrub.mjs +136 -23
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.30.
|
|
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.
|
|
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
|
|
365
|
+
`--project=<new id>`.
|
|
366
366
|
|
|
367
367
|
- **Which projects.** Every character outside ASCII letters, digits and `_.-` used to become `-`,
|
|
368
368
|
so `~/projects/博客` and `~/projects/商城` were both `projects----` and shared one memory.
|
|
@@ -374,7 +374,7 @@ directories by their old ids again, so their moved rows are listed only with
|
|
|
374
374
|
directory using the old id, the project takes everything under it, deferred items and session
|
|
375
375
|
history included. Otherwise only the memories whose file paths lie inside the directory move;
|
|
376
376
|
the rest stay under the old id. A one-time notice says what moved and how to list what stayed
|
|
377
|
-
(`claude-mem-lite recent 50 --project
|
|
377
|
+
(`claude-mem-lite recent 50 --project=<old id>`).
|
|
378
378
|
- **Not in this release:** directories with the same parent and name in different repositories
|
|
379
379
|
(`~/a/packages/api` and `~/b/packages/api`) still share one id.
|
|
380
380
|
- **Also changed:** two sessions open in one project keep separate memory sessions (handoffs,
|
package/README.zh-CN.md
CHANGED
|
@@ -277,7 +277,7 @@ README "Trust model per install path" 一节的回退步骤操作。
|
|
|
277
277
|
|
|
278
278
|
**名字不是纯 ASCII 的项目会换一个新标识,它们存下的内容会搬一次。** 没有 schema 版本变更:
|
|
279
279
|
本版本打开过的数据库,6.20.0 仍能打开。想避免搬迁,请在**升级之前**固定 `claude-mem-lite@6.20.0`;
|
|
280
|
-
升级之后再回退,6.20.0 会按旧标识称呼这些目录,搬走的行只能用 `--project
|
|
280
|
+
升级之后再回退,6.20.0 会按旧标识称呼这些目录,搬走的行只能用 `--project=<新标识>` 列出。
|
|
281
281
|
|
|
282
282
|
- **哪些项目。** 以前 ASCII 字母、数字和 `_.-` 以外的字符都变成 `-`,所以 `~/projects/博客` 和
|
|
283
283
|
`~/projects/商城` 都是 `projects----`,共用一份记忆。现在任何文字的字母、组合符和数字都会保留
|
|
@@ -285,7 +285,7 @@ README "Trust model per install path" 一节的回退步骤操作。
|
|
|
285
285
|
组合符或数字,或有基本多文种平面以外的字符(如 `🚀`)时,标识会变。
|
|
286
286
|
- **项目第一次启动会话时搬迁数据。** 如果旧标识下没有任何文件路径显示另一个同样使用该旧标识的目录,
|
|
287
287
|
就把旧标识下的全部内容搬过来,包括待办和会话历史。否则只搬文件路径落在本目录里的记忆,其余留在
|
|
288
|
-
旧标识下。会提示一次搬了什么,以及怎么列出留下的内容(`claude-mem-lite recent 50 --project
|
|
288
|
+
旧标识下。会提示一次搬了什么,以及怎么列出留下的内容(`claude-mem-lite recent 50 --project=<旧标识>`)。
|
|
289
289
|
- **本版本不包含:** 不同仓库里父目录和目录名都相同的目录(`~/a/packages/api` 与 `~/b/packages/api`)
|
|
290
290
|
仍共用一个标识。
|
|
291
291
|
- **其他变化:** 同一项目同时开两个会话,各自的交接、摘要和未保存的工具记录不再混在一起;会话里的
|
package/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,
|
|
654
|
-
* date, the lesson or that there is none,
|
|
655
|
-
* with near-identical titles apart without
|
|
656
|
-
*
|
|
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
|
}
|
package/cli/verify-apply.mjs
CHANGED
|
@@ -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
|
|
156
|
+
` ${SELF} verify-apply ${shellWord(file)} --project=${shellWord(project)} --apply --digest ${digest}`,
|
|
157
157
|
);
|
|
158
158
|
return;
|
|
159
159
|
}
|
package/commands/verify.md
CHANGED
|
@@ -33,7 +33,7 @@ otherwise ask. Always pass that exact name — `export --project` matches loosel
|
|
|
33
33
|
neighbouring project from a short name.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" export --project
|
|
36
|
+
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" export --project=<project> [--from <date>] > <scratch>/memories.json
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Every row carries `id`, `project`, `type`, `title`, `narrative`, `facts`, `lesson_learned`,
|
|
@@ -113,7 +113,7 @@ Write `<scratch>/proposals.json` — a JSON array, one entry per memory to chang
|
|
|
113
113
|
## Step 4 — Show the plan and get approval
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project
|
|
116
|
+
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project=<project>
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
This is a dry run: it validates every entry against the database, writes nothing, prints each
|
|
@@ -130,7 +130,7 @@ the old one will be refused.
|
|
|
130
130
|
Run exactly the command the dry run printed:
|
|
131
131
|
|
|
132
132
|
```bash
|
|
133
|
-
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project
|
|
133
|
+
node "${CLAUDE_PLUGIN_ROOT}/cli.mjs" verify-apply <scratch>/proposals.json --project=<project> --apply --digest <digest>
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
It refuses if the proposals or the memories changed since the dry run. Otherwise it backs up
|
package/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
|
|
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
|
|
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(
|
|
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
|
package/lib/db-unusable.mjs
CHANGED
|
@@ -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
|
|
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.`,
|
package/lib/hook-prune.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// path, which must stay cheap and must not drag install.mjs's dependency graph in.
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
-
import {
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, dirname, isAbsolute, win32 } from 'node:path';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Identify a settings hook as one of OURS (to replace on install / strip on uninstall).
|
|
@@ -36,8 +37,13 @@ export function isMemHook(cfg, roots = null) {
|
|
|
36
37
|
// under `.claude-mem-lite`, the pre-v0.5 `claude-mem-lite`, or a checkout named after it
|
|
37
38
|
// (`claude-mem-lite-main` from a zip), with either separator. A bare substring also took
|
|
38
39
|
// `claude-mem-lite export > ~/backups/…` and `~/tools/claude-mem-lite-prompt-audit.py`, which
|
|
39
|
-
// install and uninstall then deleted.
|
|
40
|
-
|
|
40
|
+
// install and uninstall then deleted. The directory name alone still took a user's own
|
|
41
|
+
// `node ~/claude-mem-lite-notes/scripts/fmt.js` (D#479), so the path counts as the launcher's
|
|
42
|
+
// does (isOurEntryPath): its root carries the product, or it names one of our entries and is gone.
|
|
43
|
+
const ENTRY_PATH = String.raw`claude-mem-lite[\w.-]*[\\/](?:hook\.mjs|scripts[\\/][\w.-]+\.(?:mjs|js|sh))`;
|
|
44
|
+
const OUR_ENTRY_PATH_RE = new RegExp(
|
|
45
|
+
`(?:"([^"]*${ENTRY_PATH})"|([^\\s"]*${ENTRY_PATH})(?=\\s|$))(?:\\s+([\\w-]+))?`,
|
|
46
|
+
);
|
|
41
47
|
|
|
42
48
|
// The launcher as every install writes it: `<dir>/scripts/hook-launcher.mjs` followed by one of
|
|
43
49
|
// our entry shapes (`hook.mjs <event>` or `scripts/<x>.js|mjs`). Matching the bare file name took
|
|
@@ -68,6 +74,16 @@ const OUR_LAUNCHER_SCRIPTS = new Set([
|
|
|
68
74
|
'pre-agent-inject.js',
|
|
69
75
|
'pre-skill-bridge.js',
|
|
70
76
|
]);
|
|
77
|
+
// Every scripts/ entry a settings.json hook or hooks/hooks.json has run directly or through the
|
|
78
|
+
// launcher, for a path under a claude-mem-lite* directory that is gone (isOurEntryPath).
|
|
79
|
+
const OUR_ENTRY_SCRIPTS = new Set([
|
|
80
|
+
...OUR_LAUNCHER_SCRIPTS,
|
|
81
|
+
'hook-launcher.mjs',
|
|
82
|
+
'setup.sh',
|
|
83
|
+
'post-tool-use.sh',
|
|
84
|
+
'pre-agent-inject.sh',
|
|
85
|
+
'pre-tool-recall-bash.sh',
|
|
86
|
+
]);
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
89
|
* Whether an install root is the product's own: the plugin's `${CLAUDE_PLUGIN_ROOT}`, or a
|
|
@@ -85,6 +101,21 @@ function isProductRoot(root) {
|
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
// `<root>/hook.mjs <event>` or `<root>/scripts/<x>` under a claude-mem-lite* directory. Either
|
|
105
|
+
// separator, since the install it names may be a Windows one, and `~/` as the user's home.
|
|
106
|
+
function isOurEntryPath(cmd) {
|
|
107
|
+
const m = OUR_ENTRY_PATH_RE.exec(cmd);
|
|
108
|
+
if (!m) return false;
|
|
109
|
+
const raw = m[1] ?? m[2];
|
|
110
|
+
const path = /^~[\\/]/.test(raw) ? join(homedir(), raw.slice(2)) : raw;
|
|
111
|
+
if (!isAbsolute(path) && !win32.isAbsolute(path)) return false;
|
|
112
|
+
if (isProductRoot(path.replace(/[\\/](?:hook\.mjs|scripts[\\/][^\\/]+)$/, ''))) return true;
|
|
113
|
+
const ours = /[\\/]hook\.mjs$/.test(path)
|
|
114
|
+
? OUR_HOOK_EVENTS.has(m[3])
|
|
115
|
+
: OUR_ENTRY_SCRIPTS.has(path.slice(path.search(/[^\\/]+$/)));
|
|
116
|
+
return ours && !existsSync(path);
|
|
117
|
+
}
|
|
118
|
+
|
|
88
119
|
function isOurLauncher(cmd) {
|
|
89
120
|
const m = OUR_LAUNCHER_RE.exec(cmd);
|
|
90
121
|
if (!m) return false;
|
|
@@ -198,7 +229,7 @@ export function unconfirmedDeadPrefilters(settings, env = process.env) {
|
|
|
198
229
|
*/
|
|
199
230
|
export function isMemHookCommand(command, roots = null) {
|
|
200
231
|
const cmd = typeof command === 'string' ? command : '';
|
|
201
|
-
return
|
|
232
|
+
return isOurEntryPath(cmd) || isOurLauncher(cmd) || isOurPrefilter(cmd, roots);
|
|
202
233
|
}
|
|
203
234
|
|
|
204
235
|
/**
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -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) {
|
package/lib/quiet-scope.mjs
CHANGED
|
@@ -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 (
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
169
|
+
return `🧷 Invited-memory 未启用:\`${CLI_INVOKE} adopt\` 写入 CLAUDE.md 托管块;静音 \`MEM_NO_ADOPT_HINT=1\``;
|
|
167
170
|
}
|
package/lib/stats-quality.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
3687
|
-
const runAll =
|
|
3690
|
+
const run = isTrue(flags.run);
|
|
3691
|
+
const runAll = isTrue(flags['run-all']);
|
|
3688
3692
|
// Sibling-command flag footgun: optimize executes with --run (compress uses
|
|
3689
3693
|
// --execute, maintain uses positional `execute`). A bare --execute previously fell
|
|
3690
3694
|
// through to a silent preview, so the user thought they ran a mutation but didn't.
|
|
@@ -3697,7 +3701,7 @@ async function cmdOptimize(db, args) {
|
|
|
3697
3701
|
);
|
|
3698
3702
|
return;
|
|
3699
3703
|
}
|
|
3700
|
-
const verbose =
|
|
3704
|
+
const verbose = isTrue(flags.verbose) || args.includes('-v');
|
|
3701
3705
|
// T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
|
|
3702
3706
|
const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
|
|
3703
3707
|
const taskIdx = args.indexOf('--task');
|
|
@@ -3932,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: {
|
|
3937
|
-
maintain: {
|
|
3938
|
-
optimize: {
|
|
3939
|
-
delete: {
|
|
3940
|
-
'activity promote': {
|
|
3941
|
-
'activity delete': {
|
|
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. ${
|
|
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
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.30.
|
|
3
|
+
"version": "6.30.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.30.
|
|
9
|
+
"version": "6.30.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.
|
|
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
|
-
|
|
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
|
|
429
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
512
|
+
i = j + 1;
|
|
513
|
+
continue;
|
|
460
514
|
}
|
|
461
|
-
i
|
|
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
|
|
530
|
-
if (tag) b = q - tag
|
|
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 =
|
|
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) ||
|
|
913
|
+
if (!(longs >= 2 || longest >= 40 || crc || (clean && b64ish) || startsLikeKey(topCore))) return null;
|
|
801
914
|
return [top, sentence ? bottom : ee];
|
|
802
915
|
}
|
|
803
916
|
|