@kira905/dsh-memory-toolkit 0.1.2 → 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 (76) hide show
  1. package/CHANGELOG.md +130 -1
  2. package/README.en.md +225 -2
  3. package/README.md +208 -2
  4. package/cordis.patch.yml +32 -0
  5. package/lib/deploy-guard.mjs +463 -275
  6. package/lib/guard-http.mjs +59 -62
  7. package/lib/pitfall-form-map.mjs +94 -84
  8. package/lib/pitfall-score.mjs +262 -185
  9. package/lib/session-file-name.mjs +116 -100
  10. package/lib/zstd-frames.mjs +88 -67
  11. package/memory-db-tools/README.md +29 -1
  12. package/memory-db-tools/audit-analyze.mjs +1 -1
  13. package/memory-db-tools/audit-export.mjs +60 -16
  14. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  15. package/memory-db-tools/lib/known-devices.mjs +137 -0
  16. package/memory-engine/README.md +58 -4
  17. package/memory-engine/bench/bench.mjs +1 -1
  18. package/memory-engine/cli/band-sentinel.mjs +164 -0
  19. package/memory-engine/cli/distill-rollback.mjs +112 -44
  20. package/memory-engine/cli/distill-sessions.mjs +237 -0
  21. package/memory-engine/cli/distill.mjs +196 -95
  22. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  23. package/memory-engine/cli/doc-index.mjs +47 -38
  24. package/memory-engine/cli/domain-tags.mjs +246 -162
  25. package/memory-engine/cli/layer-migrate.mjs +176 -0
  26. package/memory-engine/cli/layer-move.mjs +189 -0
  27. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  28. package/memory-engine/cli/recall.mjs +243 -110
  29. package/memory-engine/cli/selfcheck.mjs +93 -44
  30. package/memory-engine/cli/serve-start.mjs +221 -140
  31. package/memory-engine/cli/sweep.mjs +83 -44
  32. package/memory-engine/cli/sync.mjs +93 -57
  33. package/memory-engine/config/audit-devices.json +6 -0
  34. package/memory-engine/config/bands.json +7 -0
  35. package/memory-engine/config/layers.json +7 -0
  36. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  37. package/memory-engine/dict/lexicon-config.json +11 -0
  38. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  39. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  40. package/memory-engine/lib/band-sentinel.mjs +417 -0
  41. package/memory-engine/lib/cli-strict.mjs +128 -0
  42. package/memory-engine/lib/cli-util.mjs +147 -125
  43. package/memory-engine/lib/config.mjs +83 -96
  44. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  45. package/memory-engine/lib/distill-rollback.mjs +173 -133
  46. package/memory-engine/lib/distill.mjs +878 -585
  47. package/memory-engine/lib/doc-index-store.mjs +208 -235
  48. package/memory-engine/lib/doc-index.mjs +351 -350
  49. package/memory-engine/lib/doc-scan.mjs +178 -156
  50. package/memory-engine/lib/doc-summarize.mjs +220 -205
  51. package/memory-engine/lib/domain-tags.mjs +358 -277
  52. package/memory-engine/lib/embedder.mjs +175 -194
  53. package/memory-engine/lib/hashes.mjs +22 -7
  54. package/memory-engine/lib/layer-migrate.mjs +330 -0
  55. package/memory-engine/lib/layer-move.mjs +307 -0
  56. package/memory-engine/lib/layers.mjs +191 -0
  57. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  58. package/memory-engine/lib/memory-source.mjs +78 -80
  59. package/memory-engine/lib/pitfall-source.mjs +115 -114
  60. package/memory-engine/lib/policy-source.mjs +279 -261
  61. package/memory-engine/lib/recall-log.mjs +285 -0
  62. package/memory-engine/lib/recall.mjs +565 -240
  63. package/memory-engine/lib/selfcheck.mjs +142 -106
  64. package/memory-engine/lib/session-distill.mjs +603 -0
  65. package/memory-engine/lib/session-source.mjs +421 -0
  66. package/memory-engine/lib/sweep.mjs +107 -130
  67. package/memory-engine/lib/sync.mjs +110 -140
  68. package/memory-engine/lib/tick-hook.mjs +123 -148
  69. package/memory-engine/lib/vectors-store.mjs +213 -188
  70. package/memory-engine/test/12-layering.test.mjs +539 -0
  71. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  72. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  73. package/memory-engine/tools/fetch-model.mjs +167 -87
  74. package/memory-engine/tools/smoke-recall.mjs +94 -38
  75. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  76. package/package.json +11 -1
@@ -0,0 +1,176 @@
1
+ // 给记忆库加 `layer` 字段的入口。默认 dry-run;动 schema 必须先备份。
2
+ //
3
+ // node memory-engine/cli/layer-migrate.mjs # 干跑:只说会做什么
4
+ // node memory-engine/cli/layer-migrate.mjs --db <库> --apply --backup <备份路径>
5
+ // node memory-engine/cli/layer-migrate.mjs --db <库> --restore <备份路径> # 干跑还原
6
+ // node memory-engine/cli/layer-migrate.mjs --db <库> --restore <备份路径> --apply
7
+ // node memory-engine/cli/layer-migrate.mjs --help
8
+ //
9
+ // 退出码:0 正常 · 1 迁移/复核失败 · 2 用法错 · 3 库不可读
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parseCli, rejectBadArgs, usageError, settle, emitJsonReport, EXIT } from '../lib/cli-strict.mjs';
13
+ import { isMainEntry } from '../lib/cli-util.mjs';
14
+ import { loadLayerConfig, defaultMemoryDbPath, describeConfig, splitNames } from '../lib/layers.mjs';
15
+ import { buildPlan, applyMigration, restoreDatabase } from '../lib/layer-migrate.mjs';
16
+
17
+ const HELP = `layer-migrate —— 给记忆库加「层」字段(默认干跑;动 schema 前必先备份)
18
+
19
+ 用法:
20
+ node memory-engine/cli/layer-migrate.mjs [--db <库>] 干跑:只说会做什么
21
+ node memory-engine/cli/layer-migrate.mjs --db <库> --apply --backup <文件> 真加列 + 回填
22
+ node memory-engine/cli/layer-migrate.mjs --db <库> --restore <备份> 干跑还原
23
+ node memory-engine/cli/layer-migrate.mjs --db <库> --restore <备份> --apply 真还原
24
+ node memory-engine/cli/layer-migrate.mjs --help
25
+
26
+ 开关:
27
+ --db <path> 目标记忆库(干跑缺省 = 引擎配置里的 memory.db;--apply 必须显式给)
28
+ --apply 真写(缺省 dry-run,一个字节都不动)
29
+ --dry-run 显式声明干跑(缺省就是它;与 --apply 互斥)
30
+ --backup <path> --apply 必填:动库前先把库文件备份到这里(已存在则拒写,不覆盖旧备份)
31
+ --restore <path> 从备份还原(默认干跑;配 --apply 才替换库文件)
32
+ --column <name> 层列名(缺省取配置,内置 layer)
33
+ --layers <a,b,c> 层名集合(缺省取配置,内置 core,recall,archival)
34
+ --default-layer <name> 存量行回填到哪一层(缺省取配置,内置 recall;必须在层名集合里)
35
+ --config <file> 换一份层配置(缺省 memory-engine/config/layers.json)
36
+ --json 机器可读摘要(**只落文件**,必须配 --out)
37
+ --out <file> --json 的落点
38
+ --quiet 少说两句
39
+ --help
40
+
41
+ 语义:
42
+ · 只加列 + 回填,**一行不删**:行数 / sqlite_sequence / integrity_check 三项改完复核;
43
+ · 幂等:列已在、空值已清零 ⇒ 第二次 --apply 一个字节都不写;
44
+ · 跨盘安全:备份走「写新文件 + sha 校验」,还原走「同目录临时文件 + rename」,不用跨盘 rename。
45
+
46
+ 退出码:0 正常 · 1 迁移或复核失败 · 2 用法错 · 3 库不可读
47
+ `;
48
+
49
+ const SPEC = {
50
+ db: 'string',
51
+ apply: 'bool',
52
+ 'dry-run': 'bool',
53
+ backup: 'string',
54
+ restore: 'string',
55
+ column: 'string',
56
+ layers: 'string',
57
+ 'default-layer': 'string',
58
+ config: 'string',
59
+ json: 'bool',
60
+ out: 'string',
61
+ quiet: 'bool',
62
+ };
63
+
64
+ function printPlan(plan, cfg) {
65
+ const say = (line) => process.stdout.write(`${line}\n`);
66
+ say(`库:${plan.dbPath}${plan.info.exists ? `(${plan.info.bytes} B · ${plan.info.rowCount} 行)` : ''}`);
67
+ say(`层配置:${describeConfig(cfg)}`);
68
+ if (plan.blockers.length) {
69
+ for (const b of plan.blockers) say(` ❌ ${b}`);
70
+ return;
71
+ }
72
+ say(`列 ${cfg.column}:${plan.hasColumn ? '已在' : '不在 ⇒ 将新增(NOT NULL DEFAULT ' + cfg.defaultLayer + ')'}`);
73
+ say(`待回填(层为空)行数:${plan.emptyLayerRows}`);
74
+ if (plan.hasColumn && Object.keys(plan.histogram).length) {
75
+ const hist = Object.entries(plan.histogram).map(([k, v]) => `${k}=${v}`).join(' · ');
76
+ say(`当前层分布:${hist}`);
77
+ }
78
+ if (Object.keys(plan.outOfSet).length) {
79
+ say(`⚠️ 有行落在层名集合之外(不改它们,只报告):${Object.entries(plan.outOfSet).map(([k, v]) => `${k}=${v}`).join(' · ')}`);
80
+ }
81
+ say(`整库完整性:${plan.integrity ?? '(无)'} · seq=${plan.seq}`);
82
+ say(
83
+ plan.needsAlter || plan.needsBackfill
84
+ ? `计划:${plan.needsAlter ? '加列' : ''}${plan.needsAlter && plan.needsBackfill ? ' + ' : ''}${plan.needsBackfill ? `回填 ${plan.emptyLayerRows} 行到「${cfg.defaultLayer}」` : ''}(dry-run:未写任何东西)`
85
+ : '计划:无事可做(列已在、没有空层行)⇒ 再跑 --apply 也不会写',
86
+ );
87
+ }
88
+
89
+ function printRestore(plan) {
90
+ const say = (line) => process.stdout.write(`${line}\n`);
91
+ say(`备份:${plan.backup.path}(${plan.backup.bytes} B · ${plan.backup.rows} 行 · sha ${String(plan.backup.sha256).slice(0, 16)})`);
92
+ say(`目标:${plan.target.path}${plan.target.missing ? '(不存在,将新建)' : `(${plan.target.bytes} B · ${plan.target.rows} 行 · sha ${String(plan.target.sha256).slice(0, 16)})`}`);
93
+ if (plan.sidecars.length) say(`⚠️ 目标旁有 ${plan.sidecars.join(' ')} —— 还原时会先摘掉(陈旧 WAL 配新库文件 = 直接损坏)`);
94
+ say(plan.applied ? `✅ 已还原(sha ${String(plan.verify.sha256).slice(0, 16)} · ${plan.verify.rows} 行 · integrity ${plan.verify.integrity})` : 'dry-run:未替换任何文件(加 --apply 才写)');
95
+ }
96
+
97
+ function main() {
98
+ const parsed = parseCli(process.argv.slice(2), SPEC);
99
+ if (parsed.help) {
100
+ process.stdout.write(HELP);
101
+ return;
102
+ }
103
+ const bad = rejectBadArgs(parsed);
104
+ if (bad.length) return usageError([...bad, '(--help 看用法)']);
105
+
106
+ const f = parsed.flags;
107
+ if (f.apply && f['dry-run']) return usageError('--apply 与 --dry-run 不能同时给(缺省就是 dry-run)');
108
+ const json = Boolean(f.json);
109
+ const quiet = Boolean(f.quiet);
110
+ const say = (line) => {
111
+ if (!quiet) process.stdout.write(`${line}\n`);
112
+ };
113
+
114
+ let cfg;
115
+ try {
116
+ cfg = loadLayerConfig({ file: f.config || null, layers: f.layers || null, defaultLayer: f['default-layer'] || null, column: f.column || null });
117
+ } catch (e) {
118
+ return usageError(`层配置不成立:${e.message}`);
119
+ }
120
+ for (const w of cfg.warnings) say(`⚠️ ${w}`);
121
+
122
+ const dbPath = f.db ? path.resolve(f.db) : defaultMemoryDbPath();
123
+ // 先把手滑闸摆好:真写必须点名库、且(迁移时)必须带备份
124
+ if (f.apply && !f.db) return usageError('--apply 必须显式给 --db <库>:不许靠缺省路径改到家目录那本库');
125
+ if (f.apply && !f.restore && !f.backup) return usageError('--apply 必须先备份:加 --backup <文件>(拒绝无备份写入)');
126
+ if (!fs.existsSync(dbPath)) {
127
+ process.stderr.write(`❌ 库不可读:${dbPath} 不存在\n`);
128
+ process.exitCode = EXIT.UNREADABLE;
129
+ return;
130
+ }
131
+
132
+ // ── 还原分支
133
+ if (f.restore) {
134
+ if (!fs.existsSync(f.restore)) {
135
+ process.stderr.write(`❌ 备份不存在:${path.resolve(f.restore)}\n`);
136
+ process.exitCode = EXIT.UNREADABLE;
137
+ return;
138
+ }
139
+ const plan = restoreDatabase({ backupPath: f.restore, dbPath, apply: Boolean(f.apply) });
140
+ if (!quiet) printRestore(plan);
141
+ emitJsonReport({ json, out: f.out, payload: { tool: 'layer-migrate', mode: 'restore', db: dbPath, ...plan } });
142
+ process.exitCode = EXIT.OK;
143
+ return;
144
+ }
145
+
146
+ // ── 迁移分支
147
+ const plan = buildPlan(dbPath, cfg);
148
+ if (plan.blockers.length) {
149
+ for (const b of plan.blockers) process.stderr.write(`❌ ${b}\n`);
150
+ process.exitCode = EXIT.UNREADABLE;
151
+ return;
152
+ }
153
+ if (!quiet) printPlan(plan, cfg);
154
+ if (!f.apply) {
155
+ emitJsonReport({ json, out: f.out, payload: { tool: 'layer-migrate', mode: 'dry-run', db: dbPath, cfg: { column: cfg.column, layers: cfg.layers, defaultLayer: cfg.defaultLayer }, plan: { ...plan, info: undefined } } });
156
+ process.exitCode = EXIT.OK;
157
+ return;
158
+ }
159
+
160
+ const result = applyMigration({ dbPath, cfg, backupPath: f.backup });
161
+ if (!quiet) {
162
+ if (!result.changed) say('✅ 无事可做(幂等:列已在、没有空层行)—— 一个字节都没写');
163
+ else {
164
+ say(`✅ 已加列/回填:动 ${result.touched} 行 · 备份 ${result.backup.method} → ${result.backup.path}(${result.backup.bytes} B · sha ${result.backup.sha256.slice(0, 16)})`);
165
+ say(` 复核:行数不变 ${result.verify.rowCountSame} · seq 不变 ${result.verify.seqSame} · 完整性 ${result.verify.integrity} · 空层行 ${result.verify.emptyAfter}`);
166
+ }
167
+ }
168
+ emitJsonReport({ json, out: f.out, payload: { tool: 'layer-migrate', mode: 'apply', db: dbPath, changed: result.changed, touched: result.touched ?? 0, backup: result.backup, verify: result.verify } });
169
+ process.exitCode = EXIT.OK;
170
+ }
171
+
172
+ try {
173
+ if (isMainEntry(import.meta.url)) main();
174
+ } catch (e) {
175
+ settle(e, { prefix: '[layer-migrate]' });
176
+ }
@@ -0,0 +1,189 @@
1
+ // 软淘汰执行器入口:把一整层搬进本库的边表(不删),一条命令出去、一条命令回来。
2
+ //
3
+ // node memory-engine/cli/layer-move.mjs --db <库> --layer archival 干跑:说会搬几行
4
+ // node memory-engine/cli/layer-move.mjs --db <库> --layer archival --apply 真搬(主表 → 边表)
5
+ // node memory-engine/cli/layer-move.mjs --db <库> --layer archival --back --apply 搬回(边表 → 主表)
6
+ // node memory-engine/cli/layer-move.mjs --db <库> --dump <before.json> 搬前留一份逐行指纹
7
+ // node memory-engine/cli/layer-move.mjs --db <库> --verify <before.json> 搬回后逐行对拍
8
+ //
9
+ // 退出码:0 正常 · 1 校验失败 / 保护性拒跑 · 2 用法错 · 3 库不可读
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parseCli, rejectBadArgs, usageError, settle, emitJsonReport, EXIT } from '../lib/cli-strict.mjs';
13
+ import { isMainEntry } from '../lib/cli-util.mjs';
14
+ import { loadLayerConfig, defaultMemoryDbPath, canonicalLayerName, describeConfig } from '../lib/layers.mjs';
15
+ import {
16
+ planMove, moveOut, moveBack, dumpDigests, verifyAgainstDump, writeDump, readDump, EVICTED_TABLE,
17
+ } from '../lib/layer-move.mjs';
18
+
19
+ const HELP = `layer-move —— 软淘汰:整层搬走(不删),一条命令出去、一条命令回来
20
+
21
+ 用法:
22
+ node memory-engine/cli/layer-move.mjs --db <库> --layer <层> 干跑:说会搬几行(零写)
23
+ node memory-engine/cli/layer-move.mjs --db <库> --layer <层> --apply 搬出:主表 → ${EVICTED_TABLE}
24
+ node memory-engine/cli/layer-move.mjs --db <库> --layer <层> --back --apply 搬回:${EVICTED_TABLE} → 主表
25
+ node memory-engine/cli/layer-move.mjs --db <库> --dump <file> 导出逐行指纹(搬前凭据)
26
+ node memory-engine/cli/layer-move.mjs --db <库> --verify <file> 拿指纹件对拍当前主表
27
+ node memory-engine/cli/layer-move.mjs --help
28
+
29
+ 开关:
30
+ --db <path> 目标记忆库(--apply 必须显式给;干跑缺省 = 引擎配置里的 memory.db)
31
+ --layer <name> 要搬出/搬回的层(必须在层名集合里)
32
+ --apply 真写(缺省 dry-run:一个字节都不动)
33
+ --dry-run 显式声明干跑(缺省就是它;与 --apply 互斥)
34
+ --back 反向:把这一层从边表搬回主表
35
+ --force 搬走会清空主表时才需要的显式确认(防层名写错)
36
+ --dump <file> 把当前主表(或 --layer 限定的那层)的逐行指纹写成 JSON
37
+ --verify <file> 拿 --dump 的产物对拍:缺行 / 多行 / 内容变了 三类分开报
38
+ --batch <name> 批次名(缺省 evict-<时间戳>),落在边表的 batch 列
39
+ --column <name> 层列名(缺省取配置)
40
+ --layers <a,b,c> 层名集合(缺省取配置)
41
+ --config <file> 换一份层配置
42
+ --json 机器可读摘要(**只落文件**,必须配 --out)
43
+ --out <file> --json 的落点
44
+ --quiet 少说两句
45
+ --help
46
+
47
+ 语义(别把它当删除):
48
+ · 搬走的行原样躺在 ${EVICTED_TABLE} 里,带 content_sha 指纹;搬回时先核指纹再写回。
49
+ · 硬判据:搬出层行数 + 剩下行数 == 搬前总行数(事务内断言,不成立整批回滚)。
50
+ · 软淘汰省的是上下文与召回面,不是磁盘:搬走的行仍占库体积,边表也没有 FTS 索引。
51
+
52
+ 退出码:0 正常 · 1 校验失败或保护性拒跑 · 2 用法错 · 3 库不可读
53
+ `;
54
+
55
+ const SPEC = {
56
+ db: 'string',
57
+ layer: 'string',
58
+ apply: 'bool',
59
+ 'dry-run': 'bool',
60
+ back: 'bool',
61
+ force: 'bool',
62
+ dump: 'string',
63
+ verify: 'string',
64
+ batch: 'string',
65
+ column: 'string',
66
+ layers: 'string',
67
+ config: 'string',
68
+ json: 'bool',
69
+ out: 'string',
70
+ quiet: 'bool',
71
+ };
72
+
73
+ const stamp = () => {
74
+ const d = new Date();
75
+ const p = (n) => String(n).padStart(2, '0');
76
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
77
+ };
78
+
79
+ function main() {
80
+ const parsed = parseCli(process.argv.slice(2), SPEC);
81
+ if (parsed.help) {
82
+ process.stdout.write(HELP);
83
+ return;
84
+ }
85
+ const bad = rejectBadArgs(parsed);
86
+ if (bad.length) return usageError([...bad, '(--help 看用法)']);
87
+
88
+ const f = parsed.flags;
89
+ if (f.apply && f['dry-run']) return usageError('--apply 与 --dry-run 不能同时给(缺省就是 dry-run)');
90
+ const say = (line) => {
91
+ if (!f.quiet) process.stdout.write(`${line}\n`);
92
+ };
93
+ const modes = ['dump', 'verify', 'back'].filter((k) => f[k]);
94
+ if (modes.length > 1) return usageError(`一次只能干一件事:${modes.map((m) => `--${m}`).join(' / ')} 互斥`);
95
+
96
+ let cfg;
97
+ try {
98
+ cfg = loadLayerConfig({ file: f.config || null, layers: f.layers || null, column: f.column || null });
99
+ } catch (e) {
100
+ return usageError(`层配置不成立:${e.message}`);
101
+ }
102
+ for (const w of cfg.warnings) say(`⚠️ ${w}`);
103
+
104
+ const dbPath = f.db ? path.resolve(f.db) : defaultMemoryDbPath();
105
+ if (!fs.existsSync(dbPath)) {
106
+ process.stderr.write(`❌ 库不可读:${dbPath} 不存在\n`);
107
+ process.exitCode = EXIT.UNREADABLE;
108
+ return;
109
+ }
110
+
111
+ // ── 导出指纹
112
+ if (f.dump) {
113
+ const dump = dumpDigests({ dbPath, cfg, layer: f.layer ? canonicalLayerName(cfg, f.layer) : null });
114
+ const out = writeDump(f.dump, dump);
115
+ say(`✅ 指纹已导出:${out}(${dump.count} 行 · 层=${dump.layer ?? '(全表)'})`);
116
+ emitJsonReport({ json: f.json, out: f.out, payload: { tool: 'layer-move', mode: 'dump', db: dbPath, layer: dump.layer, count: dump.count, file: out } });
117
+ process.exitCode = EXIT.OK;
118
+ return;
119
+ }
120
+
121
+ // ── 对拍指纹
122
+ if (f.verify) {
123
+ const dump = readDump(f.verify);
124
+ const res = verifyAgainstDump({ dbPath, cfg, dump });
125
+ say(`指纹对拍:核 ${res.checked} 行 · 当前 ${res.currentCount} 行`);
126
+ if (res.ok) {
127
+ say('✅ 逐行一致(缺 0 / 多 0 / 变 0)');
128
+ } else {
129
+ say(`❌ 不一致:缺 [${res.missing.join(',')}] 多 [${res.extra.join(',')}] 变 [${res.changed.join(',')}]`);
130
+ }
131
+ emitJsonReport({ json: f.json, out: f.out, payload: { tool: 'layer-move', mode: 'verify', db: dbPath, ...res } });
132
+ process.exitCode = res.ok ? EXIT.OK : EXIT.FAIL;
133
+ return;
134
+ }
135
+
136
+ if (!f.layer) return usageError('缺 --layer <层>:要搬哪一层(--help 看层名集合从哪来)');
137
+ const layer = canonicalLayerName(cfg, f.layer);
138
+ if (!layer) return usageError(`「${f.layer}」不在层名集合 [${cfg.layers.join(', ')}] 里(--layers 可改,或改配置)`);
139
+
140
+ // ── 搬回
141
+ if (f.back) {
142
+ if (!f.apply) {
143
+ const plan = planMove({ dbPath, cfg, layer });
144
+ say(`干跑(搬回):层「${layer}」在边表里有 ${plan.evictedRows} 行 · 主表 ${plan.totalRows} 行`);
145
+ say('dry-run:未写任何东西(加 --apply 才搬)');
146
+ emitJsonReport({ json: f.json, out: f.out, payload: { tool: 'layer-move', mode: 'back-dry-run', db: dbPath, layer, evicted: plan.evictedRows, total: plan.totalRows } });
147
+ process.exitCode = EXIT.OK;
148
+ return;
149
+ }
150
+ const res = moveBack({ dbPath, cfg, layer });
151
+ say(`✅ 搬回 ${res.restored} 行(主表 ${res.totalBefore} → ${res.totalAfter})${res.reason ? ` · ${res.reason}` : ''}`);
152
+ if (res.seqBefore !== undefined && res.seqAfter !== res.seqBefore) say(`⚠️ sqlite_sequence 变了:${res.seqBefore} → ${res.seqAfter}`);
153
+ emitJsonReport({ json: f.json, out: f.out, payload: { tool: 'layer-move', mode: 'back', db: dbPath, layer, ...res } });
154
+ process.exitCode = EXIT.OK;
155
+ return;
156
+ }
157
+
158
+ // ── 搬出
159
+ const plan = planMove({ dbPath, cfg, layer });
160
+ if (plan.blockers.length) {
161
+ for (const b of plan.blockers) process.stderr.write(`❌ ${b}\n`);
162
+ process.exitCode = EXIT.UNREADABLE;
163
+ return;
164
+ }
165
+ say(`层配置:${describeConfig(cfg)}`);
166
+ say(`主表 ${plan.totalRows} 行 · 层「${layer}」${plan.layerRows} 行 · 边表已有该层 ${plan.evictedRows} 行`);
167
+ if (plan.leavesEmpty) say(`⚠️ 这一搬会把主表搬空(${plan.layerRows}/${plan.totalRows})—— 真要这么干得加 --force`);
168
+ if (!f.apply) {
169
+ say('dry-run:未写任何东西(加 --apply 才搬)');
170
+ emitJsonReport({ json: f.json, out: f.out, payload: { tool: 'layer-move', mode: 'move-dry-run', db: dbPath, layer, plan } });
171
+ process.exitCode = EXIT.OK;
172
+ return;
173
+ }
174
+ if (!f.db) return usageError('--apply 必须显式给 --db <库>:不许靠缺省路径改到家目录那本库');
175
+ const batch = f.batch || `evict-${stamp()}`;
176
+ const res = moveOut({ dbPath, cfg, layer, batch, force: Boolean(f.force) });
177
+ say(`✅ 搬出 ${res.moved} 行 → ${EVICTED_TABLE}(批次 ${res.batch})`);
178
+ say(` 守恒:搬出 ${res.evicted} + 剩下 ${res.remaining} = 搬前 ${res.totalBefore} ${res.evicted + res.remaining === res.totalBefore ? '✅' : '❌'}`);
179
+ if (res.moved === 0) say(' (这一层本来就是空的 —— 没搬任何行)');
180
+ if (res.seqBefore !== undefined && res.seqAfter !== res.seqBefore) say(`⚠️ sqlite_sequence 变了:${res.seqBefore} → ${res.seqAfter}`);
181
+ emitJsonReport({ json: f.json, out: f.out, payload: { tool: 'layer-move', mode: 'move', db: dbPath, layer, ...res } });
182
+ process.exitCode = EXIT.OK;
183
+ }
184
+
185
+ try {
186
+ if (isMainEntry(import.meta.url)) main();
187
+ } catch (e) {
188
+ settle(e, { prefix: '[layer-move]' });
189
+ }
@@ -1,90 +1,156 @@
1
- // 记忆引擎 v2 · 离线层 —— 坑库向量同步钩子 CLI(A 点 · 标记/状态/消费三用)
1
+ // 坑库向量钩子入口:一个「置标记 / 看标记 / 消费标记」三用的小命令。
2
2
  //
3
- // 用法:
4
- // node cli/pitfall-hook.mjs --status --json # 只读:标记现值 + 两个来源的向量条数
5
- // node cli/pitfall-hook.mjs --run --json # **预演**(默认 dry-run:只算不写、不清标记)
6
- // node cli/pitfall-hook.mjs --run --apply --json # 真消费(写向量 + 清标记)
7
- // node cli/pitfall-hook.mjs --mark --reason "pitfall job 变动" # 置标记(pitfall job 侧一行调用)
8
- // 路径可注入:--marker-file / --pitfalls-file / --vectors-db / --dsh-home
3
+ // 坑库那边改完之后不直接编向量(会拖慢它自己),只写一个标记文件;谁有空谁来看这个标记,
4
+ // 有标记就跑一轮坑源增量同步,跑成功才清标记 —— 跑失败一律保留,下一轮重试。
9
5
  //
10
- // ⚠️ 生态规矩:动数据的动作一律**默认 dry-run**,`--apply` 才真写。
6
+ // node cli/pitfall-hook.mjs --status --json # 只看:标记现值 + 两个来源的向量条数
7
+ // node cli/pitfall-hook.mjs --run --json # 预演(缺省就是不写、不清标记)
8
+ // node cli/pitfall-hook.mjs --run --apply --json # 真消费(写向量 + 清标记)
9
+ // node cli/pitfall-hook.mjs --mark --reason "原因" # 置标记
10
+ //
11
+ // 路径可注入:--marker-file / --pitfalls-file / --vectors-db / --dsh-home
12
+ // 退出码:0 = 正常(含没标记可消费)· 1 = 消费失败(标记保留)· 2 = 起不来。
11
13
  import { isMainEntry, parseArgs, makeLogger, openRuntime, num } from '../lib/cli-util.mjs';
12
14
  import { readMarker, markDirty, runPitfallHook } from '../lib/tick-hook.mjs';
13
15
  import { countVectors } from '../lib/vectors-store.mjs';
14
16
 
15
- async function main() {
17
+ const OK = 0;
18
+ const FAIL_RUN = 1;
19
+ const CRASH = 2;
20
+ const COLUMNS = 12;
21
+
22
+ /** 统一的启动三件套:原始 flag、是否机器可读、以及分好流的 logger */
23
+ function boot() {
16
24
  const { flags } = parseArgs();
17
25
  const json = Boolean(flags.json);
18
- const log = makeLogger({ json, quiet: Boolean(flags.quiet) });
19
- const mode = flags.run ? 'run' : flags.mark ? 'mark' : 'status';
20
- const apply = Boolean(flags.apply);
21
- let rt = null;
26
+ return { flags, json, log: makeLogger({ json, quiet: Boolean(flags.quiet) }) };
27
+ }
28
+
29
+ /** 开一个运行时,交给回调用,出了什么事都把库关掉 */
30
+ async function withRuntime(flags, work, opts) {
31
+ const rt = await openRuntime(flags, opts);
22
32
  try {
23
- // 钩子只关心坑源 ⇒ 不预开默认来源(默认来源 = memory,会被守卫拒掉)
24
- rt = await openRuntime(flags, { withSource: false, withEmbedder: mode === 'run' });
25
- const markerPath = rt.config.pitfallVectorDirtyFile;
26
- const out = {
27
- ok: true,
28
- mode,
29
- apply,
30
- markerPath,
31
- pitfallsFile: rt.config.pitfallsFile,
32
- vectorsDb: rt.config.vectorsDb,
33
- counts: { memory: countVectors(rt.store.db, 'memory'), pitfall: countVectors(rt.store.db, 'pitfall') },
34
- };
33
+ return await work(rt);
34
+ } finally {
35
+ rt.close();
36
+ }
37
+ }
35
38
 
36
- if (mode === 'status') {
37
- const r = readMarker(markerPath);
38
- out.marker = { present: r.present, value: r.marker, error: r.error };
39
- } else if (mode === 'mark') {
40
- out.mark = markDirty(markerPath, {
41
- reason: flags.reason === undefined || flags.reason === true ? null : String(flags.reason),
42
- by: 'cli/pitfall-hook.mjs',
43
- });
44
- } else {
45
- out.hook = await runPitfallHook({
39
+ /** 起来就炸:json 走 stdout,文本走 logger,退出码固定 2 */
40
+ function oops(json, log, err) {
41
+ const detail = String(err?.stack ?? err);
42
+ if (json) process.stdout.write(`${JSON.stringify({ ok: false, error: detail }, null, 2)}\n`);
43
+ else log(`❌ ${detail}`);
44
+ process.exitCode = CRASH;
45
+ }
46
+
47
+ /** 中文占两格:按显示宽度补空格,冒号右对齐 */
48
+ function field(text) {
49
+ let used = 0;
50
+ for (const ch of String(text)) used += ch.codePointAt(0) > 0xff ? 2 : 1;
51
+ return String(text) + ' '.repeat(Math.max(0, COLUMNS - used));
52
+ }
53
+
54
+ /** 模式判定:--run 优先,其次 --mark,都没有就是 --status */
55
+ const readMode = (flags) => ['run', 'mark'].find((k) => Boolean(flags[k])) || 'status';
56
+
57
+ function banner(log, mode, apply) {
58
+ log(`=== 坑库向量钩子(${mode}${apply ? ' · --apply' : ' · 预演'}) ===`);
59
+ }
60
+
61
+ function report(log, out) {
62
+ const row = (name, value) => log(`${field(name)}: ${value}`);
63
+ row('标记文件', out.markerPath);
64
+ row('坑库', out.pitfallsFile);
65
+ row('向量库', out.vectorsDb);
66
+ row('向量条数', `memory=${out.counts.memory} / pitfall=${out.counts.pitfall}`);
67
+ const hook = out.hook;
68
+ if (out.mode === 'status') {
69
+ const unread = !out.marker.present && out.marker.error;
70
+ const shown = out.marker.present ? JSON.stringify(out.marker.value) : '无';
71
+ row('标记', unread ? `${shown}(读失败:${out.marker.error})` : shown);
72
+ }
73
+ if (out.mode === 'mark') row('已置标记', `updatedAt=${out.mark.updatedAt}`);
74
+ if (hook) {
75
+ const skip = hook.skipped ? `(${hook.skipped})` : '';
76
+ row('钩子结果', `${hook.ok ? '✅' : '❌'} ${skip}`);
77
+ if (hook.stats) {
78
+ const s = hook.stats;
79
+ row('同步', `扫 ${s.scanned} / 编码 ${s.encoded}(跳过 ${s.skippedSameHash})向量 ${s.vectorsBefore} → ${s.vectorsAfter}`);
80
+ }
81
+ row('清标记', JSON.stringify(hook.cleared));
82
+ row('向量条数', `memory=${out.countsAfter.memory} / pitfall=${out.countsAfter.pitfall}`);
83
+ }
84
+ }
85
+
86
+ /** 三种模式各交一段「往结果上补的字段」;消费是唯一异步的那个 */
87
+ function handlersFor(ctx) {
88
+ const { rt, flags, markerPath, apply, tally, log } = ctx;
89
+ return {
90
+ status() {
91
+ const seen = readMarker(markerPath);
92
+ return { marker: { present: seen.present, value: seen.marker, error: seen.error } };
93
+ },
94
+ mark() {
95
+ const reason = flags.reason === undefined || flags.reason === true ? null : String(flags.reason);
96
+ return { mark: markDirty(markerPath, { reason, by: 'cli/pitfall-hook.mjs' }) };
97
+ },
98
+ async run() {
99
+ const { config, embedder, store } = rt;
100
+ const hook = await runPitfallHook({
46
101
  markerPath,
47
- store: rt.store,
48
- // ⚠️ 必须是**坑源**:默认来源(memory)会被 runPitfallHook 的守卫拒掉
102
+ store,
103
+ // 必须是坑源:别的来源会被 runPitfallHook 的守卫直接拒掉
49
104
  source: rt.openSource('pitfall'),
50
- embedder: rt.embedder,
51
- modelId: rt.config.modelId,
52
- dim: rt.config.dim,
53
- batchSize: num(flags.batch, rt.config.batchSize),
105
+ embedder,
106
+ modelId: config.modelId,
107
+ dim: config.dim,
108
+ batchSize: num(flags.batch, config.batchSize),
54
109
  full: Boolean(flags.full),
55
110
  dryRun: !apply,
56
111
  log,
57
112
  });
58
- out.countsAfter = {
59
- memory: countVectors(rt.store.db, 'memory'),
60
- pitfall: countVectors(rt.store.db, 'pitfall'),
61
- };
62
- }
113
+ return { hook, countsAfter: tally() };
114
+ },
115
+ };
116
+ }
63
117
 
64
- if (json) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
65
- else {
66
- log(`=== 坑库向量钩子(${mode}${apply ? ' · --apply' : ' · 预演'}) ===`);
67
- log(`标记文件 : ${markerPath}`);
68
- log(`坑库 : ${rt.config.pitfallsFile}`);
69
- log(`向量库 : ${rt.config.vectorsDb}`);
70
- log(`向量条数 : memory=${out.counts.memory} / pitfall=${out.counts.pitfall}`);
71
- if (mode === 'status') log(`标记 : ${out.marker.present ? JSON.stringify(out.marker.value) : `无${out.marker.error ? `(读失败:${out.marker.error})` : ''}`}`);
72
- if (mode === 'mark') log(`已置标记 : updatedAt=${out.mark.updatedAt}`);
73
- if (mode === 'run') {
74
- log(`钩子结果 : ${out.hook.ok ? '✅' : '❌'} ${out.hook.skipped ? `(${out.hook.skipped})` : ''}`);
75
- if (out.hook.stats) log(`同步 : 扫 ${out.hook.stats.scanned} / 编码 ${out.hook.stats.encoded}(跳过 ${out.hook.stats.skippedSameHash})向量 ${out.hook.stats.vectorsBefore} → ${out.hook.stats.vectorsAfter}`);
76
- log(`清标记 : ${JSON.stringify(out.hook.cleared)}`);
77
- log(`向量条数 : memory=${out.countsAfter.memory} / pitfall=${out.countsAfter.pitfall}`);
78
- }
79
- }
80
- process.exitCode = out.hook && out.hook.ok === false ? 1 : 0;
81
- } catch (e) {
82
- const msg = String(e && e.stack ? e.stack : e);
83
- if (json) process.stdout.write(`${JSON.stringify({ ok: false, error: msg }, null, 2)}\n`);
84
- else log(`❌ ${msg}`);
85
- process.exitCode = 2;
86
- } finally {
87
- if (rt) rt.close();
118
+ async function work(rt, flags, ctx) {
119
+ const { json, log, mode } = ctx;
120
+ const { config, store } = rt;
121
+ const apply = Boolean(flags.apply);
122
+ const markerPath = config.pitfallVectorDirtyFile;
123
+ const tally = () => ({ memory: countVectors(store.db, 'memory'), pitfall: countVectors(store.db, 'pitfall') });
124
+ const out = {
125
+ ok: true,
126
+ mode,
127
+ apply,
128
+ markerPath,
129
+ pitfallsFile: config.pitfallsFile,
130
+ vectorsDb: config.vectorsDb,
131
+ counts: tally(),
132
+ };
133
+ Object.assign(out, await handlersFor({ rt, flags, markerPath, apply, tally, log })[mode]());
134
+ if (json) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
135
+ else {
136
+ banner(log, mode, apply);
137
+ report(log, out);
138
+ }
139
+ const failed = out.hook !== undefined && out.hook.ok === false;
140
+ process.exitCode = failed ? FAIL_RUN : OK;
141
+ }
142
+
143
+ async function main() {
144
+ const { flags, json, log } = boot();
145
+ const mode = readMode(flags);
146
+ try {
147
+ // 钩子只关心坑源 ⇒ 不预开默认来源(默认来源会被守卫拒掉);非 run 模式连编码器都不建
148
+ await withRuntime(flags, (rt) => work(rt, flags, { json, log, mode }), {
149
+ withSource: false,
150
+ withEmbedder: mode === 'run',
151
+ });
152
+ } catch (err) {
153
+ oops(json, log, err);
88
154
  }
89
155
  }
90
156