@stardustlc/dsh-dream 0.4.1 → 0.5.1

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,1453 @@
1
+ /**
2
+ * dsh-dream 知识存储:events.jsonl 为权威追加日志,evidence.jsonl 追加证据,
3
+ * index.json 为可丢弃重建的派生索引(含 checkpoint),.lock 为短期文件锁。
4
+ *
5
+ * 设计要点:
6
+ * - 幂等:同 idempotencyKey + 同 requestHash 重复调用返回首次结果且不写新事件;
7
+ * 同 key 异 payload → KnowledgeError('duplicate')。
8
+ * - revision 守卫:expectedRevision 不符抛 KnowledgeError('revision')(details 带 currentRevision),不写盘。
9
+ * - 崩溃安全:单行追加 + 单行 JSON.parse 容错;坏行/孤儿事件/未知 schemaVersion 分开计数。
10
+ * - 有界回放:events.jsonl 超过 maxReplayEvents 行时 truncated=true;
11
+ * index.json 的 checkpoint(lastEventOffset/lastEventBytes/lastEventId/前缀哈希)让读路径
12
+ * "快照 + 尾部增量",截断不再丢实体;checkpoint 失效回退全量回放;rebuildIndex() 权威重建。
13
+ * - 写路径在状态不完整/截断时自动做一次完整回放,避免"看不到旧实体就无法更新"。
14
+ * - 只读方法(listLessons/listEvidence/getLesson/stats/diagnose)绝不建目录、绝不写盘。
15
+ * - 新写盘路径在 maskSecrets 开启(默认)时统一过 mask.ts。
16
+ *
17
+ * @module dsh-dream/knowledge-store
18
+ */
19
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
20
+ import { createHash, randomUUID } from 'node:crypto';
21
+ import { join } from 'node:path';
22
+ import { StringDecoder } from 'node:string_decoder';
23
+ import { KnowledgeError, canTransition, evidenceNaturalKey, evidenceSessionKey, lessonFingerprint, mergeEvidenceSupport, validateEvidence, validateLesson, } from './knowledge.js';
24
+ import { maskSecrets } from './mask.js';
25
+ /** events.jsonl 默认最多回放的行数(有界读取)。 */
26
+ export const MAX_REPLAY_EVENTS = 200000;
27
+ const PREFIX_HASH_BYTES = 64 * 1024;
28
+ const LAST_LINE_WINDOW_BYTES = 64 * 1024;
29
+ const TOUCH_EVERY_LINES = 4096;
30
+ /** 本进程 boot 标识,用于锁的 PID 复用判别。 */
31
+ const BOOT_ID = randomUUID();
32
+ const LESSON_STATES = ['candidate', 'usable', 'disputed', 'stale', 'rejected'];
33
+ const REVIEW_DECISIONS = ['unreviewed', 'accepted', 'rejected'];
34
+ function isRecord(value) {
35
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
36
+ }
37
+ function isEnum(value, allowed) {
38
+ return typeof value === 'string' && allowed.includes(value);
39
+ }
40
+ function clone(value) {
41
+ return JSON.parse(JSON.stringify(value));
42
+ }
43
+ function errorMessage(error) {
44
+ return error instanceof Error ? error.message : String(error);
45
+ }
46
+ function sleepSync(ms) {
47
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
+ }
49
+ function isProcessAlive(pid) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ }
54
+ catch (error) {
55
+ return error.code === 'EPERM';
56
+ }
57
+ }
58
+ function stableStringify(value) {
59
+ if (value === null || typeof value !== 'object') {
60
+ const text = JSON.stringify(value);
61
+ return text === undefined ? 'null' : text;
62
+ }
63
+ if (Array.isArray(value))
64
+ return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
65
+ const record = value;
66
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
67
+ }
68
+ /** 读取锁信息;绝不修改文件。 */
69
+ function readLockInfo(lockPath, staleMs) {
70
+ let raw;
71
+ try {
72
+ raw = readFileSync(lockPath, 'utf8');
73
+ }
74
+ catch {
75
+ return { present: false, stale: false };
76
+ }
77
+ let at = Date.now();
78
+ try {
79
+ at = statSync(lockPath).mtimeMs;
80
+ }
81
+ catch {
82
+ // 用文件 mtime 判断年龄失败时退回 now
83
+ }
84
+ let pid;
85
+ let bootId;
86
+ let token;
87
+ try {
88
+ const parsed = JSON.parse(raw);
89
+ if (isRecord(parsed)) {
90
+ const rawPid = parsed.pid;
91
+ if (typeof rawPid === 'number' && Number.isInteger(rawPid) && rawPid > 0)
92
+ pid = rawPid;
93
+ if (typeof parsed.bootId === 'string')
94
+ bootId = parsed.bootId;
95
+ if (typeof parsed.token === 'string')
96
+ token = parsed.token;
97
+ const rawAt = parsed.at;
98
+ if (typeof rawAt === 'string') {
99
+ const parsedAt = Date.parse(rawAt);
100
+ if (!Number.isNaN(parsedAt))
101
+ at = parsedAt;
102
+ }
103
+ }
104
+ }
105
+ catch {
106
+ // 内容损坏:无法验证所有者,只能用文件 mtime 判断年龄
107
+ }
108
+ const ageMs = Date.now() - at;
109
+ const alive = pid === undefined ? undefined : isProcessAlive(pid);
110
+ const stale = ageMs > staleMs && (pid === undefined || alive === false);
111
+ return { present: true, ageMs, pid, alive, bootId, token, stale, raw };
112
+ }
113
+ function refreshLock(lockPath, token) {
114
+ try {
115
+ const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
116
+ if (!isRecord(parsed) || parsed.token !== token)
117
+ return;
118
+ writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token, bootId: BOOT_ID }));
119
+ }
120
+ catch {
121
+ // 锁已被接管/删除:心跳失败不覆盖他人锁
122
+ }
123
+ }
124
+ /** 释放自己持有的锁:token 不匹配或内容不可读时不删,避免误删他人锁。 */
125
+ function releaseLock(lockPath, token) {
126
+ try {
127
+ const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
128
+ if (!isRecord(parsed) || parsed.token !== token)
129
+ return;
130
+ }
131
+ catch {
132
+ return;
133
+ }
134
+ try {
135
+ unlinkSync(lockPath);
136
+ }
137
+ catch {
138
+ // 已被他人清理或无权删除:保持现状
139
+ }
140
+ }
141
+ function holderDetails(info) {
142
+ const details = {};
143
+ if (info.pid !== undefined)
144
+ details.holderPid = info.pid;
145
+ if (info.ageMs !== undefined)
146
+ details.ageMs = Math.round(info.ageMs);
147
+ if (info.bootId !== undefined)
148
+ details.holderBootId = info.bootId;
149
+ return details;
150
+ }
151
+ /** 获取短期文件锁;失败抛 KnowledgeError('io')。超硬上限且心跳停滞时不静默抢占。 */
152
+ function acquireLock(lockPath, options) {
153
+ const token = randomUUID();
154
+ const deadline = Date.now() + options.timeoutMs;
155
+ for (;;) {
156
+ let acquired = false;
157
+ try {
158
+ const fd = openSync(lockPath, 'wx');
159
+ try {
160
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token, bootId: BOOT_ID }));
161
+ }
162
+ finally {
163
+ closeSync(fd);
164
+ }
165
+ acquired = true;
166
+ }
167
+ catch (error) {
168
+ const code = error.code;
169
+ if (code !== 'EEXIST')
170
+ throw new KnowledgeError('io', '获取知识库锁失败:' + errorMessage(error));
171
+ }
172
+ if (acquired) {
173
+ const heartbeat = setInterval(() => refreshLock(lockPath, token), options.heartbeatMs);
174
+ if (typeof heartbeat.unref === 'function')
175
+ heartbeat.unref();
176
+ return {
177
+ release: () => {
178
+ clearInterval(heartbeat);
179
+ releaseLock(lockPath, token);
180
+ },
181
+ touch: () => refreshLock(lockPath, token),
182
+ };
183
+ }
184
+ const info = readLockInfo(lockPath, options.staleMs);
185
+ if (info.stale) {
186
+ // TOCTOU:删除前再确认内容没有变化(他人刚接管/刷新则重新判断)。
187
+ const recheck = readLockInfo(lockPath, options.staleMs);
188
+ if (recheck.raw === info.raw) {
189
+ try {
190
+ unlinkSync(lockPath);
191
+ continue;
192
+ }
193
+ catch (error) {
194
+ const code = error.code;
195
+ if (code === 'ENOENT')
196
+ continue;
197
+ if (Date.now() >= deadline) {
198
+ throw new KnowledgeError('io', '检测到过期知识库锁但无法接管:' + errorMessage(error), holderDetails(info));
199
+ }
200
+ }
201
+ }
202
+ sleepSync(25);
203
+ continue;
204
+ }
205
+ if (info.present && info.pid !== undefined && info.alive === true && (info.ageMs ?? 0) > options.hardLimitMs) {
206
+ throw new KnowledgeError('io', '知识库锁被进程 ' + info.pid + ' 持有 ' + Math.round(info.ageMs ?? 0) + 'ms(超过硬上限 ' + options.hardLimitMs + 'ms 且心跳停滞);请结束该进程后重试,或确认无人写入后手动删除 ' + lockPath, holderDetails(info));
207
+ }
208
+ if (Date.now() >= deadline) {
209
+ if (info.present) {
210
+ throw new KnowledgeError('io', '知识库被其他进程占用(pid=' + (info.pid === undefined ? '未知' : String(info.pid)) + ',age=' + Math.round(info.ageMs ?? 0) + 'ms),请稍后重试', holderDetails(info));
211
+ }
212
+ throw new KnowledgeError('io', '获取知识库锁超时,请稍后重试');
213
+ }
214
+ sleepSync(25);
215
+ }
216
+ }
217
+ /** 事件行形状检查(字段值再做 schema 校验)。 */
218
+ function isEventShape(value) {
219
+ if (!isRecord(value) || value.schemaVersion !== 1)
220
+ return false;
221
+ if (typeof value.id !== 'string' || value.id === '')
222
+ return false;
223
+ if (typeof value.at !== 'string' || value.at === '')
224
+ return false;
225
+ if (typeof value.idempotencyKey !== 'string' || value.idempotencyKey === '')
226
+ return false;
227
+ switch (value.kind) {
228
+ case 'lesson.create':
229
+ return typeof value.lessonId === 'string' && value.lessonId !== '' && value.payload !== undefined;
230
+ case 'lesson.update':
231
+ case 'lesson.review':
232
+ return (typeof value.lessonId === 'string' &&
233
+ value.lessonId !== '' &&
234
+ typeof value.revision === 'number' &&
235
+ Number.isInteger(value.revision) &&
236
+ value.payload !== undefined);
237
+ case 'evidence.add':
238
+ return value.payload !== undefined;
239
+ default:
240
+ return false;
241
+ }
242
+ }
243
+ /** 事件 / 证据行是否携带非当前 schemaVersion(R7:独立计 unsupportedVersions)。 */
244
+ function hasUnsupportedSchema(value) {
245
+ if (!isRecord(value))
246
+ return false;
247
+ const version = value.schemaVersion;
248
+ if (typeof version === 'number' && version !== 1)
249
+ return true;
250
+ const payload = value.payload;
251
+ if (isRecord(payload)) {
252
+ const payloadVersion = payload.schemaVersion;
253
+ if (typeof payloadVersion === 'number' && payloadVersion !== 1)
254
+ return true;
255
+ }
256
+ return false;
257
+ }
258
+ function emptyStateCounts() {
259
+ return { candidate: 0, usable: 0, disputed: 0, stale: 0, rejected: 0 };
260
+ }
261
+ function createEmptyReplayState() {
262
+ return {
263
+ lessons: new Map(),
264
+ evidence: new Map(),
265
+ idempotency: new Map(),
266
+ events: 0,
267
+ replayedEvents: 0,
268
+ badLines: 0,
269
+ orphanEvents: 0,
270
+ unsupportedVersions: 0,
271
+ replayMode: 'full',
272
+ idempotencyComplete: true,
273
+ entitiesFromSnapshot: false,
274
+ snapshotEvidenceCount: 0,
275
+ };
276
+ }
277
+ /**
278
+ * 经验知识存储。无文件时所有读取方法安全返回空,写入方法在首次写入时自建目录。
279
+ */
280
+ export class KnowledgeStore {
281
+ dir;
282
+ eventsPath;
283
+ evidencePath;
284
+ indexPath;
285
+ lockPath;
286
+ mask;
287
+ maxReplayEvents;
288
+ lockTimeoutMs;
289
+ lockStaleMs;
290
+ lockHardLimitMs;
291
+ lockHeartbeatMs;
292
+ lastIndexError;
293
+ constructor(knowledgeDir, options = {}) {
294
+ if (typeof knowledgeDir !== 'string' || knowledgeDir.trim() === '') {
295
+ throw new KnowledgeError('invalid', 'knowledgeDir 必须是非空字符串');
296
+ }
297
+ const maxReplay = typeof options.maxReplayEvents === 'number' && Number.isFinite(options.maxReplayEvents)
298
+ ? Math.max(1, Math.floor(options.maxReplayEvents))
299
+ : MAX_REPLAY_EVENTS;
300
+ const numberOption = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
301
+ this.dir = knowledgeDir;
302
+ this.eventsPath = join(knowledgeDir, 'events.jsonl');
303
+ this.evidencePath = join(knowledgeDir, 'evidence.jsonl');
304
+ this.indexPath = join(knowledgeDir, 'index.json');
305
+ this.lockPath = join(knowledgeDir, '.lock');
306
+ this.mask = options.maskSecrets !== false;
307
+ this.maxReplayEvents = maxReplay;
308
+ this.lockTimeoutMs = numberOption(options.lockTimeoutMs, 2000);
309
+ this.lockStaleMs = numberOption(options.lockStaleMs, 30000);
310
+ this.lockHardLimitMs = numberOption(options.lockHardLimitMs, 600000);
311
+ this.lockHeartbeatMs = Math.max(10, numberOption(options.lockHeartbeatMs, 5000));
312
+ }
313
+ /** 全部经验,按 updatedAt 降序(同刻按 id 稳定排序)。 */
314
+ listLessons() {
315
+ const state = this.loadEvents();
316
+ return Array.from(state.lessons.values())
317
+ .map((lesson) => clone(lesson))
318
+ .sort((a, b) => (a.updatedAt === b.updatedAt ? a.id.localeCompare(b.id) : b.updatedAt.localeCompare(a.updatedAt)));
319
+ }
320
+ /** 全部证据,按 observedAt 升序(同刻按 id 稳定排序)。 */
321
+ listEvidence() {
322
+ const state = this.loadAll();
323
+ return Array.from(state.evidence.values())
324
+ .map((item) => clone(item))
325
+ .sort((a, b) => (a.observedAt === b.observedAt ? a.id.localeCompare(b.id) : a.observedAt.localeCompare(b.observedAt)));
326
+ }
327
+ /** 按 id 取经验;不存在返回 undefined。 */
328
+ getLesson(id) {
329
+ const state = this.loadEvents();
330
+ const lesson = state.lessons.get(id);
331
+ return lesson === undefined ? undefined : clone(lesson);
332
+ }
333
+ /** 追加证据;同 sessionId+recordSeq 或同 sourceHash 视为重复,返回已有记录且不写。 */
334
+ appendEvidence(input) {
335
+ if (!isRecord(input))
336
+ throw new KnowledgeError('invalid', '证据输入必须是对象');
337
+ return this.withLock((touch) => {
338
+ const state = this.loadCompleteState(touch);
339
+ const existing = this.findEvidenceByNaturalKey(state, input);
340
+ if (existing !== undefined)
341
+ return { evidence: clone(existing), created: false };
342
+ const created = this.persistEvidenceLocked(input, state);
343
+ return { evidence: clone(created), created: true };
344
+ });
345
+ }
346
+ /**
347
+ * 创建候选经验。同 idempotencyKey + 同 requestHash 幂等;同指纹(文本 + 范围相同)确定性合并,
348
+ * 只把新证据并入已有经验,不新增经验。
349
+ * R2′:有 read 证据 → 初始 usable(含 user-correction read 时 review=accepted/user);只有 claimed 或无证据 → candidate。
350
+ */
351
+ createLesson(input, idempotencyKey) {
352
+ assertIdempotencyKey(idempotencyKey);
353
+ if (!isRecord(input))
354
+ throw new KnowledgeError('invalid', '经验输入必须是对象');
355
+ if (input.evidence !== undefined && !Array.isArray(input.evidence)) {
356
+ throw new KnowledgeError('invalid', 'evidence 必须是数组');
357
+ }
358
+ const evidenceInputs = input.evidence === undefined ? [] : input.evidence;
359
+ const requestHash = this.hashRequest({
360
+ operation: 'lesson.create',
361
+ kind: input.kind === undefined ? null : input.kind,
362
+ title: input.title === undefined ? null : input.title,
363
+ action: input.action === undefined ? null : input.action,
364
+ when: input.when === undefined ? null : input.when,
365
+ exceptions: input.exceptions === undefined ? null : input.exceptions,
366
+ projectId: input.projectId === undefined ? null : input.projectId,
367
+ workspaceRoot: input.workspaceRoot === undefined ? null : input.workspaceRoot,
368
+ global: input.global === true,
369
+ applicability: input.applicability === undefined ? null : input.applicability,
370
+ evidence: evidenceInputs,
371
+ conflictIds: input.conflictIds === undefined ? null : input.conflictIds,
372
+ });
373
+ return this.withLock((touch) => {
374
+ const state = this.loadCompleteState(touch);
375
+ const cached = state.idempotency.get(idempotencyKey);
376
+ if (cached !== undefined) {
377
+ this.assertSameRequest(cached, requestHash, idempotencyKey);
378
+ const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
379
+ if (lesson === undefined) {
380
+ throw new KnowledgeError('io', '无法恢复幂等键的历史结果(events.jsonl 可能被删改):' + idempotencyKey, { idempotencyKey });
381
+ }
382
+ return { lesson, created: false };
383
+ }
384
+ const scope = { global: input.global === true };
385
+ if (typeof input.projectId === 'string' && input.projectId.trim() !== '')
386
+ scope.projectId = input.projectId;
387
+ if (typeof input.workspaceRoot === 'string' && input.workspaceRoot.trim() !== '')
388
+ scope.workspaceRoot = input.workspaceRoot;
389
+ const now = new Date().toISOString();
390
+ validateLesson({
391
+ schemaVersion: 1,
392
+ id: 'lsn_probe',
393
+ revision: 1,
394
+ kind: input.kind,
395
+ title: input.title,
396
+ action: input.action,
397
+ when: input.when,
398
+ exceptions: input.exceptions === undefined ? [] : input.exceptions,
399
+ scope,
400
+ applicability: input.applicability === undefined ? [] : input.applicability,
401
+ state: 'candidate',
402
+ evidenceIds: [],
403
+ independentSupportCount: 0,
404
+ review: { decision: 'unreviewed' },
405
+ createdAt: now,
406
+ updatedAt: now,
407
+ conflictIds: input.conflictIds === undefined ? [] : input.conflictIds,
408
+ });
409
+ const fingerprint = lessonFingerprint({
410
+ kind: input.kind,
411
+ title: typeof input.title === 'string' ? input.title : undefined,
412
+ action: typeof input.action === 'string' ? input.action : '',
413
+ when: typeof input.when === 'string' ? input.when : '',
414
+ scope,
415
+ });
416
+ const existing = Array.from(state.lessons.values()).find((lesson) => lessonFingerprint(lesson) === fingerprint);
417
+ const appended = [];
418
+ for (const raw of evidenceInputs)
419
+ appended.push(this.persistEvidenceLocked(raw, state));
420
+ if (existing !== undefined) {
421
+ const newIds = appended.map((item) => item.id).filter((id) => !existing.evidenceIds.includes(id));
422
+ const mergedIds = existing.evidenceIds.concat(newIds);
423
+ const mergedEvidence = mergedIds
424
+ .map((id) => state.evidence.get(id))
425
+ .filter((item) => item !== undefined);
426
+ const hasRead = mergedEvidence.some((item) => item.verification === 'read');
427
+ const hasUserCorrectionRead = mergedEvidence.some((item) => item.kind === 'user-correction' && item.verification === 'read');
428
+ const patch = {};
429
+ if (newIds.length > 0) {
430
+ patch.evidenceIds = mergedIds;
431
+ patch.independentSupportCount = mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values()));
432
+ }
433
+ // R2′:候选经验遇到 read 证据可升为 usable;disputed/stale/rejected 状态保持不变。
434
+ if (existing.state === 'candidate' && hasRead) {
435
+ patch.state = 'usable';
436
+ if (hasUserCorrectionRead) {
437
+ patch.review = { decision: 'accepted', actor: 'user', at: now };
438
+ }
439
+ else if (existing.review.decision !== 'unreviewed') {
440
+ patch.review = { decision: 'unreviewed' };
441
+ }
442
+ }
443
+ else if (existing.state === 'usable' &&
444
+ hasUserCorrectionRead &&
445
+ existing.review.decision !== 'accepted') {
446
+ patch.review = { decision: 'accepted', actor: 'user', at: now };
447
+ }
448
+ if (Object.keys(patch).length === 0)
449
+ return { lesson: clone(existing), created: false };
450
+ const merged = this.appendLessonUpdate(state, existing, patch, idempotencyKey, requestHash, touch);
451
+ return { lesson: clone(merged), created: false };
452
+ }
453
+ const evidenceIds = appended.map((item) => item.id);
454
+ const allEvidence = evidenceIds
455
+ .map((id) => state.evidence.get(id))
456
+ .filter((item) => item !== undefined);
457
+ const hasRead = allEvidence.some((item) => item.verification === 'read');
458
+ const hasUserCorrectionRead = allEvidence.some((item) => item.kind === 'user-correction' && item.verification === 'read');
459
+ const initialState = hasRead ? 'usable' : 'candidate';
460
+ const initialReview = hasUserCorrectionRead
461
+ ? { decision: 'accepted', actor: 'user', at: now }
462
+ : { decision: 'unreviewed' };
463
+ const lesson = validateLesson({
464
+ schemaVersion: 1,
465
+ id: 'lsn_' + randomUUID(),
466
+ revision: 1,
467
+ kind: input.kind,
468
+ title: input.title,
469
+ action: input.action,
470
+ when: input.when,
471
+ exceptions: input.exceptions === undefined ? [] : input.exceptions,
472
+ scope,
473
+ applicability: input.applicability === undefined ? [] : input.applicability,
474
+ state: initialState,
475
+ evidenceIds,
476
+ independentSupportCount: mergeEvidenceSupport({ evidenceIds }, Array.from(state.evidence.values())),
477
+ review: initialReview,
478
+ createdAt: now,
479
+ updatedAt: now,
480
+ conflictIds: input.conflictIds === undefined ? [] : input.conflictIds,
481
+ });
482
+ const stored = this.maskLesson(lesson);
483
+ const event = {
484
+ schemaVersion: 1,
485
+ id: 'evt_' + randomUUID(),
486
+ at: now,
487
+ kind: 'lesson.create',
488
+ lessonId: stored.id,
489
+ idempotencyKey,
490
+ requestHash,
491
+ payload: stored,
492
+ };
493
+ this.appendEvent(state, event);
494
+ state.lessons.set(stored.id, stored);
495
+ if (!state.idempotency.has(idempotencyKey)) {
496
+ state.idempotency.set(idempotencyKey, { lessonId: stored.id, requestHash, lesson: stored });
497
+ }
498
+ this.writeIndex(state, touch);
499
+ return { lesson: clone(stored), created: true };
500
+ });
501
+ }
502
+ /** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')(details 带 currentRevision)且不写。 */
503
+ updateLesson(id, patch, expectedRevision, idempotencyKey) {
504
+ assertIdempotencyKey(idempotencyKey);
505
+ if (!isRecord(patch))
506
+ throw new KnowledgeError('invalid', 'patch 必须是对象');
507
+ const requestHash = this.hashRequest({ operation: 'lesson.update', lessonId: id, patch: this.hashablePatch(patch) });
508
+ return this.withLock((touch) => {
509
+ const state = this.loadCompleteState(touch);
510
+ const cached = state.idempotency.get(idempotencyKey);
511
+ if (cached !== undefined) {
512
+ this.assertSameRequest(cached, requestHash, idempotencyKey);
513
+ const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
514
+ if (lesson === undefined)
515
+ throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
516
+ return lesson;
517
+ }
518
+ const lesson = state.lessons.get(id);
519
+ if (lesson === undefined)
520
+ throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
521
+ if (lesson.revision !== expectedRevision) {
522
+ throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
523
+ lessonId: id,
524
+ expectedRevision,
525
+ currentRevision: lesson.revision,
526
+ });
527
+ }
528
+ const clean = this.cleanPatch(patch);
529
+ if (clean.state !== undefined && clean.state !== lesson.state && !canTransition(lesson.state, clean.state)) {
530
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + clean.state, { lessonId: id });
531
+ }
532
+ if (clean.evidenceIds !== undefined) {
533
+ clean.independentSupportCount = mergeEvidenceSupport({ evidenceIds: clean.evidenceIds }, Array.from(state.evidence.values()));
534
+ }
535
+ return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey, requestHash, touch));
536
+ });
537
+ }
538
+ /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
539
+ reviewLesson(id, decision, expectedRevision, idempotencyKey, actor) {
540
+ assertIdempotencyKey(idempotencyKey);
541
+ if (!isEnum(decision, REVIEW_DECISIONS))
542
+ throw new KnowledgeError('invalid', 'review decision 取值不合法');
543
+ const maskedActor = actor === undefined ? undefined : this.mask ? maskSecrets(actor) : actor;
544
+ const requestHash = this.hashRequest({ operation: 'lesson.review', lessonId: id, decision, actor: maskedActor === undefined ? null : maskedActor });
545
+ return this.withLock((touch) => {
546
+ const state = this.loadCompleteState(touch);
547
+ const cached = state.idempotency.get(idempotencyKey);
548
+ if (cached !== undefined) {
549
+ this.assertSameRequest(cached, requestHash, idempotencyKey);
550
+ const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
551
+ if (lesson === undefined)
552
+ throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
553
+ return lesson;
554
+ }
555
+ const lesson = state.lessons.get(id);
556
+ if (lesson === undefined)
557
+ throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
558
+ if (lesson.revision !== expectedRevision) {
559
+ throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
560
+ lessonId: id,
561
+ expectedRevision,
562
+ currentRevision: lesson.revision,
563
+ });
564
+ }
565
+ const nextState = decision === 'accepted' ? 'usable' : decision === 'rejected' ? 'rejected' : 'candidate';
566
+ if (nextState !== lesson.state && !canTransition(lesson.state, nextState)) {
567
+ throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState, { lessonId: id });
568
+ }
569
+ const now = new Date().toISOString();
570
+ const merged = validateLesson({
571
+ ...lesson,
572
+ state: nextState,
573
+ review: maskedActor === undefined
574
+ ? { decision, at: now }
575
+ : { decision, actor: maskedActor, at: now },
576
+ revision: lesson.revision + 1,
577
+ updatedAt: now,
578
+ });
579
+ const payload = { decision, state: nextState };
580
+ if (maskedActor !== undefined)
581
+ payload.actor = maskedActor;
582
+ const event = {
583
+ schemaVersion: 1,
584
+ id: 'evt_' + randomUUID(),
585
+ at: now,
586
+ kind: 'lesson.review',
587
+ lessonId: lesson.id,
588
+ revision: merged.revision,
589
+ idempotencyKey,
590
+ requestHash,
591
+ payload,
592
+ };
593
+ this.appendEvent(state, event);
594
+ state.lessons.set(merged.id, merged);
595
+ if (!state.idempotency.has(idempotencyKey)) {
596
+ state.idempotency.set(idempotencyKey, { lessonId: merged.id, requestHash, lesson: merged });
597
+ }
598
+ this.writeIndex(state, touch);
599
+ return clone(merged);
600
+ });
601
+ }
602
+ /** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
603
+ applyTransition(id, to, expectedRevision, idempotencyKey) {
604
+ assertIdempotencyKey(idempotencyKey);
605
+ if (!isEnum(to, LESSON_STATES))
606
+ throw new KnowledgeError('invalid', '目标状态取值不合法');
607
+ const requestHash = this.hashRequest({ operation: 'lesson.transition', lessonId: id, to });
608
+ return this.withLock((touch) => {
609
+ const state = this.loadCompleteState(touch);
610
+ const cached = state.idempotency.get(idempotencyKey);
611
+ if (cached !== undefined) {
612
+ this.assertSameRequest(cached, requestHash, idempotencyKey);
613
+ const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
614
+ if (lesson === undefined)
615
+ throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
616
+ return lesson;
617
+ }
618
+ const lesson = state.lessons.get(id);
619
+ if (lesson === undefined)
620
+ throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
621
+ if (lesson.revision !== expectedRevision) {
622
+ throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
623
+ lessonId: id,
624
+ expectedRevision,
625
+ currentRevision: lesson.revision,
626
+ });
627
+ }
628
+ if (!canTransition(lesson.state, to)) {
629
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + to, { lessonId: id });
630
+ }
631
+ return clone(this.appendLessonUpdate(state, lesson, { state: to }, idempotencyKey, requestHash, touch));
632
+ });
633
+ }
634
+ /** 从事件完整重建派生索引与 checkpoint;不删除、不改写 events.jsonl / evidence.jsonl。 */
635
+ rebuildIndex() {
636
+ this.withLock((touch) => {
637
+ const state = this.loadAllUnbounded(touch);
638
+ this.writeIndexStrict(state, touch);
639
+ });
640
+ }
641
+ /** 统计。truncated=true 表示事件总量超过有界回放上限(不静默);快照模式下实体已折叠进 checkpoint。 */
642
+ stats() {
643
+ const state = this.loadAll();
644
+ const byState = emptyStateCounts();
645
+ for (const lesson of state.lessons.values())
646
+ byState[lesson.state]++;
647
+ return {
648
+ lessons: state.lessons.size,
649
+ evidence: state.evidence.size,
650
+ events: state.events,
651
+ byState,
652
+ truncated: state.events > this.maxReplayEvents,
653
+ badLines: state.badLines,
654
+ orphanEvents: state.orphanEvents,
655
+ unsupportedVersions: state.unsupportedVersions,
656
+ firstReplayedEventId: state.firstReplayedEventId,
657
+ replayMode: state.replayMode,
658
+ replayedEvents: state.replayedEvents,
659
+ skippedEvents: Math.max(0, state.events - state.replayedEvents),
660
+ };
661
+ }
662
+ /** 只读诊断:锁、文件、计数与最近一次派生索引写失败;绝不获取锁、绝不写盘。 */
663
+ diagnose() {
664
+ const info = readLockInfo(this.lockPath, this.lockStaleMs);
665
+ const state = this.loadAll();
666
+ const byState = emptyStateCounts();
667
+ for (const lesson of state.lessons.values())
668
+ byState[lesson.state]++;
669
+ const lock = {
670
+ present: info.present,
671
+ stale: info.stale,
672
+ };
673
+ if (info.ageMs !== undefined)
674
+ lock.ageMs = Math.round(info.ageMs);
675
+ if (info.pid !== undefined)
676
+ lock.pid = info.pid;
677
+ if (info.alive !== undefined)
678
+ lock.alive = info.alive;
679
+ if (info.bootId !== undefined)
680
+ lock.bootId = info.bootId;
681
+ let generatedAt;
682
+ try {
683
+ const raw = existsSync(this.indexPath) ? JSON.parse(readFileSync(this.indexPath, 'utf8')) : undefined;
684
+ if (isRecord(raw) && typeof raw.generatedAt === 'string')
685
+ generatedAt = raw.generatedAt;
686
+ }
687
+ catch {
688
+ // 派生索引损坏不影响 diagnose
689
+ }
690
+ const result = {
691
+ writable: !info.present || info.stale,
692
+ lock,
693
+ files: {
694
+ dir: this.dir,
695
+ events: this.fileInfo(this.eventsPath),
696
+ evidence: this.fileInfo(this.evidencePath),
697
+ index: generatedAt === undefined ? this.fileInfo(this.indexPath) : { ...this.fileInfo(this.indexPath), generatedAt },
698
+ },
699
+ counts: {
700
+ lessons: state.lessons.size,
701
+ evidence: state.evidence.size,
702
+ events: state.events,
703
+ badLines: state.badLines,
704
+ orphanEvents: state.orphanEvents,
705
+ unsupportedVersions: state.unsupportedVersions,
706
+ truncated: state.events > this.maxReplayEvents,
707
+ byState,
708
+ },
709
+ };
710
+ if (this.lastIndexError !== undefined)
711
+ result.lastIndexError = this.lastIndexError;
712
+ return result;
713
+ }
714
+ // ---------- 内部实现 ----------
715
+ ensureDir() {
716
+ if (!existsSync(this.dir))
717
+ mkdirSync(this.dir, { recursive: true });
718
+ }
719
+ withLock(operation) {
720
+ this.ensureDir();
721
+ const handle = acquireLock(this.lockPath, {
722
+ timeoutMs: this.lockTimeoutMs,
723
+ staleMs: this.lockStaleMs,
724
+ hardLimitMs: this.lockHardLimitMs,
725
+ heartbeatMs: this.lockHeartbeatMs,
726
+ });
727
+ try {
728
+ return operation(handle.touch);
729
+ }
730
+ finally {
731
+ handle.release();
732
+ }
733
+ }
734
+ fileInfo(path) {
735
+ let bytes = 0;
736
+ let exists = false;
737
+ try {
738
+ const stat = statSync(path);
739
+ exists = true;
740
+ bytes = stat.size;
741
+ }
742
+ catch {
743
+ exists = false;
744
+ }
745
+ return { path, exists, bytes };
746
+ }
747
+ hashRequest(value) {
748
+ return createHash('sha256').update(stableStringify(value), 'utf8').digest('hex');
749
+ }
750
+ hashablePatch(patch) {
751
+ const out = {};
752
+ for (const key of Object.keys(patch)) {
753
+ if (key === 'id' || key === 'schemaVersion' || key === 'revision' || key === 'createdAt' || key === 'updatedAt')
754
+ continue;
755
+ const value = patch[key];
756
+ if (value !== undefined)
757
+ out[key] = value;
758
+ }
759
+ return out;
760
+ }
761
+ cleanPatch(patch) {
762
+ const clean = {};
763
+ const hashable = this.hashablePatch(patch);
764
+ for (const key of Object.keys(hashable)) {
765
+ ;
766
+ clean[key] = hashable[key];
767
+ }
768
+ return clean;
769
+ }
770
+ assertSameRequest(entry, requestHash, idempotencyKey) {
771
+ if (entry.requestHash !== undefined && entry.requestHash !== requestHash) {
772
+ throw new KnowledgeError('duplicate', '幂等键已用于不同的请求 payload:' + idempotencyKey, {
773
+ idempotencyKey,
774
+ existingRequestHash: entry.requestHash,
775
+ requestHash,
776
+ });
777
+ }
778
+ }
779
+ /**
780
+ * 恢复"首次结果":
781
+ * - 尾部回放产生的 entry 自带快照,直接返回;
782
+ * - checkpoint 里的 entry 只有 lessonId/requestHash,则做一次有针对性的完整扫描,
783
+ * 在命中该幂等键的那一刻取快照;失败再退回当前实体(best effort)。
784
+ */
785
+ resolveIdempotentLesson(state, idempotencyKey) {
786
+ const entry = state.idempotency.get(idempotencyKey);
787
+ if (entry === undefined)
788
+ return undefined;
789
+ if (entry.lesson !== undefined)
790
+ return clone(entry.lesson);
791
+ const exact = this.replayUntilIdempotencyKey(idempotencyKey);
792
+ if (exact !== undefined)
793
+ return clone(exact);
794
+ const current = state.lessons.get(entry.lessonId);
795
+ return current === undefined ? undefined : clone(current);
796
+ }
797
+ replayUntilIdempotencyKey(idempotencyKey) {
798
+ const state = createEmptyReplayState();
799
+ let found;
800
+ try {
801
+ this.forEachLine(this.eventsPath, 0, (line) => {
802
+ state.events++;
803
+ this.applyEventLine(state, line);
804
+ const hit = state.idempotency.get(idempotencyKey);
805
+ if (hit !== undefined && hit.lesson !== undefined) {
806
+ found = hit.lesson;
807
+ return false;
808
+ }
809
+ return true;
810
+ });
811
+ }
812
+ catch {
813
+ return undefined;
814
+ }
815
+ return found;
816
+ }
817
+ /** 顺序读取 JSONL 的非空行;每 TOUCH_EVERY_LINES 行调用一次 touch(长回放锁心跳)。 */
818
+ forEachLine(file, fromOffset, onLine, touch) {
819
+ if (!existsSync(file))
820
+ return;
821
+ const fd = openSync(file, 'r');
822
+ try {
823
+ const decoder = new StringDecoder('utf8');
824
+ const buffer = Buffer.allocUnsafe(64 * 1024);
825
+ let carry = '';
826
+ let position = fromOffset;
827
+ let count = 0;
828
+ for (;;) {
829
+ const read = readSync(fd, buffer, 0, buffer.length, position);
830
+ if (read <= 0)
831
+ break;
832
+ position += read;
833
+ carry += decoder.write(buffer.subarray(0, read));
834
+ let index = carry.indexOf('\n');
835
+ while (index >= 0) {
836
+ let line = carry.slice(0, index);
837
+ carry = carry.slice(index + 1);
838
+ if (line.endsWith('\r'))
839
+ line = line.slice(0, -1);
840
+ if (line.trim() !== '') {
841
+ count++;
842
+ if (count % TOUCH_EVERY_LINES === 0)
843
+ touch?.();
844
+ if (onLine(line) === false)
845
+ return;
846
+ }
847
+ index = carry.indexOf('\n');
848
+ }
849
+ }
850
+ carry += decoder.end();
851
+ if (carry.trim() !== '')
852
+ onLine(carry.endsWith('\r') ? carry.slice(0, -1) : carry);
853
+ }
854
+ finally {
855
+ closeSync(fd);
856
+ }
857
+ }
858
+ /** 有界读取:只保留最后 maxReplayEvents 条非空行,并报告总行数与是否截断。 */
859
+ readBoundedLines(file, fromOffset, touch) {
860
+ const max = this.maxReplayEvents;
861
+ const ring = [];
862
+ let ringStart = 0;
863
+ let total = 0;
864
+ this.forEachLine(file, fromOffset, (line) => {
865
+ total++;
866
+ if (ring.length < max) {
867
+ ring.push(line);
868
+ }
869
+ else {
870
+ ring[ringStart] = line;
871
+ ringStart = (ringStart + 1) % max;
872
+ }
873
+ }, touch);
874
+ const lines = ringStart === 0 ? ring : ring.slice(ringStart).concat(ring.slice(0, ringStart));
875
+ return { lines, total, truncated: total > max };
876
+ }
877
+ /** 快照 + 尾部增量(checkpoint 有效时);否则有界全量回放。 */
878
+ loadEvents(touch) {
879
+ const snapshot = this.tryLoadCheckpoint();
880
+ if (snapshot !== undefined) {
881
+ const state = createEmptyReplayState();
882
+ state.replayMode = 'snapshot+tail';
883
+ state.entitiesFromSnapshot = true;
884
+ state.lessons = snapshot.lessons;
885
+ state.idempotency = snapshot.idempotency;
886
+ state.events = snapshot.checkpoint.eventsTotal;
887
+ state.badLines = snapshot.checkpoint.counters.badLines;
888
+ state.orphanEvents = snapshot.checkpoint.counters.orphanEvents;
889
+ state.unsupportedVersions = snapshot.checkpoint.counters.unsupportedVersions;
890
+ state.snapshotEvidenceCount = snapshot.checkpoint.counters.evidenceCount;
891
+ state.idempotencyComplete = snapshot.checkpoint.idempotencyComplete === true;
892
+ state.lastEventId = snapshot.checkpoint.lastEventId;
893
+ const tail = this.readBoundedLines(this.eventsPath, snapshot.checkpoint.offset, touch);
894
+ if (tail.truncated)
895
+ return this.loadEventsBounded(0, touch);
896
+ state.events += tail.total;
897
+ state.replayedEvents = tail.total;
898
+ for (const line of tail.lines)
899
+ this.applyEventLine(state, line);
900
+ return state;
901
+ }
902
+ return this.loadEventsBounded(0, touch);
903
+ }
904
+ loadEventsBounded(fromOffset, touch) {
905
+ const state = createEmptyReplayState();
906
+ state.replayMode = 'full';
907
+ const bounded = this.readBoundedLines(this.eventsPath, fromOffset, touch);
908
+ state.events = bounded.total;
909
+ state.replayedEvents = bounded.lines.length;
910
+ state.idempotencyComplete = !bounded.truncated;
911
+ for (const line of bounded.lines)
912
+ this.applyEventLine(state, line);
913
+ return state;
914
+ }
915
+ /** 无界完整回放(显式压缩 / 写路径发现截断或状态不完整时使用)。 */
916
+ loadAllUnbounded(touch) {
917
+ const state = createEmptyReplayState();
918
+ state.replayMode = 'full';
919
+ state.idempotencyComplete = true;
920
+ this.forEachLine(this.eventsPath, 0, (line) => {
921
+ state.events++;
922
+ this.applyEventLine(state, line);
923
+ }, touch);
924
+ state.replayedEvents = state.events;
925
+ this.mergeEvidenceFile(state, touch);
926
+ return state;
927
+ }
928
+ /** 事件流 + evidence.jsonl(两条路径都留痕,按 id 合并去重)。 */
929
+ loadAll(touch) {
930
+ let state = this.mergeEvidenceFile(this.loadEvents(touch), touch);
931
+ if (state.entitiesFromSnapshot && this.hasMissingEvidenceRefs(state)) {
932
+ state = this.mergeEvidenceFile(this.loadEventsBounded(0, touch), touch);
933
+ }
934
+ return state;
935
+ }
936
+ /** 写路径:状态不完整、证据疑似缺失或截断未折叠时,自动做一次完整回放。 */
937
+ loadCompleteState(touch) {
938
+ let state = this.loadAll(touch);
939
+ const evidenceDeficit = state.entitiesFromSnapshot && state.evidence.size < state.snapshotEvidenceCount;
940
+ const incompleteIdempotency = state.replayMode === 'snapshot+tail' && !state.idempotencyComplete;
941
+ const skippedWithoutSnapshot = !state.entitiesFromSnapshot && state.events - state.replayedEvents > 0;
942
+ if (evidenceDeficit || incompleteIdempotency || skippedWithoutSnapshot) {
943
+ state = this.loadAllUnbounded(touch);
944
+ }
945
+ return state;
946
+ }
947
+ hasMissingEvidenceRefs(state) {
948
+ for (const lesson of state.lessons.values()) {
949
+ for (const id of lesson.evidenceIds) {
950
+ if (!state.evidence.has(id))
951
+ return true;
952
+ }
953
+ }
954
+ return false;
955
+ }
956
+ mergeEvidenceFile(state, touch) {
957
+ this.forEachLine(this.evidencePath, 0, (line) => {
958
+ let raw;
959
+ try {
960
+ raw = JSON.parse(line);
961
+ }
962
+ catch {
963
+ state.badLines++;
964
+ return;
965
+ }
966
+ if (hasUnsupportedSchema(raw)) {
967
+ state.unsupportedVersions++;
968
+ return;
969
+ }
970
+ try {
971
+ const evidence = validateEvidence(raw);
972
+ if (!state.evidence.has(evidence.id))
973
+ state.evidence.set(evidence.id, evidence);
974
+ }
975
+ catch {
976
+ state.badLines++;
977
+ }
978
+ }, touch);
979
+ return state;
980
+ }
981
+ applyEventLine(state, line) {
982
+ let raw;
983
+ try {
984
+ raw = JSON.parse(line);
985
+ }
986
+ catch {
987
+ state.badLines++;
988
+ return;
989
+ }
990
+ if (!isRecord(raw)) {
991
+ state.badLines++;
992
+ return;
993
+ }
994
+ if (hasUnsupportedSchema(raw)) {
995
+ state.unsupportedVersions++;
996
+ return;
997
+ }
998
+ if (!isEventShape(raw)) {
999
+ state.badLines++;
1000
+ return;
1001
+ }
1002
+ const event = raw;
1003
+ if (state.firstReplayedEventId === undefined)
1004
+ state.firstReplayedEventId = event.id;
1005
+ state.lastEventId = event.id;
1006
+ try {
1007
+ switch (event.kind) {
1008
+ case 'lesson.create': {
1009
+ const lesson = validateLesson(event.payload);
1010
+ const stored = { ...lesson, id: event.lessonId };
1011
+ state.lessons.set(stored.id, stored);
1012
+ if (!state.idempotency.has(event.idempotencyKey)) {
1013
+ const record = { lessonId: stored.id, lesson: stored };
1014
+ if (event.requestHash !== undefined)
1015
+ record.requestHash = event.requestHash;
1016
+ state.idempotency.set(event.idempotencyKey, record);
1017
+ }
1018
+ return;
1019
+ }
1020
+ case 'lesson.update': {
1021
+ const previous = state.lessons.get(event.lessonId);
1022
+ if (previous === undefined || event.revision <= previous.revision) {
1023
+ state.orphanEvents++;
1024
+ return;
1025
+ }
1026
+ const merged = validateLesson({
1027
+ ...previous,
1028
+ ...event.payload,
1029
+ id: previous.id,
1030
+ revision: event.revision,
1031
+ updatedAt: event.at,
1032
+ });
1033
+ if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
1034
+ state.orphanEvents++;
1035
+ return;
1036
+ }
1037
+ state.lessons.set(merged.id, merged);
1038
+ if (!state.idempotency.has(event.idempotencyKey)) {
1039
+ const record = { lessonId: merged.id, lesson: merged };
1040
+ if (event.requestHash !== undefined)
1041
+ record.requestHash = event.requestHash;
1042
+ state.idempotency.set(event.idempotencyKey, record);
1043
+ }
1044
+ return;
1045
+ }
1046
+ case 'lesson.review': {
1047
+ const previous = state.lessons.get(event.lessonId);
1048
+ if (previous === undefined || event.revision <= previous.revision) {
1049
+ state.orphanEvents++;
1050
+ return;
1051
+ }
1052
+ const review = event.payload.actor === undefined
1053
+ ? { decision: event.payload.decision, at: event.at }
1054
+ : { decision: event.payload.decision, actor: event.payload.actor, at: event.at };
1055
+ const merged = validateLesson({
1056
+ ...previous,
1057
+ review,
1058
+ state: event.payload.state,
1059
+ id: previous.id,
1060
+ revision: event.revision,
1061
+ updatedAt: event.at,
1062
+ });
1063
+ if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
1064
+ state.orphanEvents++;
1065
+ return;
1066
+ }
1067
+ state.lessons.set(merged.id, merged);
1068
+ if (!state.idempotency.has(event.idempotencyKey)) {
1069
+ const record = { lessonId: merged.id, lesson: merged };
1070
+ if (event.requestHash !== undefined)
1071
+ record.requestHash = event.requestHash;
1072
+ state.idempotency.set(event.idempotencyKey, record);
1073
+ }
1074
+ return;
1075
+ }
1076
+ case 'evidence.add': {
1077
+ const evidence = validateEvidence(event.payload);
1078
+ state.evidence.set(evidence.id, evidence);
1079
+ return;
1080
+ }
1081
+ default:
1082
+ state.badLines++;
1083
+ }
1084
+ }
1085
+ catch {
1086
+ state.badLines++;
1087
+ }
1088
+ }
1089
+ appendEvent(state, event) {
1090
+ this.appendLine(this.eventsPath, event);
1091
+ state.events++;
1092
+ state.lastEventId = event.id;
1093
+ }
1094
+ appendLine(file, value) {
1095
+ this.ensureDir();
1096
+ try {
1097
+ appendFileSync(file, JSON.stringify(value) + '\n', 'utf8');
1098
+ }
1099
+ catch (error) {
1100
+ throw new KnowledgeError('io', '写入 ' + file + ' 失败:' + errorMessage(error));
1101
+ }
1102
+ }
1103
+ findEvidenceByNaturalKey(state, input) {
1104
+ const sessionKey = evidenceSessionKey(input);
1105
+ const hash = typeof input.sourceHash === 'string' && input.sourceHash !== '' ? input.sourceHash : undefined;
1106
+ if (sessionKey === undefined && hash === undefined)
1107
+ return undefined;
1108
+ for (const item of state.evidence.values()) {
1109
+ if (sessionKey !== undefined && evidenceSessionKey(item) === sessionKey)
1110
+ return item;
1111
+ if (hash !== undefined && item.sourceHash === hash)
1112
+ return item;
1113
+ }
1114
+ return undefined;
1115
+ }
1116
+ /** 在锁内追加/复用一条证据;同天然身份键返回已有记录且不写。 */
1117
+ persistEvidenceLocked(input, state) {
1118
+ if (!isRecord(input))
1119
+ throw new KnowledgeError('invalid', '证据输入必须是对象');
1120
+ const existing = this.findEvidenceByNaturalKey(state, input);
1121
+ if (existing !== undefined)
1122
+ return existing;
1123
+ const now = new Date().toISOString();
1124
+ const raw = {
1125
+ schemaVersion: 1,
1126
+ id: 'evd_' + randomUUID(),
1127
+ kind: input.kind,
1128
+ observedAt: typeof input.observedAt === 'string' ? input.observedAt : now,
1129
+ summary: input.summary,
1130
+ verification: input.verification,
1131
+ };
1132
+ if (input.sessionId !== undefined)
1133
+ raw.sessionId = input.sessionId;
1134
+ if (input.recordSeq !== undefined)
1135
+ raw.recordSeq = input.recordSeq;
1136
+ if (input.projectId !== undefined)
1137
+ raw.projectId = input.projectId;
1138
+ if (input.sourceHash !== undefined)
1139
+ raw.sourceHash = input.sourceHash;
1140
+ const evidence = this.maskEvidence(validateEvidence(raw));
1141
+ const natural = evidenceNaturalKey(evidence);
1142
+ const event = {
1143
+ schemaVersion: 1,
1144
+ id: 'evt_' + randomUUID(),
1145
+ at: now,
1146
+ kind: 'evidence.add',
1147
+ idempotencyKey: 'evidence:' + (natural === undefined ? evidence.id : natural),
1148
+ payload: evidence,
1149
+ };
1150
+ this.appendLine(this.evidencePath, evidence);
1151
+ this.appendEvent(state, event);
1152
+ state.evidence.set(evidence.id, evidence);
1153
+ return evidence;
1154
+ }
1155
+ /** 在锁内追加 lesson.update 并同步内存与索引。 */
1156
+ appendLessonUpdate(state, previous, patch, idempotencyKey, requestHash, touch) {
1157
+ if (patch.state !== undefined && patch.state !== previous.state && !canTransition(previous.state, patch.state)) {
1158
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + patch.state, { lessonId: previous.id });
1159
+ }
1160
+ const now = new Date().toISOString();
1161
+ const merged = this.maskLesson(validateLesson({
1162
+ ...previous,
1163
+ ...patch,
1164
+ id: previous.id,
1165
+ schemaVersion: 1,
1166
+ revision: previous.revision + 1,
1167
+ updatedAt: now,
1168
+ }));
1169
+ const payload = {};
1170
+ for (const key of Object.keys(patch)) {
1171
+ if (key === 'id' || key === 'schemaVersion' || key === 'revision' || key === 'createdAt' || key === 'updatedAt')
1172
+ continue;
1173
+ payload[key] = merged[key];
1174
+ }
1175
+ const event = {
1176
+ schemaVersion: 1,
1177
+ id: 'evt_' + randomUUID(),
1178
+ at: now,
1179
+ kind: 'lesson.update',
1180
+ lessonId: merged.id,
1181
+ revision: merged.revision,
1182
+ idempotencyKey,
1183
+ requestHash,
1184
+ payload,
1185
+ };
1186
+ this.appendEvent(state, event);
1187
+ state.lessons.set(merged.id, merged);
1188
+ if (!state.idempotency.has(idempotencyKey)) {
1189
+ state.idempotency.set(idempotencyKey, { lessonId: merged.id, requestHash, lesson: merged });
1190
+ }
1191
+ this.writeIndex(state, touch);
1192
+ return merged;
1193
+ }
1194
+ maskLesson(lesson) {
1195
+ if (!this.mask)
1196
+ return lesson;
1197
+ return {
1198
+ ...lesson,
1199
+ title: maskSecrets(lesson.title),
1200
+ action: maskSecrets(lesson.action),
1201
+ when: maskSecrets(lesson.when),
1202
+ exceptions: lesson.exceptions.map((item) => maskSecrets(item)),
1203
+ };
1204
+ }
1205
+ maskEvidence(evidence) {
1206
+ if (!this.mask)
1207
+ return evidence;
1208
+ return { ...evidence, summary: maskSecrets(evidence.summary) };
1209
+ }
1210
+ // ---------- checkpoint / index ----------
1211
+ hashPrefix(file, bytes) {
1212
+ if (bytes <= 0)
1213
+ return createHash('sha256').digest('hex');
1214
+ const fd = openSync(file, 'r');
1215
+ try {
1216
+ const buffer = Buffer.allocUnsafe(bytes);
1217
+ let offset = 0;
1218
+ while (offset < bytes) {
1219
+ const read = readSync(fd, buffer, offset, bytes - offset, offset);
1220
+ if (read <= 0)
1221
+ break;
1222
+ offset += read;
1223
+ }
1224
+ return createHash('sha256').update(buffer.subarray(0, offset)).digest('hex');
1225
+ }
1226
+ finally {
1227
+ closeSync(fd);
1228
+ }
1229
+ }
1230
+ readLastLineId(file, offset) {
1231
+ if (offset <= 0)
1232
+ return undefined;
1233
+ const start = Math.max(0, offset - LAST_LINE_WINDOW_BYTES);
1234
+ const fd = openSync(file, 'r');
1235
+ try {
1236
+ const buffer = Buffer.allocUnsafe(offset - start);
1237
+ let read = 0;
1238
+ while (read < buffer.length) {
1239
+ const chunk = readSync(fd, buffer, read, buffer.length - read, start + read);
1240
+ if (chunk <= 0)
1241
+ break;
1242
+ read += chunk;
1243
+ }
1244
+ const text = buffer.subarray(0, read).toString('utf8').replace(/\r?\n+$/, '');
1245
+ const index = Math.max(text.lastIndexOf('\n'), text.lastIndexOf('\r'));
1246
+ const line = index >= 0 ? text.slice(index + 1) : text;
1247
+ try {
1248
+ const parsed = JSON.parse(line);
1249
+ return isRecord(parsed) && typeof parsed.id === 'string' ? parsed.id : undefined;
1250
+ }
1251
+ catch {
1252
+ return undefined;
1253
+ }
1254
+ }
1255
+ finally {
1256
+ closeSync(fd);
1257
+ }
1258
+ }
1259
+ tryLoadCheckpoint() {
1260
+ if (!existsSync(this.indexPath))
1261
+ return undefined;
1262
+ let raw;
1263
+ try {
1264
+ raw = JSON.parse(readFileSync(this.indexPath, 'utf8'));
1265
+ }
1266
+ catch {
1267
+ return undefined;
1268
+ }
1269
+ if (!isRecord(raw) || raw.schemaVersion !== 1)
1270
+ return undefined;
1271
+ const cpRaw = raw.checkpoint;
1272
+ if (!isRecord(cpRaw))
1273
+ return undefined;
1274
+ const offset = cpRaw.offset;
1275
+ const bytes = cpRaw.bytes;
1276
+ const lastEventId = cpRaw.lastEventId;
1277
+ const prefixHash = cpRaw.prefixHash;
1278
+ const prefixBytes = cpRaw.prefixBytes;
1279
+ const eventsTotal = cpRaw.eventsTotal;
1280
+ const countersRaw = cpRaw.counters;
1281
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0 ||
1282
+ typeof bytes !== 'number' || bytes !== offset ||
1283
+ typeof lastEventId !== 'string' ||
1284
+ typeof prefixHash !== 'string' ||
1285
+ typeof prefixBytes !== 'number' || !Number.isInteger(prefixBytes) || prefixBytes < 0 ||
1286
+ typeof eventsTotal !== 'number' || !Number.isInteger(eventsTotal) || eventsTotal < 0 ||
1287
+ !isRecord(countersRaw)) {
1288
+ return undefined;
1289
+ }
1290
+ const counterNumber = (key) => {
1291
+ const value = countersRaw[key];
1292
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
1293
+ };
1294
+ const counters = {
1295
+ badLines: counterNumber('badLines'),
1296
+ orphanEvents: counterNumber('orphanEvents'),
1297
+ unsupportedVersions: counterNumber('unsupportedVersions'),
1298
+ evidenceCount: counterNumber('evidenceCount'),
1299
+ };
1300
+ const lessonsRaw = raw.lessons;
1301
+ if (!isRecord(lessonsRaw))
1302
+ return undefined;
1303
+ const lessons = new Map();
1304
+ for (const [id, value] of Object.entries(lessonsRaw)) {
1305
+ let lesson;
1306
+ try {
1307
+ lesson = validateLesson(value);
1308
+ }
1309
+ catch {
1310
+ return undefined;
1311
+ }
1312
+ if (lesson.id !== id)
1313
+ return undefined;
1314
+ lessons.set(id, lesson);
1315
+ }
1316
+ if (offset === 0 && lessons.size > 0)
1317
+ return undefined;
1318
+ if (existsSync(this.eventsPath)) {
1319
+ const size = statSync(this.eventsPath).size;
1320
+ if (size < offset)
1321
+ return undefined;
1322
+ if (offset > 0) {
1323
+ if (prefixBytes !== Math.min(offset, PREFIX_HASH_BYTES))
1324
+ return undefined;
1325
+ if (this.hashPrefix(this.eventsPath, prefixBytes) !== prefixHash)
1326
+ return undefined;
1327
+ if (this.readLastLineId(this.eventsPath, offset) !== lastEventId)
1328
+ return undefined;
1329
+ }
1330
+ }
1331
+ else if (offset !== 0) {
1332
+ return undefined;
1333
+ }
1334
+ const idempotency = new Map();
1335
+ const idemRaw = raw.checkpoint !== undefined && isRecord(cpRaw.idempotency) ? cpRaw.idempotency : undefined;
1336
+ if (idemRaw !== undefined) {
1337
+ for (const [key, value] of Object.entries(idemRaw)) {
1338
+ if (!isRecord(value) || typeof value.lessonId !== 'string' || value.lessonId === '')
1339
+ return undefined;
1340
+ const record = { lessonId: value.lessonId };
1341
+ if (typeof value.requestHash === 'string')
1342
+ record.requestHash = value.requestHash;
1343
+ idempotency.set(key, record);
1344
+ }
1345
+ }
1346
+ const serialized = {};
1347
+ for (const [key, entry] of idempotency) {
1348
+ serialized[key] = entry.requestHash === undefined
1349
+ ? { lessonId: entry.lessonId }
1350
+ : { lessonId: entry.lessonId, requestHash: entry.requestHash };
1351
+ }
1352
+ return {
1353
+ lessons,
1354
+ idempotency,
1355
+ checkpoint: {
1356
+ offset,
1357
+ bytes,
1358
+ lastEventId,
1359
+ prefixHash,
1360
+ prefixBytes,
1361
+ eventsTotal,
1362
+ counters,
1363
+ idempotency: serialized,
1364
+ idempotencyComplete: cpRaw.idempotencyComplete === true,
1365
+ },
1366
+ };
1367
+ }
1368
+ buildCheckpoint(state) {
1369
+ if (!existsSync(this.eventsPath))
1370
+ return undefined;
1371
+ const size = statSync(this.eventsPath).size;
1372
+ if (size > 0 && (state.lastEventId === undefined || state.lastEventId === ''))
1373
+ return undefined;
1374
+ const prefixBytes = Math.min(size, PREFIX_HASH_BYTES);
1375
+ const idempotency = {};
1376
+ for (const [key, entry] of state.idempotency) {
1377
+ idempotency[key] = entry.requestHash === undefined
1378
+ ? { lessonId: entry.lessonId }
1379
+ : { lessonId: entry.lessonId, requestHash: entry.requestHash };
1380
+ }
1381
+ return {
1382
+ offset: size,
1383
+ bytes: size,
1384
+ lastEventId: state.lastEventId === undefined ? '' : state.lastEventId,
1385
+ prefixHash: this.hashPrefix(this.eventsPath, prefixBytes),
1386
+ prefixBytes,
1387
+ eventsTotal: state.events,
1388
+ counters: {
1389
+ badLines: state.badLines,
1390
+ orphanEvents: state.orphanEvents,
1391
+ unsupportedVersions: state.unsupportedVersions,
1392
+ evidenceCount: state.evidence.size,
1393
+ },
1394
+ idempotency,
1395
+ idempotencyComplete: state.idempotencyComplete,
1396
+ };
1397
+ }
1398
+ buildIndexPayload(state) {
1399
+ const lessons = {};
1400
+ const stateCounts = emptyStateCounts();
1401
+ for (const lesson of state.lessons.values()) {
1402
+ lessons[lesson.id] = lesson;
1403
+ stateCounts[lesson.state]++;
1404
+ }
1405
+ const payload = {
1406
+ schemaVersion: 1,
1407
+ generatedAt: new Date().toISOString(),
1408
+ lessons,
1409
+ stateCounts,
1410
+ byState: stateCounts,
1411
+ };
1412
+ const checkpoint = this.buildCheckpoint(state);
1413
+ if (checkpoint !== undefined)
1414
+ payload.checkpoint = checkpoint;
1415
+ return payload;
1416
+ }
1417
+ /** 自动更新派生索引:失败降级(权威事件已落盘),记录 lastIndexError 供 diagnose()。 */
1418
+ writeIndex(state, touch) {
1419
+ try {
1420
+ this.writeIndexStrict(state, touch);
1421
+ this.lastIndexError = undefined;
1422
+ }
1423
+ catch (error) {
1424
+ this.lastIndexError = { message: maskSecrets(errorMessage(error)), at: new Date().toISOString() };
1425
+ }
1426
+ }
1427
+ writeIndexStrict(state, touch) {
1428
+ this.ensureDir();
1429
+ touch?.();
1430
+ const payload = this.buildIndexPayload(state);
1431
+ touch?.();
1432
+ const target = this.indexPath;
1433
+ const tmp = target + '.tmp-' + process.pid + '-' + randomUUID();
1434
+ try {
1435
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
1436
+ renameSync(tmp, target);
1437
+ }
1438
+ catch (error) {
1439
+ try {
1440
+ rmSync(tmp, { force: true });
1441
+ }
1442
+ catch {
1443
+ // 清理临时文件失败不覆盖原始 io 错误
1444
+ }
1445
+ throw new KnowledgeError('io', '写入 index.json 失败:' + errorMessage(error));
1446
+ }
1447
+ }
1448
+ }
1449
+ function assertIdempotencyKey(idempotencyKey) {
1450
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
1451
+ throw new KnowledgeError('invalid', 'idempotencyKey 必须是非空字符串');
1452
+ }
1453
+ }