@rennimars/dsh-weknora-memory 0.2.0 → 0.3.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.
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env node
2
+ // 夜间做梦(写侧,可选):自动裁决事实卡之间的取代/冲突/重复,只改 header 里的状态字段,正文逐字保留。
3
+ // 五阶段:S0 收拢 -> S1 分组 -> S2 裁决 -> S3 落账 -> S4 报告。
4
+ // 契约见 writers/README.md:配置走 writer.config.json 或 --config,HTTP 只走 ../lib/weknora.js 的 request()。
5
+ // --dry-run:跑完整流程(含 LLM 裁决),只打印计划,不写库、不写 state、不写报告、不写日志。
6
+ // --no-llm :一次 LLM 请求都不发(配合 --dry-run 做零成本演练)。
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { request } from '../lib/weknora.js';
13
+
14
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
15
+ const ROOT = path.resolve(HERE, '..');
16
+ const DEFAULT_CONFIG = path.join(HERE, 'writer.config.json');
17
+ const EXAMPLE_CONFIG = path.join(HERE, 'config.example.json');
18
+
19
+ const argv = process.argv.slice(2);
20
+ const flag = (n) => argv.includes(n);
21
+ const argVal = (n) => { const i = argv.indexOf(n); return i >= 0 ? String(argv[i + 1] ?? '') : ''; };
22
+
23
+ const DRY = flag('--dry-run') || flag('--dry');
24
+ const NO_LLM = flag('--no-llm');
25
+ const VERBOSE = flag('--verbose');
26
+ const SELF_TEST = flag('--self-test');
27
+ const THEN_PROFILE = flag('--then-profile');
28
+ const CONFIG_ARG = argVal('--config');
29
+
30
+ const USAGE = [
31
+ '用法:node writers/dream.mjs [选项]',
32
+ '',
33
+ '夜间事实卡裁决:只改 header 里的 status / superseded_by,正文逐字节保留。',
34
+ '不新建文档、不删除文档。',
35
+ '',
36
+ '选项:',
37
+ ' --config <path> 配置文件;默认 writers/writer.config.json,缺省回落到 config.example.json',
38
+ ' --dry-run 只打印计划:不写库、不写 state、不写报告(--dry 同义)',
39
+ ' --no-llm 一次 LLM 请求都不发(配合 --dry-run 做零成本演练)',
40
+ ' --verbose 打印每次裁决的完整 prompt 与配置解析结果',
41
+ ' --then-profile 落账成功后紧接着跑 profile-writer.mjs --review --write(dry-run 下不做)',
42
+ ' --self-test 用内置样本跑「裁决 -> 改动计划」映射,不调 API、不写任何文件',
43
+ ' -h, --help 打印本帮助并退出 0',
44
+ '',
45
+ '退出码:0 成功;1 运行期失败(配置/网络/解析);2 有落账未通过复核;3 落账被 maxUpdates 打断。',
46
+ ].join('\n');
47
+
48
+ if (flag('--help') || flag('-h')) {
49
+ console.log(USAGE);
50
+ process.exit(0);
51
+ }
52
+
53
+ // ---------- 配置 ----------
54
+ /** 两层浅合并:example 提供默认值,用户配置只覆盖它写了的部分。 */
55
+ function mergeConfig(base, over) {
56
+ const out = { ...base };
57
+ for (const [key, value] of Object.entries(over || {})) {
58
+ const prev = out[key];
59
+ out[key] = value && typeof value === 'object' && !Array.isArray(value) && prev && typeof prev === 'object'
60
+ ? { ...prev, ...value }
61
+ : value;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function loadConfig(explicit) {
67
+ if (!fs.existsSync(EXAMPLE_CONFIG)) throw new Error('缺少默认配置:' + EXAMPLE_CONFIG);
68
+ let file = '';
69
+ let fallback = false;
70
+ if (explicit) {
71
+ if (!fs.existsSync(explicit)) throw new Error('配置文件不存在:' + explicit);
72
+ file = explicit;
73
+ } else if (fs.existsSync(DEFAULT_CONFIG)) {
74
+ file = DEFAULT_CONFIG;
75
+ } else {
76
+ file = EXAMPLE_CONFIG;
77
+ fallback = true;
78
+ }
79
+ const defaults = JSON.parse(fs.readFileSync(EXAMPLE_CONFIG, 'utf8'));
80
+ const raw = file === EXAMPLE_CONFIG ? defaults : JSON.parse(fs.readFileSync(file, 'utf8'));
81
+ const cfg = mergeConfig(defaults, raw);
82
+ // 凭证只从环境变量取:配置文件里的 apiKey 一律忽略,避免明文密钥进仓库。
83
+ const fromFile = typeof cfg.apiKey === 'string' && cfg.apiKey ? cfg.apiKey : '';
84
+ cfg.apiKey = process.env.WEKNORA_API_KEY || '';
85
+ cfg.__file = file;
86
+ cfg.__fallback = fallback;
87
+ cfg.__ignoredFileKey = fromFile !== '';
88
+ return cfg;
89
+ }
90
+
91
+ let CFG;
92
+ try {
93
+ CFG = loadConfig(CONFIG_ARG);
94
+ } catch (error) {
95
+ console.error('[fail] ' + (error?.message || error));
96
+ process.exit(1);
97
+ }
98
+
99
+ const D = CFG.dream || {};
100
+ const API_KEY_PRESENT = CFG.apiKey !== '';
101
+ const KB_ID = String(CFG.kbs?.knowledge || '');
102
+ const KB = encodeURIComponent(KB_ID);
103
+ const PREFIX = String(D.titlePrefix || '[fact] ');
104
+ const MAX_LLM = Math.max(0, Number(process.env.DREAM_MAX_LLM || D.maxLlmCalls) || 0);
105
+ const MAX_UPDATES = Math.max(0, Number(process.env.DREAM_MAX_UPDATES || D.maxUpdates) || 0);
106
+ const GRP = Object.assign({ searchTopK: 5, queryChars: 120, sameRootScore: 0.5, crossRootScore: 0.6, maxCluster: 5 }, D.grouping || {});
107
+ const REPORT_DIR = path.resolve(ROOT, String(D.reportDir || 'logs/dream'));
108
+ const STATE_PATH = path.resolve(ROOT, String(D.statePath || 'data/dream-state.json'));
109
+ const LOG_PATH = path.resolve(ROOT, String(D.logPath || 'logs/dream.log'));
110
+ const PARSE_TIMEOUT_MS = (Number(D.parseTimeoutSec) || 180) * 1000;
111
+ const LLM_TIMEOUT_MS = Number(CFG.llm?.timeoutMs) || 240000;
112
+ const REQUEST_TIMEOUT_MS = Number(CFG.requestTimeoutMs) || 30000;
113
+
114
+ // ---------- 通用 ----------
115
+ const pad = (n) => String(n).padStart(2, '0');
116
+ const fmtDay = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
117
+ const fmtTs = (ms) => { const d = new Date(ms); return fmtDay(d) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); };
118
+ const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
119
+ const sha1 = (s) => crypto.createHash('sha1').update(String(s), 'utf8').digest('hex');
120
+ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length <= n ? t : t.slice(0, n) + '…'; };
121
+ const BT = String.fromCharCode(96);
122
+ const FENCE = BT + BT + BT;
123
+
124
+ const lines = [];
125
+ function log(msg) {
126
+ lines.push(msg);
127
+ console.log(msg);
128
+ if (DRY) return;
129
+ try {
130
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
131
+ fs.appendFileSync(LOG_PATH, new Date().toISOString() + ' ' + msg + '\n', 'utf8');
132
+ } catch { /* 日志写不进去不该让这一夜失败 */ }
133
+ }
134
+
135
+ function reasonText(res) {
136
+ return String(res.reason || 'unknown') + (res.status ? ' (HTTP ' + res.status + ')' : '');
137
+ }
138
+
139
+ async function api(method, ep, body, opts = {}) {
140
+ const res = await request(CFG, method, ep, {
141
+ body,
142
+ timeoutMs: opts.timeoutMs ?? REQUEST_TIMEOUT_MS,
143
+ fetchImpl: opts.fetchImpl,
144
+ expect: opts.expect,
145
+ });
146
+ if (!res.ok) throw new Error(method + ' ' + ep + ' -> ' + reasonText(res));
147
+ return res.body;
148
+ }
149
+
150
+ // ---------- state ----------
151
+ const state = fs.existsSync(STATE_PATH) ? JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')) : {};
152
+ state.version = 1;
153
+ state.groups = state.groups || {};
154
+ state.counters = Object.assign({ runs: 0, llmCallsTotal: 0, updatesTotal: 0 }, state.counters || {});
155
+ state.history = state.history || [];
156
+ const saveState = () => { if (DRY) return; fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n', 'utf8'); };
157
+
158
+ // ---------- 卡解析(契约 v1:第 1 行是 header 注释行) ----------
159
+ const HEADER_RE = /^<!--\s*fact\s+(\{[\s\S]*\})\s*-->\s*$/;
160
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
161
+
162
+ function splitContent(content) {
163
+ const nl = content.indexOf('\n');
164
+ if (nl < 0) return { head: content, rest: '' };
165
+ return { head: content.slice(0, nl), rest: content.slice(nl) };
166
+ }
167
+
168
+ // 解析失败(无 header / JSON 坏 / 缺必填字段)= legacy 或坏卡:一律跳过,绝不当成卡。
169
+ function parseCard(doc, content) {
170
+ const { head } = splitContent(content);
171
+ const m = HEADER_RE.exec(head.replace(/\r$/, ''));
172
+ if (!m) return { ok: false, why: '无 fact header(legacy 或坏卡)' };
173
+ let f;
174
+ try { f = JSON.parse(m[1]); } catch (e) { return { ok: false, why: 'header JSON 解析失败: ' + e.message }; }
175
+ if (!f || typeof f !== 'object') return { ok: false, why: 'header 不是对象' };
176
+ if (typeof f.subject !== 'string' || !f.subject) return { ok: false, why: 'header 缺 subject' };
177
+ if (!['active', 'superseded', 'disputed'].includes(f.status)) return { ok: false, why: 'header status 非法: ' + JSON.stringify(f.status) };
178
+ if (!DATE_RE.test(String(f.valid_from || ''))) return { ok: false, why: 'header valid_from 非法: ' + JSON.stringify(f.valid_from) };
179
+ const body = content.split('\n').map(s => s.trim());
180
+ const claim = body.find(s => s && !s.startsWith('<!--') && !s.startsWith('#') && !s.startsWith('证据:')) || '';
181
+ const title = String(doc.title || '');
182
+ return { ok: true, fields: f, claim, shortTitle: title.startsWith(PREFIX) ? title.slice(PREFIX.length) : title, root: f.subject.split('.')[0] };
183
+ }
184
+
185
+ function buildHeader(fields) {
186
+ return '<!-- fact ' + JSON.stringify(fields) + ' -->';
187
+ }
188
+
189
+ // ---------- S0 收拢 ----------
190
+ async function listAllDocs() {
191
+ const out = [];
192
+ for (let page = 1; page <= 20; page++) {
193
+ const j = await api('GET', '/knowledge-bases/' + KB + '/knowledge?page=' + page + '&page_size=100');
194
+ const data = (j && j.data) || [];
195
+ out.push(...data);
196
+ if (data.length < 100) break;
197
+ }
198
+ return out;
199
+ }
200
+
201
+ async function loadCards() {
202
+ const docs = await listAllDocs();
203
+ const cardDocs = docs.filter(d => String(d.title || '').startsWith(PREFIX));
204
+ const cards = [];
205
+ const skipped = [];
206
+ for (const d of cardDocs) {
207
+ try {
208
+ const j = await api('GET', '/knowledge/' + encodeURIComponent(d.id));
209
+ const dd = (j && j.data) || {};
210
+ const content = ((dd.metadata || {}).content) || '';
211
+ if (!content) { skipped.push({ id: d.id, title: d.title, why: 'metadata.content 为空,跳过(绝不当成卡)' }); continue; }
212
+ const p = parseCard(d, content);
213
+ if (!p.ok) { skipped.push({ id: d.id, title: d.title, why: p.why }); continue; }
214
+ cards.push({
215
+ id: d.id, title: String(d.title || ''), content, fields: p.fields, claim: p.claim,
216
+ shortTitle: p.shortTitle, root: p.root, validFrom: String(p.fields.valid_from),
217
+ beforeSha: sha256(content)
218
+ });
219
+ } catch (e) {
220
+ skipped.push({ id: d.id, title: d.title, why: 'GET 失败: ' + e.message });
221
+ }
222
+ }
223
+ return { docs, cardDocs, cards, skipped };
224
+ }
225
+
226
+ // ---------- S1 分组:根前缀粗分组 + 库内检索形成候选组 ----------
227
+ async function searchNear(claim) {
228
+ const j = await api('POST', '/knowledge-search', {
229
+ query: String(claim).slice(0, GRP.queryChars),
230
+ knowledge_base_ids: [KB_ID],
231
+ max_results: GRP.searchTopK
232
+ });
233
+ const out = new Map();
234
+ for (const h of ((j && j.data) || [])) {
235
+ const id = String(h.knowledge_id || '');
236
+ if (!id) continue;
237
+ const s = Number(h.score || 0);
238
+ if (!out.has(id) || s > out.get(id)) out.set(id, s);
239
+ }
240
+ return out;
241
+ }
242
+
243
+ class UnionFind {
244
+ constructor(keys) { this.p = new Map(keys.map(k => [k, k])); }
245
+ find(x) {
246
+ let r = x;
247
+ while (this.p.get(r) !== r) r = this.p.get(r);
248
+ while (this.p.get(x) !== r) { const n = this.p.get(x); this.p.set(x, r); x = n; }
249
+ return r;
250
+ }
251
+ union(a, b) { const ra = this.find(a), rb = this.find(b); if (ra !== rb) this.p.set(ra, rb); }
252
+ }
253
+
254
+ async function buildClusters(cards) {
255
+ const byId = new Map(cards.map(c => [c.id, c]));
256
+ const edge = new Map();
257
+ for (const c of cards) {
258
+ let hits;
259
+ try { hits = await searchNear(c.claim); } catch (e) { log('[warn] knowledge-search 失败(' + c.fields.subject + '): ' + e.message); continue; }
260
+ for (const [id, s] of hits) {
261
+ if (id === c.id || !byId.has(id)) continue;
262
+ edge.set(c.id + '|' + id, s);
263
+ }
264
+ }
265
+ const getEdge = (a, b) => edge.get(a + '|' + b) || 0;
266
+ const uf = new UnionFind(cards.map(c => c.id));
267
+ const pairNotes = [];
268
+ for (let i = 0; i < cards.length; i++) {
269
+ for (let j = i + 1; j < cards.length; j++) {
270
+ const a = cards[i], b = cards[j];
271
+ const sab = getEdge(a.id, b.id), sba = getEdge(b.id, a.id);
272
+ if (!sab && !sba) continue;
273
+ const same = a.root === b.root;
274
+ const merged = same ? Math.max(sab, sba) >= GRP.sameRootScore : Math.min(sab, sba) >= GRP.crossRootScore;
275
+ if (merged) {
276
+ uf.union(a.id, b.id);
277
+ pairNotes.push({ a: a.fields.subject, b: b.fields.subject, sab: Number(sab.toFixed(3)), sba: Number(sba.toFixed(3)), sameRoot: same });
278
+ } else if (!same && Math.max(sab, sba) >= GRP.crossRootScore) {
279
+ // 跨根只认双向命中;单边高分不合并,但记一笔,免得静默漏掉真冲突。
280
+ log('[S1] 跨根单边命中未合并 ' + a.fields.subject + ' <-> ' + b.fields.subject + ' (' + sab.toFixed(3) + ' / ' + sba.toFixed(3) + ')');
281
+ }
282
+ }
283
+ }
284
+ const buckets = new Map();
285
+ for (const c of cards) {
286
+ const r = uf.find(c.id);
287
+ if (!buckets.has(r)) buckets.set(r, []);
288
+ buckets.get(r).push(c);
289
+ }
290
+ const clusters = [...buckets.values()].filter(g => g.length >= 2);
291
+ const oversized = [];
292
+ for (const g of clusters) {
293
+ g.sort((x, y) => (y.validFrom.localeCompare(x.validFrom)) || x.id.localeCompare(y.id));
294
+ if (g.length > GRP.maxCluster) { oversized.push({ root: g[0].root, size: g.length }); g.splice(GRP.maxCluster); }
295
+ }
296
+ return { clusters, pairNotes, oversized };
297
+ }
298
+
299
+ function fingerprintOf(members) {
300
+ const parts = members.map(c => c.id + '|' + c.validFrom + '|' + sha1(c.claim).slice(0, 8)).sort();
301
+ return sha1(parts.join(';')).slice(0, 16);
302
+ }
303
+
304
+ // ---------- S2 裁决 ----------
305
+ let llmCalls = 0;
306
+ let llmAborted = '';
307
+
308
+ /**
309
+ * 一次裁决:建一个 WeKnora 会话,再走 knowledge-chat 的 SSE。
310
+ * SSE 是纯文本,request() 只会 json():这里用 fetchImpl 抓住原始响应文本,
311
+ * 请求头与错误映射仍然全部由 lib/weknora.js 决定。
312
+ */
313
+ async function chatOnce(chatSessionId, prompt) {
314
+ const held = { text: '' };
315
+ const fetchImpl = async (url, init) => {
316
+ const response = await fetch(url, init);
317
+ held.text = await response.text();
318
+ return { ok: response.ok, status: response.status, json: async () => ({}) };
319
+ };
320
+ const res = await request(CFG, 'POST', '/knowledge-chat/' + encodeURIComponent(chatSessionId), {
321
+ body: { query: prompt, knowledge_base_ids: [], agent_enabled: false, web_search_enabled: false },
322
+ timeoutMs: LLM_TIMEOUT_MS,
323
+ fetchImpl
324
+ });
325
+ if (!res.ok) throw new Error('knowledge-chat -> ' + reasonText(res));
326
+ let text = '';
327
+ for (const line of held.text.split('\n')) {
328
+ if (!line.startsWith('data:')) continue;
329
+ let o;
330
+ try { o = JSON.parse(line.slice(5).trim()); } catch { continue; }
331
+ if (o.response_type === 'answer' && typeof o.content === 'string') text += o.content;
332
+ if (o.response_type === 'error') throw new Error('LLM error event: ' + JSON.stringify(o).slice(0, 200));
333
+ }
334
+ return text;
335
+ }
336
+
337
+ async function llmOnce(prompt) {
338
+ if (NO_LLM) throw new Error('--no-llm:本轮禁止调用 LLM');
339
+ if (llmCalls >= MAX_LLM) throw new Error('LLM 预算用尽:' + llmCalls + '/' + MAX_LLM + '(config.dream.maxLlmCalls)');
340
+ llmCalls++;
341
+ const sj = await api('POST', '/sessions', { title: 'dream ' + new Date().toISOString().slice(0, 16) }, { timeoutMs: LLM_TIMEOUT_MS });
342
+ const chatSessionId = sj && sj.data && sj.data.id;
343
+ if (!chatSessionId) throw new Error('创建 WeKnora 会话失败: ' + JSON.stringify(sj).slice(0, 200));
344
+ return chatOnce(chatSessionId, prompt);
345
+ }
346
+
347
+ function buildJudgePrompt(members) {
348
+ const list = members.map((c, i) => [
349
+ '[' + (i + 1) + '] 标题: ' + c.title,
350
+ ' subject: ' + c.fields.subject,
351
+ ' valid_from: ' + c.validFrom,
352
+ ' status: ' + c.fields.status,
353
+ ' claim: ' + c.claim
354
+ ].join('\n')).join('\n\n');
355
+ return [
356
+ '你是记忆库的事实卡裁决器。下面是一组主题相近的事实卡,每张卡是一个独立文档。',
357
+ '只需要判断这几张卡之间的语义关系,输出一行结论 + 不超过 20 字的理由。',
358
+ '',
359
+ '结论只能取以下五个之一(照抄英文单词):',
360
+ 'NEWER_SUPERSEDES: <被取代的卡片标题> 同一事实有了更新版本,旧卡的整体结论已被新卡推翻或更新',
361
+ 'CONFLICT 两张卡的结论互相矛盾,且无法判断谁更新更对',
362
+ 'DUPLICATE 同一事实被重复记录,信息等价',
363
+ 'COMPLEMENT 各说一面,合起来更完整,不冲突',
364
+ 'KEEP_ALL 主题相近但各自独立,互不影响',
365
+ '',
366
+ '额外硬规则:',
367
+ '1) NEWER_SUPERSEDES 只在「保留卡 valid_from 严格晚于被取代卡」且两张卡讲同一件事时使用;日期相同或更早一律不许用。',
368
+ '2) 两张卡讲不同主题、互不影响 -> 必须 KEEP_ALL,不许用 NEWER_SUPERSEDES。',
369
+ '3) 被取代的标题必须原样来自下面列表里某一行的标题,不许编造或截断。',
370
+ '',
371
+ '输出格式:第 1 行 = 结论(NEWER_SUPERSEDES 时必须原样复制被取代卡片的「标题」,含 [fact] 前缀);第 1 行末尾空一格写不超过 20 字的理由。',
372
+ '不要输出第 2 行,不要代码块,不要解释过程。',
373
+ '',
374
+ '示例:',
375
+ 'NEWER_SUPERSEDES: [fact] env.endpoints · 记忆栈端口 端口号已由新卡更新',
376
+ 'COMPLEMENT 一个讲链路一个讲口径',
377
+ '',
378
+ '卡片列表:',
379
+ list
380
+ ].join('\n');
381
+ }
382
+
383
+ const normKey = (s) => String(s || '').toLowerCase().replace(/[\u0060\[\]\s"'“”‘’·—\-_/\\|::,,.。()()]/g, '');
384
+
385
+ function parseVerdict(raw) {
386
+ for (const line of String(raw || '').split('\n')) {
387
+ const t = line.trim().replace(/^\*+|\*+$/g, '');
388
+ if (!t) continue;
389
+ const m = /^(NEWER_SUPERSEDES|CONFLICT|DUPLICATE|COMPLEMENT|KEEP_ALL)\s*[::]?\s*(.*)$/i.exec(t);
390
+ if (!m) continue;
391
+ const verdict = m[1].toUpperCase();
392
+ const rest = m[2].trim().replace(/[\u0060"']/g, '');
393
+ return { verdict, rest, reason: rest.trim() };
394
+ }
395
+ return { verdict: '', rest: '', reason: '', raw: String(raw || '').slice(0, 200) };
396
+ }
397
+
398
+ // 把模型给的目标标题对回组内成员:精确 -> 去前缀包含 -> bigram 兜底。
399
+ function resolveTarget(rest, members) {
400
+ const cand = String(rest || '').replace(/^\[fact\]\s*/i, '').trim();
401
+ if (!cand) return null;
402
+ const kc = normKey(cand);
403
+ let hit = members.find(c => c.title === cand)
404
+ || members.find(c => normKey(c.shortTitle) === kc)
405
+ || members.find(c => normKey(c.title) === kc);
406
+ if (hit) return hit;
407
+ hit = members.find(c => { const k = normKey(c.shortTitle); return k && (kc.startsWith(k) || k.startsWith(kc) || kc.includes(k)); });
408
+ if (hit) return hit;
409
+ let best = null, bestScore = 0;
410
+ for (const c of members) {
411
+ const a = normKey(c.shortTitle);
412
+ const A = new Set();
413
+ for (let i = 0; i + 1 < a.length; i++) A.add(a.slice(i, i + 2));
414
+ let inter = 0;
415
+ for (let i = 0; i + 1 < kc.length; i++) if (A.has(kc.slice(i, i + 2))) inter++;
416
+ const denom = Math.max(1, Math.min(a.length, kc.length) - 1);
417
+ const score = inter / denom;
418
+ if (score > bestScore) { bestScore = score; best = c; }
419
+ }
420
+ return bestScore >= 0.5 ? best : null;
421
+ }
422
+
423
+ // ---------- 裁决 -> 改动计划 ----------
424
+ function planChanges(members, verdictInfo) {
425
+ const v = verdictInfo.verdict;
426
+ const changes = [];
427
+ if (!v) return { changes, note: '未知结论,按 KEEP_ALL 处理' };
428
+ if (v === 'KEEP_ALL' || v === 'COMPLEMENT') return { changes, note: '' };
429
+ if (v === 'NEWER_SUPERSEDES') {
430
+ const old = verdictInfo.target;
431
+ if (!old) return { changes, note: '目标标题无法对回组内成员,按 KEEP_ALL 处理(不猜)' };
432
+ const rest = members.filter(c => c.id !== old.id);
433
+ if (!rest.length) return { changes, note: 'NEWER_SUPERSEDES 但没有其它成员可作为新卡,按 KEEP_ALL 处理' };
434
+ const newer = rest[0];
435
+ // 只认严格更新的证据:valid_from 相等或倒挂都视为「没有更新」,一律不改。
436
+ if (!(newer.validFrom > old.validFrom)) return { changes, note: '保留卡 valid_from ' + newer.validFrom + ' 不晚于被取代卡 ' + old.validFrom + ',无「更新」证据,按 KEEP_ALL 处理' };
437
+ changes.push({ card: old, status: 'superseded', supersededBy: newer.shortTitle, why: '被「' + newer.shortTitle + '」取代' });
438
+ return { changes, note: '' };
439
+ }
440
+ if (v === 'DUPLICATE') {
441
+ const keep = members[0];
442
+ for (const c of members.slice(1)) changes.push({ card: c, status: 'superseded', supersededBy: keep.shortTitle, why: '与「' + keep.shortTitle + '」重复,保留排名靠前者' });
443
+ return { changes, note: '' };
444
+ }
445
+ if (v === 'CONFLICT') {
446
+ for (const c of members) changes.push({ card: c, status: 'disputed', supersededBy: String(c.fields.superseded_by || ''), why: '组内结论互相矛盾,双方标 disputed' });
447
+ return { changes, note: '' };
448
+ }
449
+ return { changes, note: '未识别结论 ' + v };
450
+ }
451
+
452
+ // ---------- S3 落账 ----------
453
+ let updates = 0;
454
+ let updateAborted = '';
455
+
456
+ async function waitParsed(id) {
457
+ const deadline = Date.now() + PARSE_TIMEOUT_MS;
458
+ let last = '';
459
+ while (Date.now() < deadline) {
460
+ const j = await api('GET', '/knowledge/' + encodeURIComponent(id));
461
+ const d = (j && j.data) || {};
462
+ last = d.parse_status || '';
463
+ if (last === 'completed') return { status: last };
464
+ if (last === 'failed' || last === 'error') return { status: last, error: d.error_message || '' };
465
+ await new Promise(r => setTimeout(r, 1500));
466
+ }
467
+ return { status: 'timeout(' + last + ')' };
468
+ }
469
+
470
+ async function chunkTotal(id) {
471
+ const j = await api('GET', '/chunks/' + encodeURIComponent(id) + '?page=1&page_size=1');
472
+ if (j && typeof j.total === 'number') return j.total;
473
+ return Array.isArray(j && j.data) ? j.data.length : 0;
474
+ }
475
+
476
+ async function applyChange(card, status, supersededBy) {
477
+ const { rest } = splitContent(card.content);
478
+ const fields = Object.assign({}, card.fields, { status, superseded_by: supersededBy });
479
+ const newHead = buildHeader(fields);
480
+ const newContent = newHead + rest;
481
+ const beforeSha = card.beforeSha;
482
+ const chunksBefore = await chunkTotal(card.id);
483
+ updates++;
484
+ const j = await api('PUT', '/knowledge/manual/' + encodeURIComponent(card.id), {
485
+ title: card.title, content: newContent, status: 'publish', channel: 'web', file_name: card.title + '.md'
486
+ });
487
+ const putOk = !!(j && j.data && j.data.id);
488
+ const parsed = await waitParsed(card.id);
489
+ const g = await api('GET', '/knowledge/' + encodeURIComponent(card.id));
490
+ const got = ((g.data || {}).metadata || {}).content || '';
491
+ const verify = { sha: sha256(got), bytesEqual: got === newContent, headerEqual: splitContent(got).head === newHead };
492
+ const reParsed = parseCard({ id: card.id, title: card.title }, got);
493
+ verify.statusNow = reParsed.ok ? reParsed.fields.status : 'PARSE_FAIL';
494
+ verify.supersededByNow = reParsed.ok ? String(reParsed.fields.superseded_by || '') : '';
495
+ const chunksAfter = await chunkTotal(card.id);
496
+ const ok = putOk && parsed.status === 'completed' && verify.bytesEqual && verify.headerEqual
497
+ && verify.statusNow === status && verify.supersededByNow === String(supersededBy || '') && chunksAfter >= 1;
498
+ const rec = {
499
+ id: card.id, title: card.title, subject: fields.subject,
500
+ statusBefore: card.fields.status, statusAfter: status,
501
+ supBefore: String(card.fields.superseded_by || ''), supAfter: String(supersededBy || ''),
502
+ shaBefore: beforeSha, shaAfter: verify.sha, bytesEqual: verify.bytesEqual, headerEqual: verify.headerEqual,
503
+ bodyShaBefore: sha256(rest), bodyShaAfter: sha256(splitContent(got).rest), bodyLen: rest.length,
504
+ chunksBefore, chunksAfter, parse: parsed.status, ok
505
+ };
506
+ card.fields = fields;
507
+ card.beforeSha = verify.sha;
508
+ return rec;
509
+ }
510
+
511
+ // ---------- S4 报告 ----------
512
+ function buildReport(ctx) {
513
+ const L = [];
514
+ L.push('# 夜间做梦报告 ' + ctx.day);
515
+ L.push('');
516
+ L.push('- 模式:' + (DRY ? 'dry-run(不写库、不写 state、不写报告)' : 'real'));
517
+ L.push('- 知识库:' + KB_ID + '(全部 ' + ctx.totalDocs + ' 篇,其中 ' + PREFIX + ' 卡 ' + ctx.cardDocs.length + ' 张)');
518
+ L.push('- 卡解析:成功 ' + ctx.cards.length + ' 张,跳过 ' + ctx.skipped.length + ' 张');
519
+ L.push('- 候选分组:' + ctx.clusters.length + ' 组;state 命中已判定跳过 ' + ctx.skippedGroups.length + ' 组');
520
+ L.push('- LLM 调用:' + ctx.llmCalls + ' / ' + MAX_LLM + (ctx.llmAborted ? '(' + ctx.llmAborted + ')' : ''));
521
+ L.push('- 文档改动:' + ctx.updates + ' / ' + MAX_UPDATES + (ctx.updateAborted ? '(' + ctx.updateAborted + ')' : ''));
522
+ L.push('- 耗时:' + (ctx.elapsedMs / 1000).toFixed(1) + ' 秒');
523
+ L.push('- 新增卡:无(本工具只改状态,不新建文档、不删除文档)');
524
+ L.push('');
525
+ L.push('## 分组与裁决');
526
+ L.push('');
527
+ if (!ctx.judged.length) { L.push('(无候选组)'); L.push(''); }
528
+ for (const g of ctx.judged) {
529
+ L.push('### 组 ' + g.index + ':' + g.members.map(c => c.fields.subject).join(' + '));
530
+ L.push('');
531
+ L.push('- 成员:' + g.members.map(c => c.fields.subject + '(' + c.validFrom + ',' + c.fields.status + ')').join(';'));
532
+ L.push('- 组内检索边:' + (g.edges || '无'));
533
+ L.push('- 指纹:' + g.fingerprint);
534
+ L.push('- 裁决:**' + (g.verdict || 'NONE') + '**' + (g.reason ? ' —— ' + clip(g.reason, 60) : ''));
535
+ if (g.note) L.push('- 备注:' + g.note);
536
+ L.push('- 计划改动:' + (g.plan.length ? g.plan.map(p => p.card.fields.subject + ' → ' + p.status).join(';') : '不动'));
537
+ L.push('');
538
+ }
539
+ if (ctx.skippedGroups.length) {
540
+ L.push('### state 命中、本轮跳过的组');
541
+ L.push('');
542
+ for (const s of ctx.skippedGroups) L.push('- ' + s.fingerprint + ' ' + s.members + '(上次裁决 ' + s.verdict + ',' + s.at + ')');
543
+ L.push('');
544
+ }
545
+ L.push('## 改动明细');
546
+ L.push('');
547
+ if (!ctx.changes.length) { L.push('(无)'); L.push(''); }
548
+ else {
549
+ L.push('| 文档 id | 标题 | status 前 → 后 | superseded_by 前 → 后 | 正文 sha256 前 → 后 | 正文逐字节相同 | chunks |');
550
+ L.push('|---|---|---|---|---|---|---|');
551
+ for (const c of ctx.changes) {
552
+ L.push('| ' + c.id + ' | ' + c.title + ' | ' + c.statusBefore + ' → ' + c.statusAfter + ' | ' + (c.supBefore || '(空)') + ' → ' + (c.supAfter || '(空)') + ' | ' + c.shaBefore.slice(0, 16) + ' → ' + c.shaAfter.slice(0, 16) + ' | ' + (c.bytesEqual ? '是' : '**否**') + ' | ' + c.chunksBefore + ' → ' + c.chunksAfter + ' |');
553
+ }
554
+ L.push('');
555
+ }
556
+ L.push('## 异常与跳过');
557
+ L.push('');
558
+ const an = [];
559
+ for (const s of ctx.skipped) an.push('- 跳过卡 ' + s.id + ' ' + s.title + ':' + s.why);
560
+ for (const c of ctx.changes) if (!c.ok) an.push('- **写入复核未通过** ' + c.id + ' ' + c.title + ':bytesEqual=' + c.bytesEqual + ' headerEqual=' + c.headerEqual + ' statusNow=' + c.statusAfter + ' parse=' + c.parse);
561
+ if (ctx.llmAborted) an.push('- ' + ctx.llmAborted);
562
+ if (ctx.updateAborted) an.push('- ' + ctx.updateAborted);
563
+ for (const o of ctx.oversized) an.push('- 组超过 maxCluster ' + GRP.maxCluster + '(' + o.root + ' 组 ' + o.size + ' 张),已截断后裁决');
564
+ for (const g of ctx.judged) if (g.note) an.push('- ' + g.members[0].fields.subject + ' 组:' + g.note);
565
+ if (!an.length) an.push('(无)');
566
+ L.push(...an);
567
+ L.push('');
568
+ L.push('## 本次日志');
569
+ L.push('');
570
+ L.push(FENCE);
571
+ L.push(...ctx.logLines);
572
+ L.push(FENCE);
573
+ L.push('');
574
+ return L.join('\n');
575
+ }
576
+
577
+ // ---------- 自检(--self-test):用内置样本跑「裁决 -> 改动计划」映射,不调 API、不写任何文件 ----------
578
+ function selfTest() {
579
+ const mk = (short, subject, vf) => {
580
+ const fields = { v: 1, subject, status: 'active', valid_from: vf, superseded_by: '', confidence: 0.8, session: '', quote: '' };
581
+ return { id: short, title: PREFIX + short, shortTitle: short, fields, claim: '正文', root: subject.split('.')[0], validFrom: vf,
582
+ content: buildHeader(fields) + '\n\n# ' + short + '\n\n正文\n' };
583
+ };
584
+ const oldC = mk('mem.inject · 旧口径', 'mem.inject', '2026-09-13');
585
+ const newC = mk('mem.inject · 新口径', 'mem.inject', '2026-11-01');
586
+ const members = [oldC, newC];
587
+ const judge = (raw) => { const v = parseVerdict(raw); return { verdict: v.verdict, reason: v.reason, target: resolveTarget(v.rest, members) }; };
588
+ const cases = [];
589
+ const sup = planChanges(members, judge('NEWER_SUPERSEDES: mem.inject · 旧口径\n理由:新口径覆盖旧口径'));
590
+ cases.push(['NEWER_SUPERSEDES 指到旧卡 -> 旧卡 superseded 且指向新卡', sup.changes.length === 1 && sup.changes[0].card === oldC && sup.changes[0].status === 'superseded' && sup.changes[0].supersededBy === newC.shortTitle]);
591
+ const bad1 = planChanges(members, judge('NEWER_SUPERSEDES: mem.inject · 新口径'));
592
+ cases.push(['NEWER_SUPERSEDES 指到更新卡 -> 拒绝改动', bad1.changes.length === 0 && /不晚于/.test(bad1.note)]);
593
+ const e1c = mk('oss.a · 甲', 'oss.a', '2026-09-13');
594
+ const e2c = mk('oss.a · 乙', 'oss.a', '2026-09-13');
595
+ const vEq = parseVerdict('NEWER_SUPERSEDES: oss.a · 甲');
596
+ const eq1 = planChanges([e1c, e2c], { verdict: vEq.verdict, reason: vEq.reason, target: resolveTarget(vEq.rest, [e1c, e2c]) });
597
+ cases.push(['valid_from 相等 -> 不许 supersede(等日期误判的回归用例)', eq1.changes.length === 0 && /不晚于/.test(eq1.note)]);
598
+ const dup = planChanges(members, judge('DUPLICATE: 同一件事'));
599
+ cases.push(['DUPLICATE -> 排名靠后者 superseded 指向保留者', dup.changes.length === 1 && dup.changes[0].card === newC && dup.changes[0].supersededBy === oldC.shortTitle]);
600
+ const con = planChanges(members, judge('CONFLICT: 互相矛盾'));
601
+ cases.push(['CONFLICT -> 双方 disputed', con.changes.length === 2 && con.changes.every(c => c.status === 'disputed')]);
602
+ cases.push(['COMPLEMENT -> 不动', planChanges(members, judge('COMPLEMENT: 互补')).changes.length === 0]);
603
+ cases.push(['KEEP_ALL -> 不动', planChanges(members, judge('KEEP_ALL: 无关')).changes.length === 0]);
604
+ cases.push(['无法识别的回复 -> 按 KEEP_ALL', planChanges(members, judge('我觉得都挺好')).changes.length === 0]);
605
+ const f2 = Object.assign({}, oldC.fields, { status: 'superseded', superseded_by: 'X' });
606
+ const h2 = buildHeader(f2); const m2 = HEADER_RE.exec(h2);
607
+ cases.push(['header 渲染 -> 解析 往返一致', !!m2 && JSON.stringify(JSON.parse(m2[1])) === JSON.stringify(f2)]);
608
+ cases.push(['正文切分不含 header', splitContent(oldC.content).rest === '\n\n# mem.inject · 旧口径\n\n正文\n']);
609
+ let bad = 0;
610
+ for (const [name, ok] of cases) { console.log((ok ? 'PASS ' : 'FAIL ') + name); if (!ok) bad++; }
611
+ console.log('self-test ' + (cases.length - bad) + '/' + cases.length + ' passed');
612
+ return bad ? 1 : 0;
613
+ }
614
+
615
+ // ---------- 主流程 ----------
616
+ async function main() {
617
+ const t0 = Date.now();
618
+ const day = fmtDay(new Date());
619
+ if (VERBOSE) {
620
+ console.log('[config] ' + CFG.__file + (CFG.__fallback ? '(未找到 writer.config.json,回落到 config.example.json 的默认值)' : ''));
621
+ console.log('[config] apiUrl=' + String(CFG.apiUrl || '(空)') + ' apiKey=' + (API_KEY_PRESENT ? '(来自 WEKNORA_API_KEY)' : '(未设置)')
622
+ + (CFG.__ignoredFileKey ? ' 配置文件里的 apiKey 已忽略' : ''));
623
+ console.log('[config] kb=' + (KB_ID || '(空)') + ' prefix=' + JSON.stringify(PREFIX) + ' maxLlm=' + MAX_LLM + ' maxUpdates=' + MAX_UPDATES);
624
+ console.log('[config] state=' + STATE_PATH + ' reportDir=' + REPORT_DIR);
625
+ }
626
+ if (!KB_ID) throw new Error('config.kbs.knowledge 为空:没有可裁决的知识库');
627
+ log('== dream start ' + fmtTs(t0) + (DRY ? ' [DRY-RUN]' : '') + (NO_LLM ? ' [NO-LLM]' : '') + ' maxLlm=' + MAX_LLM + ' maxUpdates=' + MAX_UPDATES);
628
+
629
+ const { docs, cardDocs, cards, skipped } = await loadCards();
630
+ log('[S0] 知识库文档 ' + docs.length + ' 篇,' + PREFIX + '卡片 ' + cardDocs.length + ' 张,解析成功 ' + cards.length + ',跳过 ' + skipped.length);
631
+ for (const s of skipped) log('[S0] 跳过 ' + String(s.id).slice(0, 8) + ' ' + clip(s.title, 40) + ':' + s.why);
632
+
633
+ if (D.enabled === false) {
634
+ log('[stop] config.dream.enabled=false,未做任何判定');
635
+ return 0;
636
+ }
637
+
638
+ const { clusters, pairNotes, oversized } = await buildClusters(cards);
639
+ log('[S1] 组内检索 ' + cards.length + ' 次,形成候选组 ' + clusters.length + ' 个' + (oversized.length ? '(' + oversized.length + ' 组超限被截断)' : ''));
640
+ for (const p of pairNotes) log('[S1] 边 ' + p.a + ' <-> ' + p.b + ' (' + p.sab + ' / ' + p.sba + ',' + (p.sameRoot ? '同根' : '跨根') + ')');
641
+
642
+ const judged = [];
643
+ const skippedGroups = [];
644
+ const changes = [];
645
+ for (let gi = 0; gi < clusters.length; gi++) {
646
+ const members = clusters[gi];
647
+ const fp = fingerprintOf(members);
648
+ const subj = members.map(c => c.fields.subject);
649
+ const edges = pairNotes.filter(p => subj.includes(p.a) && subj.includes(p.b)).map(p => p.a + '/' + p.b + ' ' + p.sab + '·' + p.sba).join(';');
650
+ const prev = state.groups[fp];
651
+ if (prev) {
652
+ skippedGroups.push({ fingerprint: fp, members: subj.join(' + '), verdict: prev.verdict, at: prev.at });
653
+ log('[S2] 组 ' + (gi + 1) + ' 指纹 ' + fp + ' 已判定过(' + prev.at + ' ' + prev.verdict + '),跳过');
654
+ continue;
655
+ }
656
+ if (llmAborted) { log('[S2] 组 ' + (gi + 1) + ' 未裁决:' + llmAborted); continue; }
657
+ let verdictInfo = { verdict: '', reason: '', target: null, raw: '' };
658
+ try {
659
+ const prompt = buildJudgePrompt(members);
660
+ if (VERBOSE) console.log('\n--- PROMPT ---\n' + prompt + '\n--- END PROMPT ---\n');
661
+ const raw = await llmOnce(prompt);
662
+ verdictInfo = parseVerdict(raw);
663
+ verdictInfo.raw = String(raw || '');
664
+ if (verdictInfo.verdict === 'NEWER_SUPERSEDES') verdictInfo.target = resolveTarget(verdictInfo.rest, members);
665
+ log('[S2] 组 ' + (gi + 1) + ' ' + subj.join('+') + ' => ' + (verdictInfo.verdict || 'NONE') + (verdictInfo.target ? ' 目标=' + verdictInfo.target.fields.subject : '') + (verdictInfo.reason ? ' 理由=' + clip(verdictInfo.reason, 40) : ''));
666
+ } catch (e) {
667
+ const msg = e.message || String(e);
668
+ if (/预算用尽|maxLlmCalls/.test(msg)) llmAborted = msg;
669
+ log('[S2] 组 ' + (gi + 1) + ' 裁决失败:' + msg);
670
+ judged.push({ index: gi + 1, members, edges, fingerprint: fp, verdict: '', reason: msg, note: '裁决失败', plan: [], raw: '' });
671
+ continue;
672
+ }
673
+ const planRes = planChanges(members, verdictInfo);
674
+ const plan = planRes.changes.map(p => ({ card: p.card, status: p.status, supersededBy: p.supersededBy, why: p.why }));
675
+ judged.push({ index: gi + 1, members, edges, fingerprint: fp, verdict: verdictInfo.verdict || 'NONE', reason: verdictInfo.reason, note: planRes.note, plan, raw: verdictInfo.raw });
676
+ if (!DRY) {
677
+ state.groups[fp] = {
678
+ at: fmtTs(Date.now()), verdict: verdictInfo.verdict || 'NONE', reason: clip(verdictInfo.reason, 60),
679
+ members: members.map(c => ({ id: c.id, subject: c.fields.subject, valid_from: c.validFrom })),
680
+ planned: plan.map(p => ({ id: p.card.id, status: p.status, superseded_by: p.supersededBy }))
681
+ };
682
+ saveState();
683
+ }
684
+ }
685
+
686
+ const finalPlan = new Map();
687
+ for (const g of judged) for (const p of g.plan) finalPlan.set(p.card.id, p);
688
+ const planned = [...finalPlan.values()];
689
+ for (const p of planned) {
690
+ const needStatus = p.status !== p.card.fields.status;
691
+ const needSup = String(p.supersededBy || '') !== String(p.card.fields.superseded_by || '');
692
+ p.noop = !needStatus && !needSup;
693
+ }
694
+ log('[S2] 判定计划 ' + planned.length + ' 条,其中无需改动的 ' + planned.filter(p => p.noop).length + ' 条');
695
+
696
+ if (DRY) {
697
+ console.log('\n===== DRY-RUN 计划(不写库、不写 state、不写报告) =====');
698
+ console.log('候选组 ' + judged.length + ' 个(另有 ' + skippedGroups.length + ' 组因 state 指纹已判定过而跳过)');
699
+ for (const g of judged) {
700
+ console.log('\n--- 组 ' + g.index + ':' + g.members.map(c => c.fields.subject).join(' + ') + ' ---');
701
+ console.log(' 指纹 : ' + g.fingerprint);
702
+ console.log(' 检索边: ' + (g.edges || '无'));
703
+ for (const c of g.members) console.log(' 成员 : ' + c.fields.subject + ' | ' + c.validFrom + ' | ' + c.fields.status + ' | ' + c.shortTitle);
704
+ console.log(' 裁决 : ' + g.verdict + (g.reason ? ' —— ' + clip(g.reason, 60) : ''));
705
+ if (g.note) console.log(' 备注 : ' + g.note);
706
+ console.log(' 计划 : ' + (g.plan.length ? g.plan.map(p => p.card.fields.subject + ' → ' + p.status + (p.supersededBy ? '(superseded_by=' + p.supersededBy + ')' : '')).join(';') : '不动'));
707
+ }
708
+ console.log('\n--- 待写文档 ' + planned.filter(p => !p.noop).length + ' 篇(上限 ' + MAX_UPDATES + ')---');
709
+ for (const p of planned) {
710
+ console.log(' ' + (p.noop ? '[skip] ' : '[plan] ') + p.card.id + ' | ' + p.card.fields.subject + ' | ' + p.card.fields.status + ' → ' + p.status + ' | superseded_by "' + String(p.card.fields.superseded_by || '') + '" → "' + String(p.supersededBy || '') + '" | ' + p.why);
711
+ }
712
+ console.log('\nLLM 调用 ' + llmCalls + ' / ' + MAX_LLM + ',PUT 0 次(dry-run),耗时 ' + ((Date.now() - t0) / 1000).toFixed(1) + ' 秒');
713
+ console.log('===== END DRY-RUN =====');
714
+ return 0;
715
+ }
716
+
717
+ // S3 落账
718
+ for (const p of planned) {
719
+ if (p.noop) { log('[S3] 跳过 ' + p.card.fields.subject + ':header 已是目标状态'); continue; }
720
+ if (updates >= MAX_UPDATES) { updateAborted = '已达 maxUpdates ' + MAX_UPDATES + ',停止写入'; log('[S3] ' + updateAborted); break; }
721
+ try {
722
+ const before = p.card.fields.status + '/' + String(p.card.fields.superseded_by || '');
723
+ const rec = await applyChange(p.card, p.status, p.supersededBy);
724
+ changes.push(rec);
725
+ log('[S3] ' + (rec.ok ? 'ok ' : 'WARN ') + p.card.fields.subject + ' ' + before + ' -> ' + rec.statusAfter + '/' + rec.supAfter +
726
+ ' 正文相同=' + rec.bytesEqual + ' sha=' + rec.shaBefore.slice(0, 12) + '→' + rec.shaAfter.slice(0, 12) + ' chunks=' + rec.chunksBefore + '→' + rec.chunksAfter + ' parse=' + rec.parse);
727
+ } catch (e) {
728
+ log('[S3] 写入失败 ' + p.card.id + ':' + e.message);
729
+ changes.push({ id: p.card.id, title: p.card.title, subject: p.card.fields.subject, statusBefore: p.card.fields.status, statusAfter: p.status, supBefore: String(p.card.fields.superseded_by || ''), supAfter: String(p.supersededBy || ''), shaBefore: p.card.beforeSha, shaAfter: '', bytesEqual: false, headerEqual: false, chunksBefore: -1, chunksAfter: -1, parse: 'put-failed', ok: false });
730
+ }
731
+ }
732
+
733
+ // S4 报告
734
+ state.counters.runs++;
735
+ state.counters.llmCallsTotal += llmCalls;
736
+ state.counters.updatesTotal += updates;
737
+ state.history.push({ at: fmtTs(Date.now()), day, llmCalls, updates, clusters: clusters.length, changes: changes.length, elapsedMs: Date.now() - t0 });
738
+ state.history = state.history.slice(-30);
739
+ saveState();
740
+
741
+ const elapsedMs = Date.now() - t0;
742
+ fs.mkdirSync(REPORT_DIR, { recursive: true });
743
+ const reportPath = path.join(REPORT_DIR, 'dream-' + day + '.md');
744
+ fs.writeFileSync(reportPath, buildReport({
745
+ day, totalDocs: docs.length, cardDocs, cards, skipped, clusters, judged, skippedGroups,
746
+ changes, oversized, elapsedMs, llmCalls, llmAborted, updates, updateAborted, logLines: lines.slice(0, 200)
747
+ }), 'utf8');
748
+
749
+ const changedSubjects = changes.filter(c => c.ok).map(c => c.subject + '→' + c.statusAfter);
750
+ console.log('');
751
+ console.log('1) dream 完成:候选组 ' + clusters.length + '(已判定跳过 ' + skippedGroups.length + '),LLM ' + llmCalls + '/' + MAX_LLM + ' 次,改动 ' + updates + '/' + MAX_UPDATES + ' 篇,耗时 ' + (elapsedMs / 1000).toFixed(1) + ' 秒');
752
+ console.log('2) 改动:' + (changedSubjects.length ? changedSubjects.join(',') : '无(本轮所有组判为 COMPLEMENT/KEEP_ALL 或 header 已是目标状态)'));
753
+ console.log('3) 报告:' + reportPath);
754
+ log('== dream end ' + fmtTs(Date.now()) + ' llm=' + llmCalls + ' updates=' + updates + ' elapsed=' + (elapsedMs / 1000).toFixed(1) + 's');
755
+
756
+ // 退出码契约:有任何一条落账失败,或写入被安全阀打断,就以非零退出,
757
+ // 让 run-all.cmd 跳过画像复核——失败的一夜不该把画像改掉。
758
+ const failedChanges = changes.filter((c) => c.ok === false).length;
759
+ if (failedChanges > 0 || updateAborted) {
760
+ log('== dream exit ' + (failedChanges > 0 ? 2 : 3) + ' failedChanges=' + failedChanges + ' updateAborted=' + (updateAborted || 'none'));
761
+ return failedChanges > 0 ? 2 : 3;
762
+ }
763
+ return 0;
764
+ }
765
+
766
+ // 只在被直接执行时运行:import 本模块必须零副作用(writers/README.md 契约)。
767
+ const ENTRY = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
768
+ if (ENTRY === import.meta.url) {
769
+ let code = 0;
770
+ try {
771
+ code = SELF_TEST ? selfTest() : await main();
772
+ } catch (error) {
773
+ console.error('[fail] ' + (error?.message || error));
774
+ code = 1;
775
+ }
776
+ if (code === 0 && THEN_PROFILE && !SELF_TEST) {
777
+ if (DRY) {
778
+ console.log('[then-profile] dry-run:跳过 profile-writer');
779
+ } else {
780
+ const args = [path.join(HERE, 'profile-writer.mjs'), '--review', '--write'];
781
+ if (CONFIG_ARG) args.push('--config', CONFIG_ARG);
782
+ const child = spawnSync(process.execPath, args, { stdio: 'inherit' });
783
+ if (child.status !== 0) code = child.status ?? 1;
784
+ }
785
+ }
786
+ process.exit(code);
787
+ }