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