@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
@@ -1,529 +1,530 @@
1
- // 记忆引擎 v2 · 离线层 —— 文档索引器主编排(方案 §3.2 / §5.2 / §5.4 / §7)
1
+ // 文档索引器 —— 一轮的编排(扫描面 → 差分 → 摘要 → 落库 → 清孤条 → 写 state → 日报一行)
2
2
  //
3
- // 一轮做的事(顺序即语义):
4
- // ① 扫描面(`docs/**/*.md`,排除 4 类)+ 取范围(首期 10 篇 / --all / --only)
5
- // ② 差分:比对 state 里的 `mtime/size` + 库中是否已有条 ⇒ **没变的一篇都不碰**
6
- // (「变了才 UPSERT 该篇」= §5.4 去重键口径;幂等靠 path 主键)
7
- // ③ 变了 → 生成摘要(rule 确定性 / llm 受 §3.5 成本闸)→ 逐批 UPSERT(busy_timeout + 重试 3 次 / 退避 ≤60s)
8
- // ④ 清孤条(文档变短后残留的 `path#§N`)
9
- // ⑤ 写 state(原子) + 日报一行;**任何失败都只记不抛**(卡要求"不报错给主人")
3
+ // 一轮里的顺序就是语义,别调换:
4
+ // ① 扫 docs 面(四类目录排除在外)+ 定本轮范围(首期十篇 / 全量 / 点名)
5
+ // ② 差分:state 里的 mtime/size + 库里有没有这一篇的条 ⇒ 没变的篇一个字节都不碰
6
+ // ③ 变了的篇:读正文 → 解析结构 → 切条 → 出摘要(规则确定性 / 模型受成本闸)
7
+ // ④ 分批 UPSERT(等锁 + 退避重试;批小一点好在锁竞争里让路)
8
+ // ⑤ 清掉本轮没再生成的分条(文档变短后留下的 path#§N 残留)
9
+ // ⑥ state 原子写 + 日报一行
10
+ // 任何一步失败都只记账、不往外抛:这条链出问题不该连累别的机制。
10
11
  import fs from 'node:fs';
11
12
  import os from 'node:os';
12
13
  import path from 'node:path';
13
- import { textHash } from './hashes.mjs';
14
14
  import {
15
- DEFAULT_DOCS_ROOT,
16
- DEFAULT_DSH_ROOT,
17
- FIRST_BATCH,
18
- FIRST_BATCH_SOURCE,
19
- parseStructure,
20
- planRows,
21
- readDoc,
22
- scanDocs,
15
+ DEFAULT_DOCS_ROOT, DEFAULT_DSH_ROOT, FIRST_BATCH, FIRST_BATCH_SOURCE,
16
+ parseStructure, planRows, readDoc, scanDocs,
23
17
  } from './doc-scan.mjs';
24
18
  import { emptyCost, resolveApiKey, rolloverCost, summarizeAll } from './doc-summarize.mjs';
25
19
  import {
26
- META_KEYS,
27
- deleteStaleRows,
28
- hasDocRows,
29
- listRows,
30
- openDocIndexStore,
31
- setMeta,
32
- upsertDocs,
33
- withBusyRetry,
20
+ META_KEYS, deleteStaleRows, hasDocRows, listRows,
21
+ openDocIndexStore, setMeta, upsertDocs, withBusyRetry,
34
22
  } from './doc-index-store.mjs';
35
23
 
36
24
  export const DOC_INDEX_STATE_SCHEMA = 1;
37
25
  export const HISTORY_MAX = 20;
38
- /** 小批量(方案 §5.2:独立库各自加锁 + 小批量)。 */
26
+ // 小批量:独立库各自加锁,一批五条就够
39
27
  export const DEFAULT_BATCH = 5;
40
28
 
41
- // ---------------------------------------------------------------- state
29
+ export const DAILY_SECTION_TITLE = '## 📄 文档索引(doc-index)';
30
+
31
+ // ── state ───────────────────────────────────────────────────────────────────
42
32
 
43
33
  export function readState(file) {
34
+ let raw;
44
35
  try {
45
- const obj = JSON.parse(fs.readFileSync(file, 'utf8'));
46
- return obj && typeof obj === 'object' ? obj : null;
36
+ raw = fs.readFileSync(file, 'utf8');
47
37
  } catch {
48
- return null; // 缺失/损坏一律当"首次运行"(state 是可重建载体)
38
+ return null; // 文件还没生成 ⇒ 当首次运行
49
39
  }
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ return parsed && typeof parsed === 'object' ? parsed : null;
43
+ } catch {
44
+ return null; // 内容坏了同样当首次运行:state 是可重建载体,不该卡住整条链
45
+ }
46
+ }
47
+
48
+ // 原子写:同目录临时件 + rename(跨盘 rename 必炸,所以临时件必须落在目标旁边)
49
+ function writeAtomic(file, text) {
50
+ const tmp = `${file}.tmp-${process.pid}`;
51
+ fs.writeFileSync(tmp, text, 'utf8');
52
+ fs.renameSync(tmp, file);
50
53
  }
51
54
 
52
55
  export function writeState(file, obj) {
53
56
  try {
54
57
  fs.mkdirSync(path.dirname(file), { recursive: true });
55
- const tmp = `${file}.tmp-${process.pid}`;
56
- fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
57
- fs.renameSync(tmp, file); // 同目录 rename:原子且不跨盘(跨盘 rename 必炸,铁律)
58
+ writeAtomic(file, `${JSON.stringify(obj, null, 2)}\n`);
58
59
  return true;
59
60
  } catch {
60
61
  return false;
61
62
  }
62
63
  }
63
64
 
65
+ // 历史只留最近 max 条,老的直接丢(不追加、不合并)
64
66
  export function pushHistory(prev, entry, max = HISTORY_MAX) {
65
- const hist = Array.isArray(prev?.history) ? prev.history.slice(-(max - 1)) : [];
66
- hist.push(entry);
67
- while (hist.length > max) hist.shift();
68
- return hist;
67
+ const kept = Array.isArray(prev?.history) ? prev.history.slice(-(max - 1)) : [];
68
+ kept.push(entry);
69
+ while (kept.length > max) kept.shift();
70
+ return kept;
69
71
  }
70
72
 
71
- // ---------------------------------------------------------------- 范围与差分
73
+ // ── 范围与差分 ──────────────────────────────────────────────────────────────
72
74
 
73
- /** 解析本轮范围:`--all` 全扫描面 / `--only a,b` 指定 / 默认首期 10 篇。 */
75
+ // 本轮做哪些篇:--all 走全扫描面 / --only 点名 / 默认首期那十篇
74
76
  export function resolveScope({ scanned, flags = {} }) {
75
- const byRel = new Map(scanned.map((d) => [d.rel, d]));
76
77
  if (flags.all || flags.scope === 'all') {
77
78
  return { mode: 'all', source: '扫描面全量(--all)', docs: scanned, missing: [] };
78
79
  }
79
- const only =
80
- typeof flags.only === 'string' && flags.only.trim()
81
- ? flags.only.split(',').map((s) => s.trim()).filter(Boolean)
82
- : null;
83
- const want = only ?? FIRST_BATCH;
80
+
81
+ const explicit = typeof flags.only === 'string' && flags.only.trim()
82
+ ? flags.only.split(',').map((s) => s.trim()).filter(Boolean)
83
+ : null;
84
+ const wanted = explicit ?? FIRST_BATCH;
85
+ const byRel = new Map(scanned.map((doc) => [doc.rel, doc]));
86
+
84
87
  const docs = [];
85
88
  const missing = [];
86
- for (const rel of want) {
87
- const hit = byRel.get(rel);
88
- if (hit) docs.push(hit);
89
+ for (const rel of wanted) {
90
+ const found = byRel.get(rel);
91
+ if (found) docs.push(found);
89
92
  else missing.push(rel);
90
93
  }
94
+
91
95
  return {
92
- mode: only ? 'only' : 'first-batch',
93
- source: only ? '--only 显式指定' : FIRST_BATCH_SOURCE,
96
+ mode: explicit ? 'only' : 'first-batch',
97
+ source: explicit ? '--only 显式指定' : FIRST_BATCH_SOURCE,
94
98
  docs,
95
99
  missing,
96
100
  };
97
101
  }
98
102
 
99
- /**
100
- * 是否要重建该篇。判据(方案 §3.2「比对 mtime/size/text_hash,变了才 UPSERT 该篇」):
101
- * state 里 mtime/size 未变 **且** 上次结果 ok **且** 库中已有该篇主条 ⇒ 跳过,一个字节都不碰。
102
- * @param {{doc:object, prevEntry:object|null, hasRow:boolean, force:boolean}} p
103
- */
103
+ // 重建判据表:从上往下,命中第一条就重建(顺序有意义 —— 强制最高,其次没记录,再其次变了)
104
+ const REBUILD_RULES = [
105
+ [(ctx) => ctx.force, 'forced'],
106
+ [(ctx) => !ctx.prevEntry, 'new'],
107
+ [
108
+ (ctx) => Number(ctx.prevEntry.mtime) !== Number(ctx.doc.mtime) || Number(ctx.prevEntry.size) !== Number(ctx.doc.size),
109
+ 'mtime-or-size-changed',
110
+ ],
111
+ // 只有 failed 才强推重算:ok(本轮重建过)与 skipped(本轮确认过最新)都算「已同步」;
112
+ // 把 skipped 也当没同步,会让跳过轮和重建轮来回振荡。
113
+ [(ctx) => ctx.prevEntry.lastResult === 'failed', 'last-failed'],
114
+ [(ctx) => !ctx.hasRow, 'missing-in-db'],
115
+ ];
116
+
104
117
  export function needsRebuild({ doc, prevEntry, hasRow, force = false }) {
105
- if (force) return { rebuild: true, reason: 'forced' };
106
- if (!prevEntry) return { rebuild: true, reason: 'new' };
107
- if (Number(prevEntry.mtime) !== Number(doc.mtime) || Number(prevEntry.size) !== Number(doc.size)) {
108
- return { rebuild: true, reason: 'mtime-or-size-changed' };
109
- }
110
- // ⚠️ 只有 **failed** 才强制重算;'ok'(本轮重建过)与 'skipped'(本轮已确认最新)都表示"已同步"。
111
- // 实测踩过:把 'skipped' 也当"未同步" ⇒ 跳过轮与重建轮来回振荡(永远有一半的轮次在重算摘要)。
112
- if (prevEntry.lastResult === 'failed') return { rebuild: true, reason: 'last-failed' };
113
- if (!hasRow) return { rebuild: true, reason: 'missing-in-db' };
118
+ const ctx = { doc, prevEntry, hasRow, force };
119
+ for (const [test, reason] of REBUILD_RULES) if (test(ctx)) return { rebuild: true, reason };
114
120
  return { rebuild: false, reason: 'unchanged' };
115
121
  }
116
122
 
117
- // ---------------------------------------------------------------- 日报一行
123
+ // ── 文案 ────────────────────────────────────────────────────────────────────
118
124
 
119
- /** 日报一行文案(纯函数;state 与日报段共用)。 */
125
+ // 日报一行(纯函数:state 里存的和日报段里写的必须是同一份文本)
120
126
  export function renderDailyLine(state) {
121
- const r = state?.lastRun ?? {};
127
+ const run = state?.lastRun ?? {};
122
128
  const cost = state?.cost ?? { calls: 0, tokens: 0 };
123
- const failed = Number(r.failedCount ?? (Array.isArray(r.failed) ? r.failed.length : 0));
124
- const head = `📄 文档索引(doc-index):${r.totalDocs ?? 0} 篇 / ${r.totalRows ?? 0} 条`;
125
- const detail = `本轮 重建 ${r.rebuilt ?? 0} · 跳过 ${r.skipped ?? 0} · 写库 ${r.upserted ?? 0} · 清孤条 ${r.staleDeleted ?? 0}`;
126
- const tail = `失败 ${failed} · 成本 ${cost.calls ?? 0} 次调用 ${cost.tokens ?? 0} token`;
127
- const warn = failed ? ` ⚠️ ${String(r.failed?.[0]?.error ?? '').slice(0, 80)}` : '';
128
- return `${head} · ${detail} · ${tail}${warn}`;
129
+ const failed = Number(run.failedCount ?? (Array.isArray(run.failed) ? run.failed.length : 0));
130
+ const scale = `📄 文档索引(doc-index):${run.totalDocs ?? 0} 篇 / ${run.totalRows ?? 0} 条`;
131
+ const detail = `本轮 重建 ${run.rebuilt ?? 0} · 跳过 ${run.skipped ?? 0} · 写库 ${run.upserted ?? 0} · 清孤条 ${run.staleDeleted ?? 0}`;
132
+ const bill = `失败 ${failed} · 成本 ${cost.calls ?? 0} 次调用 ${cost.tokens ?? 0} token`;
133
+ const flag = failed ? ` ⚠️ ${String(run.failed?.[0]?.error ?? '').slice(0, 80)}` : '';
134
+ return `${scale} · ${detail} · ${bill}${flag}`;
129
135
  }
130
136
 
131
- /** 当日日报路径(与 report.mjs 同口径:`<docsDir>\管家报告\<YYYY-MM-DD>-<host>.md`)。 */
137
+ // 当日日报路径(与日报其它段同一口径:<docsDir>/管家报告/<YYYY-MM-DD>-<host>.md)
132
138
  export function dailyReportPath({ docsDir = DEFAULT_DOCS_ROOT, host, now = Date.now() } = {}) {
133
- const d = new Date(now);
134
- const p = (n) => String(n).padStart(2, '0');
135
- const date = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
139
+ const when = new Date(now);
140
+ const pad = (n) => String(n).padStart(2, '0');
141
+ const date = `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
136
142
  return path.join(docsDir, '管家报告', `${date}-${host || os.hostname()}.md`);
137
143
  }
138
144
 
139
- export const DAILY_SECTION_TITLE = '## 📄 文档索引(doc-index)';
145
+ // 段体替换:从标题行之后一路换到「下一个二级标题」之前
146
+ function swapSection(rows, at, block) {
147
+ let stop = rows.length;
148
+ for (let i = at + 1; i < rows.length; i += 1) {
149
+ if (/^##\s/.test(rows[i])) {
150
+ stop = i;
151
+ break;
152
+ }
153
+ }
154
+ return [...rows.slice(0, at), ...block, ...rows.slice(stop)].join('\n');
155
+ }
140
156
 
141
157
  /**
142
- * 幂等更新当日日报里的「📄 文档索引」段:**文件不存在则不新建**
143
- * (照既有先例「日报缺失 → 跳过不报错」;日报是 report.mjs 的产物,本模块只往里补一行)。
158
+ * 幂等更新当日日报里的「文档索引」段。**文件不存在就不新建** ——
159
+ * 日报是别的模块的产物,本模块只负责往里补一行。
144
160
  * @returns {{ok:boolean, file:string, reason?:string, created?:boolean}}
145
161
  */
146
- export function updateDailySection({ file, line, dryRun = false }) {
147
- let txt;
162
+ export function updateDailySection({
163
+ file,
164
+ line,
165
+ dryRun = false,
166
+ }) {
167
+ let text;
148
168
  try {
149
- txt = fs.readFileSync(file, 'utf8');
169
+ text = fs.readFileSync(file, 'utf8');
150
170
  } catch {
151
171
  return { ok: false, file, reason: 'daily-missing' };
152
172
  }
153
- const lines = txt.split(/\r?\n/);
154
- const idx = lines.findIndex((l) => l.trim() === DAILY_SECTION_TITLE);
155
- const block = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
156
- let out;
157
- let created = false;
158
- if (idx < 0) {
159
- out = `${txt.replace(/\s*$/, '')}\n\n${block.join('\n')}`;
160
- created = true;
161
- } else {
162
- // 段体 = 标题之后到下一个 `## ` 之前
163
- let end = lines.length;
164
- for (let j = idx + 1; j < lines.length; j += 1) {
165
- if (/^##\s/.test(lines[j])) {
166
- end = j;
167
- break;
168
- }
169
- }
170
- out = [...lines.slice(0, idx), ...block, ...lines.slice(end)].join('\n');
171
- if (out === txt) return { ok: true, file, reason: 'unchanged', created: false };
172
- }
173
+
174
+ const rows = text.split(/\r?\n/);
175
+ const sectionLines = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
176
+ const at = rows.findIndex((row) => row.trim() === DAILY_SECTION_TITLE);
177
+ const created = at < 0;
178
+ const out = created
179
+ ? `${text.replace(/\s*$/, '')}\n\n${sectionLines.join('\n')}`
180
+ : swapSection(rows, at, sectionLines);
181
+
182
+ if (!created && out === text) return { ok: true, file, reason: 'unchanged', created: false };
173
183
  if (dryRun) return { ok: true, file, reason: 'dry-run', created };
174
184
  try {
175
- const tmp = `${file}.tmp-${process.pid}`;
176
- fs.writeFileSync(tmp, out, 'utf8');
177
- fs.renameSync(tmp, file);
178
- } catch (e) {
179
- return { ok: false, file, reason: `write-failed:${String((e && e.message) || e)}` };
185
+ writeAtomic(file, out);
186
+ } catch (err) {
187
+ return { ok: false, file, reason: `write-failed:${String((err && err.message) || err)}` };
180
188
  }
181
189
  return { ok: true, file, created };
182
190
  }
183
191
 
184
- // ---------------------------------------------------------------- 主流程
192
+ // ── 主流程 ──────────────────────────────────────────────────────────────────
193
+
194
+ // 注入点:单测把外部依赖整件换掉 ⇒ 零副作用、零网络
195
+ const resolveHooks = (deps) => ({
196
+ readState: deps.readState ?? readState,
197
+ writeState: deps.writeState ?? writeState,
198
+ openStore: deps.openStore ?? openDocIndexStore,
199
+ scanDocs: deps.scanDocs ?? scanDocs,
200
+ readDoc: deps.readDoc ?? readDoc,
201
+ summarize: deps.summarizeAll ?? summarizeAll,
202
+ upsert: deps.upsertDocs ?? upsertDocs,
203
+ purge: deps.deleteStaleRows ?? deleteStaleRows,
204
+ list: deps.listRows ?? listRows,
205
+ });
206
+
207
+ // 各类路径一律三档:flags > 环境变量 > 默认值
208
+ function resolvePaths(flags, env) {
209
+ const homeFlag = String(flags.home || env.DSH_HOME || path.join(os.homedir(), '.dsh'));
210
+ const docsFlag = String(flags['docs-root'] || env.DOC_INDEX_DOCS_ROOT || DEFAULT_DOCS_ROOT);
211
+ const dbFlag = String(flags['doc-index-db'] || env.DOC_INDEX_DB || path.join(homeFlag, 'memory-vectors', 'doc-index.db'));
212
+ const stateFlag = String(flags['state-file'] || path.join(homeFlag, 'butler-doc-index-state.json'));
213
+ return {
214
+ home: path.resolve(homeFlag),
215
+ docsRoot: path.resolve(docsFlag),
216
+ dshRoot: path.resolve(String(flags['dsh-root'] || DEFAULT_DSH_ROOT)),
217
+ dbPath: path.resolve(dbFlag),
218
+ stateFile: path.resolve(stateFlag),
219
+ };
220
+ }
221
+
222
+ // 计数段拆两块:中间夹着三个「数组型」字段,这样字段顺序与既有 state 文件一致
223
+ const RUN_COUNTS_A = ['rebuilt', 'skipped', 'upserted', 'inserted', 'updated', 'unchanged', 'staleDeleted', 'longDocs'];
224
+ const RUN_COUNTS_B = ['batches', 'busyWaits', 'llmCalls', 'llmTokens'];
225
+
226
+ function freshStats({ docsRoot, dbPath, stateFile, startedAt }) {
227
+ const tally = (keys) => Object.fromEntries(keys.map((key) => [key, 0]));
228
+ return {
229
+ scope: null, scopeSource: null,
230
+ docsRoot, dbPath, stateFile,
231
+ scanned: 0, considered: 0, totalDocs: 0, totalRows: 0,
232
+ ...tally(RUN_COUNTS_A),
233
+ failures: [], failed: [], failedCount: 0, missing: [],
234
+ ...tally(RUN_COUNTS_B),
235
+ rowsByDoc: {}, decisions: {},
236
+ startedAt, finishedAt: 0, elapsedMs: 0,
237
+ };
238
+ }
239
+
240
+ // 模型通路要的三件:key(flags / 环境 / 凭证文件三档)、端点、模型名
241
+ function buildLlmConfig(flags, env, home) {
242
+ const key = flags['llm-key'] || resolveApiKey({ env, credentialsFile: path.join(home, '.credentials.yaml') });
243
+ const endpoint = String(flags['llm-base-url'] || env.DOC_INDEX_LLM_BASE_URL || 'https://api.deepseek.com');
244
+ const model = String(flags['llm-model'] || env.DOC_INDEX_LLM_MODEL || 'deepseek-flash');
245
+ return { apiKey: key, baseUrl: endpoint, model };
246
+ }
185
247
 
186
248
  /**
187
- * 跑一轮文档索引(纯编排;外部依赖可注入 ⇒ 单测零副作用、零网络)。
249
+ * 跑一轮文档索引(纯编排)。
188
250
  * @returns {Promise<{ok:boolean, exitCode:number, status:string, stats:object, state:object, lines:string[]}>}
189
251
  */
190
- export async function runDocIndexOnce({ flags = {}, env = process.env, now = () => Date.now(), deps = {} } = {}) {
191
- const lines = [];
192
- const log = (m) => lines.push(String(m));
193
- const dryRun = Boolean(flags['dry-run']);
194
- const force = Boolean(flags.rebuild) || Boolean(flags.force);
195
- const batchSize = Number(flags.batch ?? DEFAULT_BATCH) || DEFAULT_BATCH;
196
- const useLlm = Boolean(flags.llm);
197
- const startedAt = now();
198
-
199
- const docsRoot = path.resolve(String(flags['docs-root'] || env.DOC_INDEX_DOCS_ROOT || DEFAULT_DOCS_ROOT));
200
- const dshRoot = path.resolve(String(flags['dsh-root'] || DEFAULT_DSH_ROOT));
201
- const home = path.resolve(String(flags.home || env.DSH_HOME || path.join(os.homedir(), '.dsh')));
202
- const dbPath = path.resolve(
203
- String(flags['doc-index-db'] || env.DOC_INDEX_DB || path.join(home, 'memory-vectors', 'doc-index.db')),
204
- );
205
- const stateFile = path.resolve(String(flags['state-file'] || path.join(home, 'butler-doc-index-state.json')));
206
- const hostId = flags.host || os.hostname();
207
-
208
- const readStateFn = deps.readState ?? readState;
209
- const writeStateFn = deps.writeState ?? writeState;
210
- const openStore = deps.openStore ?? openDocIndexStore;
211
- const scanFn = deps.scanDocs ?? scanDocs;
212
- const readDocFn = deps.readDoc ?? readDoc;
213
- const summarizeFn = deps.summarizeAll ?? summarizeAll;
214
- const upsertFn = deps.upsertDocs ?? upsertDocs;
215
- const deleteStaleFn = deps.deleteStaleRows ?? deleteStaleRows;
216
-
217
- const prev = readStateFn(stateFile) ?? {};
218
- const d = new Date(startedAt);
219
- const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
220
- const cost = rolloverCost(prev.cost ?? emptyCost(dateKey), dateKey);
221
-
222
- const stats = {
223
- scope: null,
224
- scopeSource: null,
225
- docsRoot,
226
- dbPath,
227
- stateFile,
228
- scanned: 0,
229
- considered: 0,
230
- totalDocs: 0,
231
- totalRows: 0,
232
- rebuilt: 0,
233
- skipped: 0,
234
- upserted: 0,
235
- inserted: 0,
236
- updated: 0,
237
- unchanged: 0,
238
- staleDeleted: 0,
239
- longDocs: 0,
240
- failures: [],
241
- failed: [],
242
- failedCount: 0,
243
- missing: [],
244
- batches: 0,
245
- busyWaits: 0,
246
- llmCalls: 0,
247
- llmTokens: 0,
248
- rowsByDoc: {},
249
- decisions: {},
250
- startedAt,
251
- finishedAt: 0,
252
- elapsedMs: 0,
253
- };
254
-
255
- const finish = (status, extra = {}) => {
252
+ export async function runDocIndexOnce({
253
+ flags = {},
254
+ env = process.env,
255
+ now = () => Date.now(),
256
+ deps = {},
257
+ } = {}) {
258
+ const notes = [];
259
+ const note = (msg) => notes.push(String(msg));
260
+ const impl = resolveHooks(deps);
261
+
262
+ const isDry = Boolean(flags['dry-run']);
263
+ const forceAll = Boolean(flags.rebuild) || Boolean(flags.force);
264
+ const perBatch = Number(flags.batch ?? DEFAULT_BATCH) || DEFAULT_BATCH;
265
+ const wantLlm = Boolean(flags.llm);
266
+ const startAt = now();
267
+ const { home, docsRoot, dshRoot, dbPath, stateFile } = resolvePaths(flags, env);
268
+ const hostTag = flags.host || os.hostname();
269
+
270
+ const prior = impl.readState(stateFile) ?? {};
271
+ const clock = new Date(startAt);
272
+ const pad = (n) => String(n).padStart(2, '0');
273
+ const dateKey = `${clock.getFullYear()}-${pad(clock.getMonth() + 1)}-${pad(clock.getDate())}`;
274
+ const cost = rolloverCost(prior.cost ?? emptyCost(dateKey), dateKey);
275
+ const stats = freshStats({ docsRoot, dbPath, stateFile, startedAt: startAt });
276
+
277
+ // 收尾:补耗时 → 拼 state → 落盘(dry-run 不写)→ 返回 state
278
+ const wrapUp = (status, extra = {}) => {
256
279
  stats.finishedAt = now();
257
280
  stats.elapsedMs = stats.finishedAt - stats.startedAt;
258
- const state = {
259
- ...prev,
281
+ const stampIso = new Date(stats.finishedAt).toISOString();
282
+ const nextState = {
283
+ ...prior,
260
284
  schemaVersion: DOC_INDEX_STATE_SCHEMA,
261
- hostId,
262
- docsRoot,
263
- dbPath,
264
- stateFile,
265
- runs: (prev.runs ?? 0) + 1,
266
- lastRunAt: new Date(stats.finishedAt).toISOString(),
285
+ hostId: hostTag, docsRoot, dbPath, stateFile,
286
+ runs: (prior.runs ?? 0) + 1,
287
+ lastRunAt: stampIso,
267
288
  lastStatus: status,
268
289
  lastRun: stats,
269
- paths: extra.paths ?? prev.paths ?? {},
270
- history: pushHistory(prev, {
271
- at: new Date(stats.finishedAt).toISOString(),
272
- status,
273
- rebuilt: stats.rebuilt,
274
- skipped: stats.skipped,
275
- upserted: stats.upserted,
276
- failed: stats.failedCount,
290
+ paths: extra.paths ?? prior.paths ?? {},
291
+ history: pushHistory(prior, {
292
+ at: stampIso, status,
293
+ rebuilt: stats.rebuilt, skipped: stats.skipped,
294
+ upserted: stats.upserted, failed: stats.failedCount,
277
295
  }),
278
296
  cost: extra.cost ?? cost,
279
297
  };
280
- state.lastDailyLine = renderDailyLine(state);
281
- if (!dryRun) writeStateFn(stateFile, state);
282
- return state;
298
+ nextState.lastDailyLine = renderDailyLine(nextState);
299
+ if (!isDry) impl.writeState(stateFile, nextState);
300
+ return nextState;
283
301
  };
284
302
 
285
303
  // ① 扫描面 + 范围
286
- const scanned = scanFn(docsRoot, { dshRoot });
287
- stats.scanned = scanned.length;
288
- const scope = resolveScope({ scanned, flags });
289
- stats.scope = scope.mode;
290
- stats.scopeSource = scope.source;
291
- stats.considered = scope.docs.length;
292
- stats.missing = scope.missing;
293
- log(
294
- `[doc-index] 扫描面 ${scanned.length} 篇(已排除 变更流水/管家报告/坑库/_archive)`
295
- + ` · 本轮范围 ${scope.mode} = ${scope.docs.length} 篇(${scope.source})`,
304
+ const surface = impl.scanDocs(docsRoot, { dshRoot });
305
+ stats.scanned = surface.length;
306
+ const range = resolveScope({ scanned: surface, flags });
307
+ stats.scope = range.mode;
308
+ stats.scopeSource = range.source;
309
+ stats.considered = range.docs.length;
310
+ stats.missing = range.missing;
311
+ note(
312
+ `[doc-index] 扫描面 ${surface.length} 篇(已排除 变更流水 / 管家报告 / 坑库 / _archive)`
313
+ + ` · 本轮范围 ${range.mode} = ${range.docs.length} 篇(${range.source})`,
296
314
  );
297
- if (scope.missing.length) {
298
- log(`[doc-index] ⚠️ 清单里 ${scope.missing.length} 篇不在扫描面内:${scope.missing.join('、')}`);
315
+ if (range.missing.length) {
316
+ note(`[doc-index] ⚠️ 清单里 ${range.missing.length} 篇不在扫描面内:${range.missing.join('、')}`);
299
317
  }
300
-
301
- if (!scope.docs.length) {
302
- const state = finish('empty', { paths: prev.paths ?? {} });
303
- return { ok: true, exitCode: 0, status: 'empty', stats, state, lines };
318
+ if (!range.docs.length) {
319
+ const nextState = wrapUp('empty', { paths: prior.paths ?? {} });
320
+ return { ok: true, exitCode: 0, status: 'empty', stats, state: nextState, lines: notes };
304
321
  }
305
322
 
306
- // 打开独立库(dry-run 且库不存在 ⇒ 不建库、不建目录)
307
- let store = null;
308
- if (!dryRun || fs.existsSync(dbPath)) {
309
- store = openStore(dbPath, { create: !dryRun, hostId });
310
- }
311
- const db = store?.db ?? null;
312
- let summaries = { byKey: new Map(), cost, calls: 0, tokens: 0, stopped: null, failures: [], skippedForFamily: [] };
323
+ // 独立库:dry-run 且库还不存在 ⇒ 不建库、不建目录
324
+ let handle = null;
325
+ if (!isDry || fs.existsSync(dbPath)) handle = impl.openStore(dbPath, { create: !isDry, hostId: hostTag });
326
+ const conn = handle?.db ?? null;
327
+
328
+ let digests = { byKey: new Map(), cost, calls: 0, tokens: 0, stopped: null, failures: [], skippedForFamily: [] };
313
329
 
314
330
  try {
315
331
  // ② 差分
316
- const prevPaths = prev.paths ?? {};
317
- const rebuildList = [];
318
- for (const doc of scope.docs) {
319
- const hasRow = db ? hasDocRows(db, doc.rel) : Boolean(prevPaths[doc.rel]);
320
- const decision = needsRebuild({ doc, prevEntry: prevPaths[doc.rel] ?? null, hasRow, force });
321
- stats.decisions[doc.rel] = decision;
322
- if (!decision.rebuild) {
323
- stats.skipped += 1;
324
- log(`[doc-index] 跳过 ${doc.rel}(未变:mtime/size 与 state 一致且库中已有条)`);
332
+ const priorPaths = prior.paths ?? {};
333
+ const dirty = [];
334
+ for (const doc of range.docs) {
335
+ const prevEntry = priorPaths[doc.rel] ?? null;
336
+ const hasRow = conn ? hasDocRows(conn, doc.rel) : Boolean(priorPaths[doc.rel]);
337
+ const verdict = needsRebuild({ doc, prevEntry, hasRow, force: forceAll });
338
+ stats.decisions[doc.rel] = verdict;
339
+ if (verdict.rebuild) {
340
+ dirty.push({ doc, reason: verdict.reason });
325
341
  continue;
326
342
  }
327
- rebuildList.push({ doc, reason: decision.reason });
343
+ stats.skipped += 1;
344
+ note(`[doc-index] 跳过 ${doc.rel}(未变:mtime/size 与 state 一致,库里也已有条)`);
328
345
  }
329
346
 
330
- // ③ 读文档 + 解析 + 切条 + 生成摘要
347
+ // ③ 读正文 → 解析 → 切条,顺带攒出要送去出摘要的那批
331
348
  const planMap = new Map();
332
- const docsForSummary = [];
333
- for (const item of rebuildList) {
349
+ const toSummarize = [];
350
+ for (const item of dirty) {
334
351
  try {
335
- const { text, chars } = readDocFn(item.doc.abs);
336
- const structure = parseStructure(text);
337
- const doc = { ...item.doc, chars, structure };
352
+ const { text, chars } = impl.readDoc(item.doc.abs);
353
+ const doc = { ...item.doc, chars, structure: parseStructure(text) };
338
354
  planMap.set(doc.rel, { doc, rows: planRows(doc), reason: item.reason });
339
- docsForSummary.push(doc);
340
- } catch (e) {
341
- stats.failures.push({ path: item.doc.rel, error: `读取/解析失败:${String((e && e.message) || e)}` });
355
+ toSummarize.push(doc);
356
+ } catch (err) {
357
+ stats.failures.push({ path: item.doc.rel, error: `读取/解析失败:${String((err && err.message) || err)}` });
342
358
  }
343
359
  }
344
360
 
345
- if (docsForSummary.length) {
346
- summaries = await summarizeFn({
347
- docs: docsForSummary,
348
- plans: new Map([...planMap].map(([rel, v]) => [rel, v.rows])),
349
- mode: useLlm ? 'llm' : 'rule',
361
+ if (toSummarize.length) {
362
+ digests = await impl.summarize({
363
+ docs: toSummarize,
364
+ plans: new Map([...planMap].map(([rel, entry]) => [rel, entry.rows])),
365
+ mode: wantLlm ? 'llm' : 'rule',
350
366
  cost,
351
367
  date: dateKey,
352
- llm: useLlm ? buildLlmConfig(flags, env, home) : null,
368
+ llm: wantLlm ? buildLlmConfig(flags, env, home) : null,
353
369
  deps,
354
370
  });
355
- for (const f of summaries.failures ?? []) stats.failures.push(f);
356
- stats.llmCalls = Number(summaries.calls) || 0;
357
- stats.llmTokens = Number(summaries.tokens) || 0;
358
- if (summaries.stopped) log(`[doc-index] ⛔ 成本闸:${summaries.stopped} ⇒ 本轮剩余文档停止(不重试)`);
371
+ for (const failure of digests.failures ?? []) stats.failures.push(failure);
372
+ stats.llmCalls = Number(digests.calls) || 0;
373
+ stats.llmTokens = Number(digests.tokens) || 0;
374
+ if (digests.stopped) note(`[doc-index] ⛔ 成本闸:${digests.stopped} ⇒ 本轮剩余文档停止(不重试)`);
359
375
  }
360
376
 
361
- // ④ 分批 UPSERT
362
- const payload = [];
377
+ // ④ 组 payload 并分批写库
378
+ const writes = [];
363
379
  for (const [rel, entry] of planMap) {
364
- const usable = entry.rows.filter((r) => summaries.byKey.has(r.key));
365
- if (usable.length !== entry.rows.length) {
380
+ const usable = entry.rows.filter((row) => digests.byKey.has(row.key));
381
+ const lost = entry.rows.length - usable.length;
382
+ if (lost > 0) {
366
383
  stats.failures.push({
367
384
  path: rel,
368
- error: `摘要缺失 ${entry.rows.length - usable.length}/${entry.rows.length} 条(成本闸中止或上游失败)⇒ 该篇跳过,下轮重试`,
385
+ error: `摘要缺失 ${lost}/${entry.rows.length} 条(成本闸中止或上游失败)⇒ 该篇跳过,下轮重试`,
369
386
  });
370
387
  }
371
388
  if (!usable.length) continue;
372
389
  stats.rebuilt += 1;
373
390
  stats.rowsByDoc[rel] = usable.length;
374
391
  if (entry.rows.length > 1) stats.longDocs += 1;
375
- for (const r of usable) {
376
- const s = summaries.byKey.get(r.key);
377
- payload.push({
378
- path: r.key,
392
+ for (const row of usable) {
393
+ const digest = digests.byKey.get(row.key);
394
+ writes.push({
395
+ path: row.key,
379
396
  mtime: entry.doc.mtime,
380
397
  size: entry.doc.size,
381
- textHash: s.textHash,
382
- summary: s.summary,
398
+ textHash: digest.textHash,
399
+ summary: digest.summary,
383
400
  ownerUser: flags['owner-user'] || 'LiJianXiao',
384
401
  visibility: flags.visibility || 'private_owner',
385
- updatedAt: entry.doc.mtime, // 索引条描述的是**该文档的 mtime**(方案 §3.2 索引条形态)
402
+ // 索引条描述的是**这篇文档的 mtime**,不是写入时刻
403
+ updatedAt: entry.doc.mtime,
386
404
  });
387
405
  }
388
406
  }
389
- stats.totalRows = Object.values(stats.rowsByDoc).reduce((a, b) => a + b, 0);
407
+ stats.totalRows = Object.values(stats.rowsByDoc).reduce((sum, n) => sum + n, 0);
390
408
  stats.totalDocs = Object.keys(stats.rowsByDoc).length;
391
409
 
392
- if (payload.length && !dryRun) {
393
- for (let i = 0; i < payload.length; i += batchSize) {
394
- const chunk = payload.slice(i, i + batchSize);
410
+ if (writes.length && isDry) note(`[doc-index] --dry-run:本应写 ${writes.length} 条(未写库)`);
411
+ if (writes.length && !isDry) {
412
+ for (let at = 0; at < writes.length; at += perBatch) {
413
+ const batch = writes.slice(at, at + perBatch);
395
414
  stats.batches += 1;
396
- const r = await withBusyRetry(() => upsertFn(db, chunk), {
415
+ const outcome = await withBusyRetry(() => impl.upsert(conn, batch), {
397
416
  sleep: deps.sleep,
398
417
  delaysMs: deps.retryDelaysMs,
399
- onRetry: (n, e) => {
418
+ onRetry: (n, err) => {
400
419
  stats.busyWaits += 1;
401
- log(`[doc-index] 库被锁,退避重试第 ${n} 次(${String((e && e.message) || e)})`);
420
+ note(`[doc-index] 库被锁,退避重试第 ${n} 次(${String((err && err.message) || err)})`);
402
421
  },
403
422
  });
404
- if (!r.ok) {
405
- // 超限记 failed(不报错给主人);细到每篇,下轮只重跑这些
406
- for (const c of chunk) {
407
- const rel = String(c.path).split('#§')[0];
408
- if (!stats.failures.some((f) => f.path === rel)) stats.failures.push({ path: rel, error: r.error });
423
+ if (!outcome.ok) {
424
+ // 超限只记失败、不抛;失败细到每篇,下一轮只重跑这些
425
+ for (const cell of batch) {
426
+ const rel = String(cell.path).split('#§')[0];
427
+ if (!stats.failures.some((f) => f.path === rel)) stats.failures.push({ path: rel, error: outcome.error });
409
428
  }
410
- log(`[doc-index] ❌ 批 ${stats.batches} 写库失败(放弃该批,下轮重试):${r.error}`);
429
+ note(`[doc-index] ❌ 批 ${stats.batches} 写库失败(放弃该批,下轮重试):${outcome.error}`);
411
430
  continue;
412
431
  }
413
- stats.upserted += r.value.inserted + r.value.updated;
414
- stats.inserted += r.value.inserted;
415
- stats.updated += r.value.updated;
416
- stats.unchanged += r.value.unchanged;
432
+ stats.upserted += outcome.value.inserted + outcome.value.updated;
433
+ stats.inserted += outcome.value.inserted;
434
+ stats.updated += outcome.value.updated;
435
+ stats.unchanged += outcome.value.unchanged;
417
436
  }
418
- } else if (payload.length && dryRun) {
419
- log(`[doc-index] --dry-run:本应写 ${payload.length} 条(未写库)`);
420
437
  }
421
438
 
422
- // ⑤ 清孤条(只清已重建的篇;跳过的与失败的都不动)
423
- if (!dryRun && db) {
439
+ // ⑤ 清孤条:只清本轮重建过的篇,跳过与失败的一律不动
440
+ if (!isDry && conn) {
424
441
  for (const [rel, entry] of planMap) {
425
- const keep = entry.rows.filter((r) => summaries.byKey.has(r.key)).map((r) => r.key);
442
+ const keep = entry.rows.filter((row) => digests.byKey.has(row.key)).map((row) => row.key);
426
443
  if (!keep.length) continue;
427
- const r = await withBusyRetry(() => deleteStaleFn(db, rel, keep), { sleep: deps.sleep, delaysMs: deps.retryDelaysMs });
428
- if (r.ok) stats.staleDeleted += Number(r.value) || 0;
429
- else stats.failures.push({ path: rel, error: `清孤条失败:${r.error}` });
444
+ const outcome = await withBusyRetry(() => impl.purge(conn, rel, keep), {
445
+ sleep: deps.sleep,
446
+ delaysMs: deps.retryDelaysMs,
447
+ });
448
+ if (outcome.ok) stats.staleDeleted += Number(outcome.value) || 0;
449
+ else stats.failures.push({ path: rel, error: `清孤条失败:${outcome.error}` });
430
450
  }
431
451
  }
432
452
 
433
- // ⑥ meta
434
- if (!dryRun && db) {
453
+ // ⑥ 元数据 + 规模取库内真实值(跳过轮也要显示真实规模,不能按本轮写了多少算)
454
+ if (!isDry && conn) {
435
455
  await withBusyRetry(
436
456
  () => {
437
- setMeta(db, META_KEYS.hostId, hostId);
438
- setMeta(db, META_KEYS.lastRunAt, new Date(stats.finishedAt || now()).toISOString());
439
- setMeta(
440
- db,
441
- META_KEYS.lastRunStats,
442
- JSON.stringify({
443
- rebuilt: stats.rebuilt,
444
- skipped: stats.skipped,
445
- upserted: stats.upserted,
446
- failed: stats.failures.length,
447
- }),
448
- );
457
+ setMeta(conn, META_KEYS.hostId, hostTag);
458
+ setMeta(conn, META_KEYS.lastRunAt, new Date(stats.finishedAt || now()).toISOString());
459
+ const snapshotOfRun = { rebuilt: stats.rebuilt, skipped: stats.skipped, upserted: stats.upserted, failed: stats.failures.length };
460
+ setMeta(conn, META_KEYS.lastRunStats, JSON.stringify(snapshotOfRun));
449
461
  },
450
462
  { sleep: deps.sleep },
451
463
  );
452
- // 规模以**库内真实值**为准:跳过轮(本轮没重建任何一篇)也必须显示真实规模,
453
- // 不能按"本轮写了多少"算 —— 否则日报一行会在跳过轮显示 "0 篇 / 0 条"(实测踩过)。
454
464
  try {
455
- const all = (deps.listRows ?? listRows)(db);
465
+ const all = impl.list(conn);
456
466
  stats.totalRows = all.length;
457
- stats.totalDocs = new Set(all.map((r) => String(r.path).split('#§')[0])).size;
467
+ stats.totalDocs = new Set(all.map((row) => String(row.path).split('#§')[0])).size;
458
468
  } catch {
459
- /* 查询失败保持既有值 */
469
+ // 查不动就保持既有值
460
470
  }
461
471
  }
462
- } catch (e) {
463
- stats.failures.push({ path: '(run)', error: `运行异常:${String((e && e.message) || e)}` });
464
- log(`[doc-index] ❌ 运行异常(只记失败,不影响其它机制):${String((e && e.message) || e)}`);
472
+ } catch (err) {
473
+ stats.failures.push({ path: '(run)', error: `运行异常:${String((err && err.message) || err)}` });
474
+ note(`[doc-index] ❌ 运行异常(只记失败,不影响其它机制):${String((err && err.message) || err)}`);
465
475
  } finally {
466
476
  try {
467
- if (store) store.db.close();
477
+ if (handle) handle.db.close();
468
478
  } catch {
469
- /* ignore */
479
+ // 关不掉也没事,进程退出会收
470
480
  }
471
481
  }
472
482
 
473
- // per-path state(mtime / size / lastResult / failCount —— 卡要求四字段)
474
- // lastResult 语义:'ok' 本轮重建过 / 'skipped' 本轮确认未变 / 'failed' 本轮失败(下轮强制重算)
475
- const paths = { ...(prev.paths ?? {}) };
476
- const failedMap = new Map(stats.failures.map((f) => [f.path, f.error]));
477
- const stampIso = new Date(stats.finishedAt || now()).toISOString();
478
- for (const doc of scope.docs) {
479
- const prevEntry = paths[doc.rel] ?? { failCount: 0 };
480
- const rebuiltDone = Object.prototype.hasOwnProperty.call(stats.rowsByDoc, doc.rel);
481
- const decision = stats.decisions[doc.rel] ?? {};
482
- let lastResult = rebuiltDone ? 'ok' : 'skipped';
483
- if (failedMap.has(doc.rel)) lastResult = 'failed';
484
- paths[doc.rel] = {
483
+ // 每篇的 state 条目:mtime / size / lastResult / failCount(四字段是验收口径)
484
+ // lastResult 含义:ok = 本轮重建过;skipped = 本轮确认未变;failed = 本轮失败(下轮强制重算)
485
+ const perPath = { ...(prior.paths ?? {}) };
486
+ const failByPath = new Map(stats.failures.map((failure) => [failure.path, failure.error]));
487
+ const stamp = new Date(stats.finishedAt || now()).toISOString();
488
+ for (const doc of range.docs) {
489
+ const before = perPath[doc.rel] ?? { failCount: 0 };
490
+ const rebuiltOK = Object.prototype.hasOwnProperty.call(stats.rowsByDoc, doc.rel);
491
+ let lastResult = rebuiltOK ? 'ok' : 'skipped';
492
+ if (failByPath.has(doc.rel)) lastResult = 'failed';
493
+ perPath[doc.rel] = {
485
494
  mtime: doc.mtime,
486
495
  size: doc.size,
487
496
  lastResult,
488
- failCount: lastResult === 'failed' ? Number(prevEntry.failCount || 0) + 1 : 0,
489
- rows: stats.rowsByDoc[doc.rel] ?? prevEntry.rows ?? 0,
490
- reason: decision.reason ?? null,
491
- updatedAt: stampIso,
492
- error: failedMap.get(doc.rel) ?? null,
497
+ failCount: lastResult === 'failed' ? Number(before.failCount || 0) + 1 : 0,
498
+ rows: stats.rowsByDoc[doc.rel] ?? before.rows ?? 0,
499
+ reason: stats.decisions[doc.rel]?.reason ?? null,
500
+ updatedAt: stamp,
501
+ error: failByPath.get(doc.rel) ?? null,
493
502
  };
494
503
  }
495
- stats.failed = stats.failures.filter((f) => f.path !== '(run)');
504
+
505
+ stats.failed = stats.failures.filter((failure) => failure.path !== '(run)');
496
506
  stats.failedCount = stats.failed.length;
497
507
  const status = stats.failedCount ? 'failed' : stats.rebuilt === 0 ? 'noop' : 'ok';
498
- const state = finish(status, {
499
- paths,
500
- cost: { date: summaries.cost?.date ?? dateKey, calls: stats.llmCalls, tokens: stats.llmTokens },
508
+ const nextState = wrapUp(status, {
509
+ paths: perPath,
510
+ cost: { date: digests.cost?.date ?? dateKey, calls: stats.llmCalls, tokens: stats.llmTokens },
501
511
  });
502
512
 
503
- // 日报一行(默认写;`--no-daily` 关闭。日报文件不存在 ⇒ 不新建,只记 reason)
504
- const dailyFile = dailyReportPath({ docsDir: docsRoot, host: hostId, now: startedAt });
505
- const daily = updateDailySection({
506
- file: dailyFile,
507
- line: state.lastDailyLine,
508
- dryRun: dryRun || Boolean(flags['no-daily']),
513
+ // 日报一行(默认写;dry-run 或 --no-daily 只算不写;日报不存在则不新建)
514
+ const reportFile = dailyReportPath({ docsDir: docsRoot, host: hostTag, now: startAt });
515
+ const dailyResult = updateDailySection({
516
+ file: reportFile,
517
+ line: nextState.lastDailyLine,
518
+ dryRun: isDry || Boolean(flags['no-daily']),
509
519
  });
510
- stats.daily = daily;
511
- log(`[doc-index] 日报一行:${state.lastDailyLine}`);
512
- log(`[doc-index] 日报落点:${dailyFile}(${daily.ok ? daily.reason ?? 'written' : daily.reason})`);
513
- log(
520
+ stats.daily = dailyResult;
521
+
522
+ note(`[doc-index] 日报一行:${nextState.lastDailyLine}`);
523
+ note(`[doc-index] 日报落点:${reportFile}(${dailyResult.ok ? dailyResult.reason ?? 'written' : dailyResult.reason})`);
524
+ note(
514
525
  `[doc-index] ${status === 'failed' ? '⚠️' : '✅'} 重建 ${stats.rebuilt} / 跳过 ${stats.skipped}`
515
526
  + ` · 写库 ${stats.upserted}(新 ${stats.inserted} / 更 ${stats.updated} / 同哈希 ${stats.unchanged})`
516
527
  + ` · 清孤条 ${stats.staleDeleted} · 失败 ${stats.failedCount} · ${stats.elapsedMs}ms`,
517
528
  );
518
- return { ok: true, exitCode: 0, status, stats, state, lines };
519
- }
520
-
521
- function buildLlmConfig(flags, env, home) {
522
- const apiKey =
523
- flags['llm-key'] || resolveApiKey({ env, credentialsFile: path.join(home, '.credentials.yaml') });
524
- return {
525
- apiKey,
526
- baseUrl: String(flags['llm-base-url'] || env.DOC_INDEX_LLM_BASE_URL || 'https://api.deepseek.com'),
527
- model: String(flags['llm-model'] || env.DOC_INDEX_LLM_MODEL || 'deepseek-flash'),
528
- };
529
+ return { ok: true, exitCode: 0, status, stats, state: nextState, lines: notes };
529
530
  }