@kira905/dsh-memory-toolkit 0.1.3 → 0.2.0

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.en.md +225 -2
  3. package/README.md +180 -2
  4. package/lib/deploy-guard.mjs +463 -275
  5. package/lib/guard-http.mjs +59 -62
  6. package/lib/pitfall-form-map.mjs +94 -84
  7. package/lib/pitfall-score.mjs +262 -185
  8. package/lib/session-file-name.mjs +116 -100
  9. package/lib/zstd-frames.mjs +88 -67
  10. package/memory-db-tools/README.md +29 -1
  11. package/memory-db-tools/audit-analyze.mjs +1 -1
  12. package/memory-db-tools/audit-export.mjs +60 -16
  13. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  14. package/memory-db-tools/lib/known-devices.mjs +137 -0
  15. package/memory-engine/README.md +58 -4
  16. package/memory-engine/bench/bench.mjs +1 -1
  17. package/memory-engine/cli/band-sentinel.mjs +164 -0
  18. package/memory-engine/cli/distill-rollback.mjs +112 -44
  19. package/memory-engine/cli/distill-sessions.mjs +237 -0
  20. package/memory-engine/cli/distill.mjs +196 -95
  21. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  22. package/memory-engine/cli/doc-index.mjs +47 -38
  23. package/memory-engine/cli/domain-tags.mjs +246 -162
  24. package/memory-engine/cli/layer-migrate.mjs +176 -0
  25. package/memory-engine/cli/layer-move.mjs +189 -0
  26. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  27. package/memory-engine/cli/recall.mjs +243 -110
  28. package/memory-engine/cli/selfcheck.mjs +93 -44
  29. package/memory-engine/cli/serve-start.mjs +221 -140
  30. package/memory-engine/cli/sweep.mjs +83 -44
  31. package/memory-engine/cli/sync.mjs +93 -57
  32. package/memory-engine/config/audit-devices.json +6 -0
  33. package/memory-engine/config/bands.json +7 -0
  34. package/memory-engine/config/layers.json +7 -0
  35. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  36. package/memory-engine/dict/lexicon-config.json +11 -0
  37. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  38. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  39. package/memory-engine/lib/band-sentinel.mjs +417 -0
  40. package/memory-engine/lib/cli-strict.mjs +128 -0
  41. package/memory-engine/lib/cli-util.mjs +147 -125
  42. package/memory-engine/lib/config.mjs +83 -96
  43. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  44. package/memory-engine/lib/distill-rollback.mjs +173 -133
  45. package/memory-engine/lib/distill.mjs +878 -585
  46. package/memory-engine/lib/doc-index-store.mjs +208 -235
  47. package/memory-engine/lib/doc-index.mjs +351 -350
  48. package/memory-engine/lib/doc-scan.mjs +178 -156
  49. package/memory-engine/lib/doc-summarize.mjs +220 -205
  50. package/memory-engine/lib/domain-tags.mjs +358 -277
  51. package/memory-engine/lib/embedder.mjs +175 -194
  52. package/memory-engine/lib/hashes.mjs +22 -7
  53. package/memory-engine/lib/layer-migrate.mjs +330 -0
  54. package/memory-engine/lib/layer-move.mjs +307 -0
  55. package/memory-engine/lib/layers.mjs +191 -0
  56. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  57. package/memory-engine/lib/memory-source.mjs +78 -80
  58. package/memory-engine/lib/pitfall-source.mjs +115 -114
  59. package/memory-engine/lib/policy-source.mjs +279 -261
  60. package/memory-engine/lib/recall-log.mjs +285 -0
  61. package/memory-engine/lib/recall.mjs +565 -240
  62. package/memory-engine/lib/selfcheck.mjs +142 -106
  63. package/memory-engine/lib/session-distill.mjs +603 -0
  64. package/memory-engine/lib/session-source.mjs +421 -0
  65. package/memory-engine/lib/sweep.mjs +107 -130
  66. package/memory-engine/lib/sync.mjs +110 -140
  67. package/memory-engine/lib/tick-hook.mjs +123 -148
  68. package/memory-engine/lib/vectors-store.mjs +213 -188
  69. package/memory-engine/test/12-layering.test.mjs +539 -0
  70. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  71. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  72. package/memory-engine/tools/fetch-model.mjs +167 -87
  73. package/memory-engine/tools/smoke-recall.mjs +94 -38
  74. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  75. package/package.json +1 -1
@@ -0,0 +1,164 @@
1
+ // 号段哨兵入口:一条命令看「下一条新 id 会不会落到别人的号段里」。
2
+ //
3
+ // node memory-engine/cli/band-sentinel.mjs # 单机缺省:只读本机库,只打印
4
+ // node memory-engine/cli/band-sentinel.mjs --line # 只打一行
5
+ // node memory-engine/cli/band-sentinel.mjs --apply --out r.json # 落盘报告(JSON)
6
+ //
7
+ // 退出码:0 正常(含"远端未探测",只看本机)/ 2 用法或配置错 / 3 号段异常 / 4 本机库读不到 / 5 加了 --require-all 且有段未探测
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { parseCli, rejectBadArgs, usageError, settle, writeTextFile } from '../lib/cli-strict.mjs';
11
+ import { isMainEntry } from '../lib/cli-util.mjs';
12
+ import { defaultMemoryDbPath } from '../lib/layers.mjs';
13
+ import { loadBandConfig, evaluate, formatHuman, formatLine, EXIT as SENTINEL_EXIT } from '../lib/band-sentinel.mjs';
14
+
15
+ const HELP = `band-sentinel —— 记忆 id 号段哨兵(纯只读;缺省单机退化)
16
+
17
+ 用法:
18
+ node memory-engine/cli/band-sentinel.mjs 单机缺省:只读本机库、只打印
19
+ node memory-engine/cli/band-sentinel.mjs --line 只打一行(日报正文用)
20
+ node memory-engine/cli/band-sentinel.mjs --only <host[,host]> 只看某几段
21
+ node memory-engine/cli/band-sentinel.mjs --apply --out <file> 把结论落盘成 JSON 报告
22
+
23
+ 开关:
24
+ --db <path> 本机记忆库(缺省 = 引擎配置里的 memory.db)
25
+ --config <file> 号段配置(缺省 memory-engine/config/bands.json)
26
+ --only <host[,host]> 只跑这几段
27
+ --band <host>=<base> 临时改某段的基值(可重复;验收用:故意传错看它会不会点名)
28
+ --timeout <ms> 单段远端超时(缺省 20000)
29
+ --require-all 有段未探测 / 不可达也判失败(rc=5)—— 接定时/自检时用
30
+ --apply 允许把报告落盘(缺省只读 + 只打印,一个文件都不写)
31
+ --out <file> --apply 的落盘路径
32
+ --json 报告用 JSON(**只落文件**,必须与 --apply --out 同用)
33
+ --line 只打一行
34
+ --quiet 不出人话
35
+ --help
36
+
37
+ 两种模式(怎么退化):
38
+ · 单机(缺省):配置里只有一条 band ⇒ 只读本机库的 sqlite_sequence,
39
+ 判「下一条新 id」是否落在 [base, base+width);**不 ssh、不假装知道别的机器**。
40
+ · 多机(可选):配置里有多条 band 且非本机条目给了 remote(ssh 别名 + 远端 node 路径)时才去读。
41
+ 任一远端读不到 ⇒ 结论写「未探测 / 不可达」,**退出码不会是 4**(4 只留给本机库读不到)。
42
+
43
+ 判据:
44
+ · 下一条新 id 越出本段(低于下界 / 冲出上界)⇒ 红;
45
+ · 出现更高位段的行 ⇒ 红;出现更低位的别台段行 ⇒ **只披露不判红**(删行=删记忆,且不影响后续新 id);
46
+ · 本机库读不到 ⇒ rc=4(fail-closed,不算通过)。
47
+
48
+ 退出码:0 正常 · 2 用法/配置错 · 3 号段异常 · 4 本机库读不到 · 5 --require-all 且有段未探测
49
+ `;
50
+
51
+ const SPEC = {
52
+ db: 'string',
53
+ config: 'string',
54
+ only: 'string',
55
+ band: 'string',
56
+ timeout: 'number',
57
+ 'require-all': 'bool',
58
+ apply: 'bool',
59
+ out: 'string',
60
+ json: 'bool',
61
+ line: 'bool',
62
+ quiet: 'bool',
63
+ };
64
+
65
+ /** 可重复开关:`--band a=1 --band b=2` 与 `--band=a=1` 都收。 */
66
+ function collectPairs(argv, name) {
67
+ const out = [];
68
+ const eqForm = `${name}=`;
69
+ for (let i = 0; i < argv.length; i += 1) {
70
+ const token = argv[i];
71
+ if (token === name) {
72
+ const next = argv[i + 1];
73
+ if (next !== undefined && !next.startsWith('--')) out.push(next);
74
+ continue;
75
+ }
76
+ if (token.startsWith(eqForm)) out.push(token.slice(eqForm.length));
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function parseOverrides(list) {
82
+ const out = {};
83
+ for (const item of list) {
84
+ const at = item.indexOf('=');
85
+ if (at <= 0) throw new Error(`--band 的形状是 host=base,收到 ${JSON.stringify(item)}`);
86
+ const host = item.slice(0, at).trim().toLowerCase();
87
+ const base = Number(item.slice(at + 1).trim());
88
+ if (!Number.isInteger(base) || base <= 0) throw new Error(`--band ${host}=${item.slice(at + 1)} 的 base 不是正整数`);
89
+ out[host] = base;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function main() {
95
+ const argv = process.argv.slice(2);
96
+ const parsed = parseCli(argv, SPEC);
97
+ if (parsed.help) {
98
+ process.stdout.write(HELP);
99
+ return;
100
+ }
101
+ const bad = rejectBadArgs(parsed);
102
+ if (bad.length) return usageError([...bad, '(--help 看用法)']);
103
+
104
+ const f = parsed.flags;
105
+ const json = Boolean(f.json);
106
+ const apply = Boolean(f.apply);
107
+ const out = f.out ? path.resolve(f.out) : null;
108
+
109
+ if (json && !apply) return usageError('`--json` 只落文件:请与 --apply --out <file> 同用(缺省只读只打印)');
110
+ if (json && !out) return usageError('`--json` 要配 --out <file>');
111
+ if (out && !apply) return usageError('给了 --out 就要加 --apply:缺省模式一个文件都不写');
112
+
113
+ let overrides;
114
+ try {
115
+ overrides = parseOverrides(collectPairs(argv, '--band'));
116
+ } catch (e) {
117
+ return usageError(e.message);
118
+ }
119
+
120
+ let config;
121
+ try {
122
+ config = loadBandConfig({ file: f.config || null });
123
+ } catch (e) {
124
+ return usageError(`号段配置不成立:${e.message}`);
125
+ }
126
+
127
+ const dbPath = f.db ? path.resolve(f.db) : defaultMemoryDbPath();
128
+ const only = f.only ? String(f.only).split(/[\s,,]+/).map((s) => s.trim()).filter(Boolean) : null;
129
+ const report = evaluate({
130
+ config,
131
+ dbPath,
132
+ only,
133
+ overrides,
134
+ timeoutMs: Number.isFinite(f.timeout) ? f.timeout : 20000,
135
+ });
136
+
137
+ // --require-all:有段未探测就判失败。必须在渲染 / 落盘之前写回 report.exitCode,
138
+ // 否则人类结论行与 JSON 报告里的 rc 会和真实退出码不一致(只有 process.exitCode 是对的)。
139
+ if (Boolean(f['require-all']) && report.unprobed.length) report.exitCode = SENTINEL_EXIT.UNPROBED;
140
+
141
+ if (apply && out) {
142
+ writeTextFile(out, `${JSON.stringify({ ...report, dbPath, reportFile: out }, null, 2)}\n`);
143
+ }
144
+ if (!f.quiet) {
145
+ process.stdout.write(`${f.line ? `${formatLine(report)}\n` : `${formatHuman(report)}\n`}`);
146
+ if (apply && out) process.stdout.write(` 报告已落盘:${out}\n`);
147
+ }
148
+
149
+ const exit = report.exitCode;
150
+ process.exitCode = exit;
151
+ if (exit === SENTINEL_EXIT.LOCAL_UNREADABLE && !f.quiet) {
152
+ process.stderr.write(` 本机库读不到 ⇒ rc=${SENTINEL_EXIT.LOCAL_UNREADABLE}(fail-closed,不算通过)\n`);
153
+ }
154
+ if (!fs.existsSync(dbPath)) {
155
+ // 本机库不在时 evaluate 已判 LOCAL-UNREADABLE;这里只是让 --line 输出时也提醒一句
156
+ if (!f.quiet && !f.line) process.stderr.write(` 缺省库路径:${dbPath}\n`);
157
+ }
158
+ }
159
+
160
+ try {
161
+ if (isMainEntry(import.meta.url)) main();
162
+ } catch (e) {
163
+ settle(e, { prefix: '[band-sentinel]' });
164
+ }
@@ -1,86 +1,154 @@
1
1
  #!/usr/bin/env node
2
- // 记忆引擎 v2 · S3c —— 提炼产物回滚 CLI(`cli/distill-rollback.mjs`)
2
+ // 提炼产物回滚 CLI:把 `distilled…` 标签的条目按组 / 按 id 删掉,删之前先留快照 + 一键恢复脚本。
3
3
  //
4
4
  // 用法:
5
- // node cli/distill-rollback.mjs --db <memory.db> # **默认 dry-run**:只列出会被删的条
6
- // node cli/distill-rollback.mjs --db <memory.db> --apply # 真删(先 VACUUM INTO 快照 + 生成一键恢复脚本)
5
+ // node cli/distill-rollback.mjs --db <memory.db> # 缺省 dry-run:只列会被删的条
6
+ // node cli/distill-rollback.mjs --db <memory.db> --apply # 真删(先 VACUUM INTO 快照 + 生成恢复脚本)
7
7
  // node cli/distill-rollback.mjs --db <memory.db> --key <srcKey> # 只回滚某一组
8
8
  // node cli/distill-rollback.mjs --db <memory.db> --id 1001 --apply
9
9
  // node cli/distill-rollback.mjs --db <memory.db> --digest # 只算内容摘要(演练对照用)
10
10
  //
11
- // 判据:`tags LIKE 'distilled%'`(回滚面就是这个前缀——提炼产物唯一性标记)
11
+ // 回滚面 = `tags LIKE 'distilled%'` —— 这个前缀就是提炼产物的唯一性标记。
12
12
  // 退出码:0 = 正常 / 1 = 失败 / 2 = 用法错 / 3 = 环境不可用
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { isMainEntry, parseArgs, makeLogger, num } from '../lib/cli-util.mjs';
16
16
  import { listDistilled, rollbackDistilled, contentDigest, fileSha256 } from '../lib/distill-rollback.mjs';
17
17
 
18
- const USAGE = 'node cli/distill-rollback.mjs --db <memory.db> [--apply] [--key <srcKey>]... [--id <id>]... '
19
- + '[--snapshot-dir <dir>] [--limit N] [--digest [--real-only]] [--show N] [--json] [--help]';
18
+ const USAGE = [
19
+ 'node cli/distill-rollback.mjs',
20
+ '--db <memory.db>',
21
+ '[--apply]',
22
+ '[--key <srcKey>]...',
23
+ '[--id <id>]...',
24
+ '[--snapshot-dir <dir>]',
25
+ '[--limit N]',
26
+ '[--digest [--real-only]]',
27
+ '[--show N]',
28
+ '[--json]',
29
+ '[--help]',
30
+ ].join(' ');
31
+
32
+ const DISTILLED_LIKE = 'distilled%';
33
+ const DEFAULT_SHOW = 50;
34
+
35
+ /** 逗号分隔的多值开关 → 干净数组(空串与首尾空白都丢掉)。 */
36
+ function splitList(raw) {
37
+ if (raw === undefined) return [];
38
+ return String(raw)
39
+ .split(',')
40
+ .map((piece) => piece.trim())
41
+ .filter(Boolean);
42
+ }
43
+
44
+ function emit(json, payload, human, log) {
45
+ if (json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
46
+ else if (human) log(human);
47
+ }
48
+
49
+ /** 只算摘要:`--real-only` 排除派生条,判据才是「真实条一个字节没动」。 */
50
+ function runDigest({ dbPath, json, log, realOnly }) {
51
+ const d = contentDigest(dbPath, { excludeTagsLike: realOnly ? DISTILLED_LIKE : null });
52
+ const sha = fileSha256(dbPath);
53
+ const payload = { db: dbPath, realOnly: Boolean(realOnly), ...d, fileSha256: sha };
54
+ const scope = realOnly ? '真实条' : '整表';
55
+ emit(json, payload, `${scope}内容摘要:条数 ${d.count} · digest ${d.digest} · 全表条数 ${d.allCount} · 文件 sha ${sha}`, log);
56
+ return 0;
57
+ }
58
+
59
+ /** 把当前状态 + 命中清单讲清楚(人看的四行)。 */
60
+ function describeSelection({ dbPath, apply, before, items, keys, ids, log }) {
61
+ log(`库 : ${dbPath}`);
62
+ log(`模式 : ${apply ? 'APPLY(真删)' : 'DRY-RUN(只列不删)'}`);
63
+ log(`库现状 : 全表 ${before.allCount} 条(其中提炼条 ${before.allCount - before.count} 条)· 内容摘要 ${before.digest}`);
64
+ log(`命中筛选: ${items.length} 条(--key ${keys.length ? keys.join(',') : '-'} / --id ${ids.length ? ids.join(',') : '-'})`);
65
+ }
66
+
67
+ /** 真删:先快照、再删、再把删除前后摘要摆一起。 */
68
+ function runApply({ dbPath, keys, ids, snapshotDir, before, json, log }) {
69
+ const r = rollbackDistilled({
70
+ dbPath,
71
+ apply: true,
72
+ keys: keys.length ? keys : null,
73
+ ids: ids.length ? ids : null,
74
+ snapshotDir,
75
+ });
76
+ log(`已删除 : ${r.deleted} 条(id ${r.deletedIds.join(',')})`);
77
+ log(`删除前快照: ${r.snapshot}${r.snapshotSha256 ? ` · sha ${r.snapshotSha256}` : ''}`);
78
+ log(`一键恢复: ${r.rollbackFile}`);
79
+ const same = r.after.digest === before.digest;
80
+ log(`回滚后 : 全表 ${r.after.allCount} 条 · 内容摘要 ${r.after.digest}${same ? ' ✅ 与删除前一致' : ' ⚠️ 与删除前不一致'}`);
81
+ for (const warn of r.warnings ?? []) log(`⚠️ ${warn}`);
82
+ emit(json, { ok: true, ...r, fileSha256: fileSha256(dbPath) }, null, log);
83
+ return 0;
84
+ }
20
85
 
21
86
  async function main() {
22
87
  const { flags } = parseArgs();
23
88
  const json = Boolean(flags.json);
24
89
  const log = makeLogger({ json, quiet: Boolean(flags.quiet) });
25
- if (flags.help) { process.stdout.write(`用法:${USAGE}\n`); return 0; }
90
+ if (flags.help) {
91
+ process.stdout.write(`用法:${USAGE}\n`);
92
+ return 0;
93
+ }
26
94
 
27
95
  const dbPath = flags.db ? path.resolve(String(flags.db)) : null;
28
- if (!dbPath) { process.stderr.write('用法错:必须给 --db <库路径>\n'); return 2; }
29
- if (!fs.existsSync(dbPath)) { process.stderr.write(`环境不可用:库不存在 ${dbPath}\n`); return 3; }
30
-
31
- const collect = (v) => (v === undefined ? [] : String(v).split(',').map((x) => x.trim()).filter(Boolean));
32
- const keys = collect(flags.key);
33
- const ids = collect(flags.id).map((x) => Number(x)).filter((x) => Number.isFinite(x));
34
-
35
- if (flags.digest) {
36
- // `--real-only`:只算**真实(非派生)条**的内容摘要 —— 演练判据要的是「回滚后真实条一个字节没变」
37
- // (整表摘要会把被删掉的提炼条也算进去 ⇒ 对比必然"不一致",那是判据写错、不是回滚失败)
38
- const exclude = flags['real-only'] ? 'distilled%' : null;
39
- const d = contentDigest(dbPath, { excludeTagsLike: exclude });
40
- const out = { db: dbPath, realOnly: Boolean(exclude), ...d, fileSha256: fileSha256(dbPath) };
41
- if (json) process.stdout.write(JSON.stringify(out, null, 2) + '\n');
42
- else log(`${exclude ? '真实条' : '整表'}内容摘要:条数 ${d.count} · digest ${d.digest} · 全表条数 ${d.allCount} · 文件 sha ${out.fileSha256}`);
43
- return 0;
96
+ if (!dbPath) {
97
+ process.stderr.write('用法错:必须给 --db <库路径>\n');
98
+ return 2;
99
+ }
100
+ if (!fs.existsSync(dbPath)) {
101
+ process.stderr.write(`环境不可用:库不存在 ${dbPath}\n`);
102
+ return 3;
44
103
  }
45
104
 
105
+ if (flags.digest) return runDigest({ dbPath, json, log, realOnly: Boolean(flags['real-only']) });
106
+
107
+ const keys = splitList(flags.key);
108
+ const ids = splitList(flags.id).map((x) => Number(x)).filter((x) => Number.isFinite(x));
46
109
  const apply = Boolean(flags.apply);
47
110
  const before = contentDigest(dbPath);
48
- const items = listDistilled({ dbPath, keys: keys.length ? keys : null, ids: ids.length ? ids : null, limit: num(flags.limit, 0) });
111
+ const items = listDistilled({
112
+ dbPath,
113
+ keys: keys.length ? keys : null,
114
+ ids: ids.length ? ids : null,
115
+ limit: num(flags.limit, 0),
116
+ });
49
117
 
50
- log(`库 : ${dbPath}`);
51
- log(`模式 : ${apply ? 'APPLY(真删)' : 'DRY-RUN(只列不删)'}`);
52
- log(`库现状 : 全表 ${before.allCount} 条(其中提炼条 ${before.allCount - before.count} 条)· 内容摘要 ${before.digest}`);
53
- log(`命中筛选: ${items.length} 条(--key ${keys.length ? keys.join(',') : '-'} / --id ${ids.length ? ids.join(',') : '-'})`);
54
- for (const it of items.slice(0, Number(flags.show ?? 50))) log(` #${it.id} src=${it.srcKey} date=${it.date} ${it.chars} 字 · ${it.head.slice(0, 70)}`);
118
+ describeSelection({ dbPath, apply, before, items, keys, ids, log });
119
+ const show = Number(flags.show ?? DEFAULT_SHOW);
120
+ for (const it of items.slice(0, show)) log(` #${it.id} src=${it.srcKey} date=${it.date} ${it.chars} 字 · ${it.head.slice(0, 70)}`);
55
121
 
56
122
  if (!items.length) {
57
- if (json) process.stdout.write(JSON.stringify({ ok: true, matched: 0, deleted: 0, before }, null, 2) + '\n');
123
+ emit(json, { ok: true, matched: 0, deleted: 0, before }, null, log);
58
124
  return 0;
59
125
  }
60
126
  if (!apply) {
61
127
  log('(dry-run:未删除任何东西;加 --apply 才真删,删除前会自动 VACUUM INTO 快照)');
62
- if (json) process.stdout.write(JSON.stringify({ ok: true, dryRun: true, matched: items.length, ids: items.map((x) => x.id), before }, null, 2) + '\n');
128
+ emit(json, { ok: true, dryRun: true, matched: items.length, ids: items.map((x) => x.id), before }, null, log);
63
129
  return 0;
64
130
  }
65
131
 
66
- const r = rollbackDistilled({
67
- dbPath, apply: true, keys: keys.length ? keys : null, ids: ids.length ? ids : null,
132
+ return runApply({
133
+ dbPath,
134
+ keys,
135
+ ids,
68
136
  snapshotDir: flags['snapshot-dir'] ? path.resolve(String(flags['snapshot-dir'])) : null,
137
+ before,
138
+ json,
139
+ log,
69
140
  });
70
- log(`已删除 : ${r.deleted} 条(id ${r.deletedIds.join(',')})`);
71
- log(`删除前快照: ${r.snapshot}${r.snapshotSha256 ? ` · sha ${r.snapshotSha256}` : ''}`);
72
- log(`一键恢复: ${r.rollbackFile}`);
73
- log(`回滚后 : 全表 ${r.after.allCount} 条 · 内容摘要 ${r.after.digest}${r.after.digest === before.digest ? ' ✅ 与删除前一致' : ' ⚠️ 与删除前不一致'}`);
74
- for (const w of r.warnings ?? []) log(`⚠️ ${w}`);
75
- if (json) process.stdout.write(JSON.stringify({ ok: true, ...r, fileSha256: fileSha256(dbPath) }, null, 2) + '\n');
76
- return 0;
77
141
  }
78
142
 
79
143
  if (isMainEntry(import.meta.url)) {
80
- main().then((code) => { process.exitCode = code; }).catch((e) => {
81
- console.error(`未捕获异常:${e && e.stack ? e.stack : e}`);
82
- process.exitCode = 1;
83
- });
144
+ main()
145
+ .then((code) => {
146
+ process.exitCode = code;
147
+ })
148
+ .catch((e) => {
149
+ console.error(`未捕获异常:${e?.stack ?? e}`);
150
+ process.exitCode = 1;
151
+ });
84
152
  }
85
153
 
86
154
  export { main };
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // 会话日志 → 记忆条(新入口;与内核的 `cli/distill.mjs` 并存,互不影响)。
3
+ //
4
+ // 与旧入口的分工:
5
+ // 旧入口吃「库里窗口内变动的记忆」,按语义聚类再提炼;
6
+ // 本入口吃「一份会话 JSONL」,只抽能回指来源的结论句,再合成 ≤N 条写回。
7
+ // 旧入口的默认值 / 参数名 / 退出码一个都没改 —— 两边是两条独立的路。
8
+ //
9
+ // 用法:
10
+ // node memory-engine/cli/distill-sessions.mjs --source <会话.jsonl> 缺省 dry-run:只抽、只规划
11
+ // node memory-engine/cli/distill-sessions.mjs --source <会话.jsonl> --max-tokens 30000 dry-run + 预算(列出将写什么)
12
+ // node memory-engine/cli/distill-sessions.mjs --source <会话.jsonl> --db <memory.db> --max-tokens 30000 --apply
13
+ //
14
+ // 退出码:0 = 正常(**含"没配预算所以没跑"**)/ 1 = 失败 / 2 = 用法错 / 3 = 环境不可用(源文件或库不在)
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { isMainEntry } from '../lib/cli-util.mjs';
18
+ import { parseCli, rejectBadArgs, usageError, settle, emitJsonReport, EXIT } from '../lib/cli-strict.mjs';
19
+ import {
20
+ runSessionDistill, DEFAULT_LIMIT, NO_BUDGET_MESSAGE, makeRunId, runKeyOf,
21
+ } from '../lib/session-distill.mjs';
22
+ import { DEFAULT_LLM } from '../lib/distill.mjs';
23
+ import { DEFAULTS as SOURCE_DEFAULTS } from '../lib/session-source.mjs';
24
+
25
+ const HELP = `distill-sessions —— 会话日志 → 记忆(提炼的输入面;缺省 dry-run)
26
+
27
+ 用法:
28
+ node memory-engine/cli/distill-sessions.mjs --source <会话.jsonl>
29
+ node memory-engine/cli/distill-sessions.mjs --source <会话.jsonl> --max-tokens <n> [--limit 5]
30
+ node memory-engine/cli/distill-sessions.mjs --source <会话.jsonl> --db <memory.db> --max-tokens <n> --apply
31
+
32
+ 它做什么:
33
+ 一行一个事件的 .jsonl 进来 → 只挑「能回指来源的结论句」(决定 / 口径 / 偏好 / 修复方案)→
34
+ 交给模型合成 ≤N 条结论 → 写回记忆库,标签以 distilled 开头并带 sess= 与 run=session:<批次>。
35
+ 每条结论都挂着来源(场次 + 行号 + 原文锚),不写摘要、不写过程叙述。
36
+
37
+ 开关:
38
+ --source <file> 会话日志(一行一个 JSON 事件)。给 .jsonl.zstd 也行:能用 node 内置 zstd 逐帧解就解开,
39
+ 解不开会明确告诉你「这是压缩日志,先解压」(不会静默失败)
40
+ --db <path> 记忆库;--apply 时必填(无默认值:写死生产路径是明令禁止的)
41
+ --apply 真跑:调模型 → 拍快照 → 写库 → 记台账
42
+ --dry-run 显式干跑(缺省就是这个);与 --apply 同时给算用法错
43
+ --limit <n> 最多写几条结论(缺省 ${DEFAULT_LIMIT})
44
+ --max-tokens <n> 每日预算(token)。**不给、台账里也没配 ⇒ 本轮一次模型都不调**(rc=0)
45
+ 给了就在 --apply 时写进台账,往后不带这个开关也按台账里的预算走
46
+ --state <file> 台账(缺省:库同级的 butler-session-distill-state.json;没有库就没有台账)
47
+ --snapshot-dir <dir> 写库前 VACUUM INTO 的落点(缺省:库同级 snapshots/)
48
+ --run <id> 批次 id(缺省自动生成)。撤回就按这一批:整批一个来源键
49
+ --owner-user <id> 库里有 owner_user 列时写它(缺省 local)
50
+ --session-id <id> 日志里没有 type=session 事件时,用它补来源 id
51
+ --max-chars <n> 单句长度上限(缺省 ${SOURCE_DEFAULTS.maxChars};超了跳过并计数)
52
+ --model <name> 模型名(缺省 ${DEFAULT_LLM.model})
53
+ --base-url <url> 模型服务地址(缺省 ${DEFAULT_LLM.baseUrl})
54
+ --now <iso> 把某个时刻当"现在"(演练 / 复现用)
55
+ --json 机器可读报告落文件(**必须配 --out,且不上 stdout**)
56
+ --out <file> --json 的落盘路径
57
+ --quiet 只打最终那几行
58
+ --help
59
+
60
+ 提炼的边界(三条,别指望它干第四件):
61
+ · 不做摘要式写入:过程叙述、寒暄、疑问、推测、工具输出一律不进库;
62
+ · 只写能回指来源的结论条:每条都带场次 / 行号 / 原文锚,回不去原文的宁可不写;
63
+ · 没配每日预算就不跑:不继承任何缺省额度,也不"没配=无限跑"。
64
+
65
+ 撤回:
66
+ node memory-engine/cli/distill-rollback.mjs --db <memory.db> --key <批次键> --apply
67
+ (批次键 = 本轮打印的 src=,一批一个键 ⇒ 一条命令整批撤掉;撤前会先拍快照)
68
+
69
+ 退出码:0 正常(含"没配预算 / 没候选 / 已产出过")/ 1 失败 / 2 用法错 / 3 环境不可用
70
+ `;
71
+
72
+ const SPEC = {
73
+ source: 'string',
74
+ db: 'string',
75
+ apply: 'bool',
76
+ 'dry-run': 'bool',
77
+ limit: 'number',
78
+ 'max-tokens': 'number',
79
+ state: 'string',
80
+ 'snapshot-dir': 'string',
81
+ run: 'string',
82
+ 'owner-user': 'string',
83
+ 'session-id': 'string',
84
+ 'max-chars': 'number',
85
+ model: 'string',
86
+ 'base-url': 'string',
87
+ now: 'string',
88
+ json: 'bool',
89
+ out: 'string',
90
+ quiet: 'bool',
91
+ };
92
+
93
+ /** 台账缺省落点:库同级(没有库就没有台账 —— 别偷偷往生产路径写)。 */
94
+ export function defaultStateFile(dbPath) {
95
+ if (!dbPath) return null;
96
+ return path.join(path.dirname(path.resolve(dbPath)), 'butler-session-distill-state.json');
97
+ }
98
+
99
+ export function parseCliArgs(argv) {
100
+ const parsed = parseCli(argv, SPEC);
101
+ return { parsed, bad: rejectBadArgs(parsed) };
102
+ }
103
+
104
+ export async function main(argv = process.argv.slice(2)) {
105
+ const { parsed } = parseCliArgs(argv);
106
+ if (parsed.help) {
107
+ process.stdout.write(`${HELP}\n`);
108
+ return EXIT.OK;
109
+ }
110
+ const bad = rejectBadArgs(parsed);
111
+ if (bad.length) {
112
+ usageError([...bad, '', '先 --help 看一眼可用开关。']);
113
+ return EXIT.USAGE;
114
+ }
115
+ const flags = parsed.flags;
116
+ const say = (line) => { if (!flags.quiet) process.stdout.write(`${line}\n`); };
117
+
118
+ if (flags.apply && flags['dry-run']) {
119
+ usageError(['--apply 与 --dry-run 不能同时给:缺省就是 dry-run,要真写只加 --apply。']);
120
+ return EXIT.USAGE;
121
+ }
122
+ if (!flags.source) {
123
+ usageError(['缺少 --source <会话.jsonl>:没有输入就没什么可提炼的。']);
124
+ return EXIT.USAGE;
125
+ }
126
+ if (flags.apply && !flags.db) {
127
+ usageError(['--apply 必须配 --db <memory.db>:本工具不会猜你的库在哪。']);
128
+ return EXIT.USAGE;
129
+ }
130
+ if (flags.json && !flags.out) {
131
+ usageError(['--json 要配 --out <file>:机器可读内容只落文件,不上 stdout。']);
132
+ return EXIT.USAGE;
133
+ }
134
+ if (flags.db && !fs.existsSync(flags.db) && flags.apply) {
135
+ process.stderr.write(`❌ 记忆库不存在:${flags.db}\n`);
136
+ return EXIT.UNREADABLE;
137
+ }
138
+ if (flags.now && Number.isNaN(Date.parse(flags.now))) {
139
+ usageError([`--now 不是可解析的时间:${flags.now}`]);
140
+ return EXIT.USAGE;
141
+ }
142
+
143
+ const now = flags.now ? Date.parse(flags.now) : Date.now();
144
+ const apply = Boolean(flags.apply);
145
+ const dbPath = flags.db ? path.resolve(flags.db) : null;
146
+ const stateFile = flags.state ? path.resolve(flags.state) : defaultStateFile(dbPath);
147
+ const runId = flags.run ? String(flags.run) : makeRunId(now);
148
+
149
+ say(`[distill-sessions] ${apply ? '真跑' : '干跑(dry-run)'} · 批次 ${runId} · 来源 ${flags.source}`);
150
+ const result = await runSessionDistill({
151
+ source: path.resolve(flags.source),
152
+ dbPath,
153
+ apply,
154
+ limit: flags.limit ?? DEFAULT_LIMIT,
155
+ maxTokens: flags['max-tokens'] ?? null,
156
+ maxChars: flags['max-chars'] ?? SOURCE_DEFAULTS.maxChars,
157
+ stateFile,
158
+ snapshotDir: flags['snapshot-dir'] ? path.resolve(flags['snapshot-dir']) : null,
159
+ runId,
160
+ ownerUser: flags['owner-user'] ?? null,
161
+ sessionId: flags['session-id'] ?? null,
162
+ model: flags.model ?? DEFAULT_LLM.model,
163
+ baseUrl: flags['base-url'] ?? DEFAULT_LLM.baseUrl,
164
+ now,
165
+ });
166
+
167
+ const ext = result.extraction;
168
+ if (ext) {
169
+ say(`[distill-sessions] 抽句:${ext.lines} 行 / ${ext.events} 个事件 ⇒ 候选 ${ext.candidates} 条(挡下:${ext.skippedLine})`);
170
+ }
171
+ if (result.source?.compressed) say(`[distill-sessions] ${result.source.note}`);
172
+ for (const warning of result.warnings ?? []) say(`[distill-sessions] ⚠️ ${warning}`);
173
+
174
+ // 结论类几行**不受 --quiet 影响**:--quiet 只压过程日志,压掉结论等于不告诉你跑没跑。
175
+ const emit = (line) => process.stdout.write(`${line}\n`);
176
+
177
+ if (result.stopping === 'NO_BUDGET') {
178
+ emit(`[distill-sessions] ${NO_BUDGET_MESSAGE}`);
179
+ say(`[distill-sessions] 怎么配:给 --max-tokens <n>(真跑时会写进台账),或先一次 --dry-run 看估算 token。`);
180
+ } else if (result.stopping === 'BUDGET_SPENT' || result.stopping === 'ESTIMATE_OVER') {
181
+ emit(`[distill-sessions] ${result.message}`);
182
+ } else if (result.stopping === 'SOURCE_UNREADABLE') {
183
+ process.stderr.write(`❌ ${result.message}\n`);
184
+ } else if (result.message) {
185
+ emit(`[distill-sessions] ${result.message}`);
186
+ }
187
+
188
+ if (result.planned?.length) {
189
+ say('');
190
+ say(`将写(最多 ${flags.limit ?? DEFAULT_LIMIT} 条)${apply ? '—— 已写' : '—— dry-run 不会写'}:`);
191
+ for (const item of result.planned) {
192
+ if (item.conclusion) {
193
+ say(` ${item.index}. [L${item.lines?.join(',') ?? '?'}] ${item.conclusion}`);
194
+ for (const anchor of item.anchors ?? []) say(` 锚:L${anchor.line} ${anchor.anchor}`);
195
+ } else {
196
+ say(` ${item.index}. [${item.kind} · L${item.line}] ${item.text}`);
197
+ say(` 锚:${item.anchor}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ if (result.budget) {
203
+ const b = result.budget;
204
+ const budgetLine = b.tokens === null ? '未配置' : `${b.spent}/${b.tokens} token(剩余 ${b.remaining},来源 ${b.source})`;
205
+ say('');
206
+ say(`预算:${budgetLine}${result.tokens ? ` · 本轮估算 ${result.tokens.estimated} token` : ''}`);
207
+ }
208
+ if (result.model) {
209
+ say(`模型:${result.model.model} · ${result.model.ok ? 'ok' : `失败:${result.model.error}`} · ${result.model.ms}ms · `
210
+ + `${result.model.promptTokens}+${result.model.completionTokens} token`);
211
+ }
212
+ if (apply && result.written) {
213
+ emit(`写回:${result.written} 条(跳过已存在 ${result.skippedExisting ?? 0} 条)· 快照 ${result.snapshot}`);
214
+ emit(`撤回:node memory-engine/cli/distill-rollback.mjs --db ${dbPath} --key ${result.srcKey} --apply`);
215
+ }
216
+ emit(`[distill-sessions] 结论:${result.stopping ?? 'OK'}${result.exitCode ? `(rc=${result.exitCode})` : ''}`);
217
+
218
+ if (flags.json) {
219
+ const payload = {
220
+ ...result,
221
+ at: new Date(now).toISOString(),
222
+ srcKey: result.srcKey,
223
+ srcKeyFull: runKeyOf(runId),
224
+ candidates: result.planned,
225
+ };
226
+ delete payload.warningsText;
227
+ const written2 = emitJsonReport({ json: true, out: flags.out, payload });
228
+ say(`JSON → ${written2}`);
229
+ }
230
+ return result.exitCode ?? EXIT.OK;
231
+ }
232
+
233
+ if (isMainEntry(import.meta.url)) {
234
+ main()
235
+ .then((code) => { process.exitCode = code ?? 0; })
236
+ .catch((failure) => settle(failure, { prefix: '[distill-sessions]' }));
237
+ }