@stardustlc/dsh-dream 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,825 @@
1
+ /**
2
+ * dsh-dream 知识存储:events.jsonl 为权威追加日志,evidence.jsonl 追加证据,
3
+ * index.json 为可丢弃重建的派生索引,.lock 为短期文件锁。
4
+ *
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)绝不建目录、绝不写盘。
12
+ * - 新写盘路径在 maskSecrets 开启(默认)时统一过 mask.ts。
13
+ *
14
+ * @module dsh-dream/knowledge-store
15
+ */
16
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, appendFileSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { join } from 'node:path';
19
+ import { StringDecoder } from 'node:string_decoder';
20
+ import { KnowledgeError, canTransition, evidenceNaturalKey, evidenceSessionKey, lessonFingerprint, mergeEvidenceSupport, validateEvidence, validateLesson, } from './knowledge.js';
21
+ import { maskSecrets } from './mask.js';
22
+ /** events.jsonl 默认最多回放的行数(有界读取)。 */
23
+ export const MAX_REPLAY_EVENTS = 200000;
24
+ const LESSON_STATES = ['candidate', 'usable', 'disputed', 'stale', 'rejected'];
25
+ const REVIEW_DECISIONS = ['unreviewed', 'accepted', 'rejected'];
26
+ function isRecord(value) {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ }
29
+ function isEnum(value, allowed) {
30
+ return typeof value === 'string' && allowed.includes(value);
31
+ }
32
+ function clone(value) {
33
+ return JSON.parse(JSON.stringify(value));
34
+ }
35
+ function errorMessage(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+ function sleepSync(ms) {
39
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
40
+ }
41
+ function isProcessAlive(pid) {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ return error.code === 'EPERM';
48
+ }
49
+ }
50
+ /**
51
+ * 判断锁是否过期可接管:
52
+ * - 未超过 staleMs:一律视为占用(哪怕 pid 已退出)。
53
+ * - 超过 staleMs:JSON 里的 pid 不存活(或内容不可验证所有者)→ 可接管;pid 存活 → 仍是占用。
54
+ * 绝不无条件删锁。
55
+ */
56
+ function lockIsStale(lockPath, staleMs) {
57
+ let mtimeMs;
58
+ try {
59
+ mtimeMs = statSync(lockPath).mtimeMs;
60
+ }
61
+ catch {
62
+ return true;
63
+ }
64
+ let at = mtimeMs;
65
+ let pid;
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
68
+ if (isRecord(parsed)) {
69
+ const rawPid = parsed.pid;
70
+ if (typeof rawPid === 'number' && Number.isInteger(rawPid) && rawPid > 0)
71
+ pid = rawPid;
72
+ const rawAt = parsed.at;
73
+ if (typeof rawAt === 'string') {
74
+ const parsedAt = Date.parse(rawAt);
75
+ if (!Number.isNaN(parsedAt))
76
+ at = parsedAt;
77
+ }
78
+ }
79
+ }
80
+ catch {
81
+ // 内容损坏:无法验证所有者,只能用文件 mtime 判断年龄
82
+ }
83
+ if (Date.now() - at <= staleMs)
84
+ return false;
85
+ if (pid === undefined)
86
+ return true;
87
+ return !isProcessAlive(pid);
88
+ }
89
+ /** 释放自己持有的锁:token 不匹配或内容不可读时不删,避免误删他人锁。 */
90
+ function releaseLock(lockPath, token) {
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
93
+ if (!isRecord(parsed) || parsed.token !== token)
94
+ return;
95
+ }
96
+ catch {
97
+ return;
98
+ }
99
+ try {
100
+ unlinkSync(lockPath);
101
+ }
102
+ catch {
103
+ // 已被他人清理或无权删除:保持现状
104
+ }
105
+ }
106
+ /** 获取短期文件锁;失败抛 KnowledgeError('io')。 */
107
+ function acquireLock(lockPath, options) {
108
+ const token = randomUUID();
109
+ const deadline = Date.now() + options.timeoutMs;
110
+ for (;;) {
111
+ let acquired = false;
112
+ try {
113
+ const fd = openSync(lockPath, 'wx');
114
+ try {
115
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }));
116
+ }
117
+ finally {
118
+ closeSync(fd);
119
+ }
120
+ acquired = true;
121
+ }
122
+ catch (error) {
123
+ const code = error.code;
124
+ if (code !== 'EEXIST')
125
+ throw new KnowledgeError('io', '获取知识库锁失败:' + errorMessage(error));
126
+ }
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') {
136
+ if (Date.now() >= deadline) {
137
+ throw new KnowledgeError('io', '知识库锁已过期但无法接管:' + errorMessage(error));
138
+ }
139
+ sleepSync(25);
140
+ }
141
+ }
142
+ continue;
143
+ }
144
+ if (Date.now() >= deadline)
145
+ throw new KnowledgeError('io', '知识库被其他进程占用,请稍后重试');
146
+ sleepSync(25);
147
+ }
148
+ }
149
+ /** 事件行形状检查(字段值再做 schema 校验)。 */
150
+ function isEventShape(value) {
151
+ if (!isRecord(value) || value.schemaVersion !== 1)
152
+ return false;
153
+ if (typeof value.id !== 'string' || value.id === '')
154
+ return false;
155
+ if (typeof value.at !== 'string' || value.at === '')
156
+ return false;
157
+ if (typeof value.idempotencyKey !== 'string' || value.idempotencyKey === '')
158
+ return false;
159
+ switch (value.kind) {
160
+ case 'lesson.create':
161
+ return typeof value.lessonId === 'string' && value.lessonId !== '' && value.payload !== undefined;
162
+ case 'lesson.update':
163
+ case 'lesson.review':
164
+ return (typeof value.lessonId === 'string' &&
165
+ value.lessonId !== '' &&
166
+ typeof value.revision === 'number' &&
167
+ Number.isInteger(value.revision) &&
168
+ value.payload !== undefined);
169
+ case 'evidence.add':
170
+ return value.payload !== undefined;
171
+ default:
172
+ return false;
173
+ }
174
+ }
175
+ function emptyStateCounts() {
176
+ return { candidate: 0, usable: 0, disputed: 0, stale: 0, rejected: 0 };
177
+ }
178
+ /**
179
+ * 经验知识存储。无文件时所有读取方法安全返回空,写入方法在首次写入时自建目录。
180
+ */
181
+ export class KnowledgeStore {
182
+ dir;
183
+ eventsPath;
184
+ evidencePath;
185
+ indexPath;
186
+ lockPath;
187
+ mask;
188
+ maxReplayEvents;
189
+ lockTimeoutMs;
190
+ lockStaleMs;
191
+ constructor(knowledgeDir, options = {}) {
192
+ if (typeof knowledgeDir !== 'string' || knowledgeDir.trim() === '') {
193
+ throw new KnowledgeError('invalid', 'knowledgeDir 必须是非空字符串');
194
+ }
195
+ const maxReplay = typeof options.maxReplayEvents === 'number' && Number.isFinite(options.maxReplayEvents)
196
+ ? Math.max(1, Math.floor(options.maxReplayEvents))
197
+ : MAX_REPLAY_EVENTS;
198
+ this.dir = knowledgeDir;
199
+ this.eventsPath = join(knowledgeDir, 'events.jsonl');
200
+ this.evidencePath = join(knowledgeDir, 'evidence.jsonl');
201
+ this.indexPath = join(knowledgeDir, 'index.json');
202
+ this.lockPath = join(knowledgeDir, '.lock');
203
+ this.mask = options.maskSecrets !== false;
204
+ 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;
207
+ }
208
+ /** 全部经验,按 updatedAt 降序(同刻按 id 稳定排序)。 */
209
+ listLessons() {
210
+ const state = this.loadEvents();
211
+ return Array.from(state.lessons.values())
212
+ .map((lesson) => clone(lesson))
213
+ .sort((a, b) => (a.updatedAt === b.updatedAt ? a.id.localeCompare(b.id) : b.updatedAt.localeCompare(a.updatedAt)));
214
+ }
215
+ /** 全部证据,按 observedAt 升序(同刻按 id 稳定排序)。 */
216
+ listEvidence() {
217
+ const state = this.loadAll();
218
+ return Array.from(state.evidence.values())
219
+ .map((item) => clone(item))
220
+ .sort((a, b) => (a.observedAt === b.observedAt ? a.id.localeCompare(b.id) : a.observedAt.localeCompare(b.observedAt)));
221
+ }
222
+ /** 按 id 取经验;不存在返回 undefined。 */
223
+ getLesson(id) {
224
+ const state = this.loadEvents();
225
+ const lesson = state.lessons.get(id);
226
+ return lesson === undefined ? undefined : clone(lesson);
227
+ }
228
+ /** 追加证据;同 sessionId+recordSeq 或同 sourceHash 视为重复,返回已有记录且不写。 */
229
+ appendEvidence(input) {
230
+ if (!isRecord(input))
231
+ throw new KnowledgeError('invalid', '证据输入必须是对象');
232
+ return this.withLock(() => {
233
+ const state = this.loadAll();
234
+ const existing = this.findEvidenceByNaturalKey(state, input);
235
+ if (existing !== undefined)
236
+ return { evidence: clone(existing), created: false };
237
+ const created = this.persistEvidenceLocked(input, state);
238
+ return { evidence: clone(created), created: true };
239
+ });
240
+ }
241
+ /**
242
+ * 创建候选经验。同 idempotencyKey 幂等;同指纹(文本 + 范围相同)确定性合并,
243
+ * 只把新证据并入已有经验,不新增经验。
244
+ */
245
+ createLesson(input, idempotencyKey) {
246
+ assertIdempotencyKey(idempotencyKey);
247
+ if (!isRecord(input))
248
+ throw new KnowledgeError('invalid', '经验输入必须是对象');
249
+ if (!Array.isArray(input.evidence))
250
+ throw new KnowledgeError('invalid', 'evidence 必须是数组');
251
+ return this.withLock(() => {
252
+ const state = this.loadAll();
253
+ 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
+ };
259
+ if (typeof input.projectId === 'string' && input.projectId.trim() !== '')
260
+ scope.projectId = input.projectId;
261
+ if (typeof input.workspaceRoot === 'string' && input.workspaceRoot.trim() !== '')
262
+ scope.workspaceRoot = input.workspaceRoot;
263
+ const now = new Date().toISOString();
264
+ validateLesson({
265
+ schemaVersion: 1,
266
+ id: 'lsn_probe',
267
+ revision: 1,
268
+ kind: input.kind,
269
+ title: input.title,
270
+ action: input.action,
271
+ when: input.when,
272
+ exceptions: input.exceptions === undefined ? [] : input.exceptions,
273
+ scope,
274
+ applicability: input.applicability === undefined ? [] : input.applicability,
275
+ state: 'candidate',
276
+ evidenceIds: [],
277
+ independentSupportCount: 0,
278
+ review: { decision: 'unreviewed' },
279
+ createdAt: now,
280
+ updatedAt: now,
281
+ conflictIds: input.conflictIds === undefined ? [] : input.conflictIds,
282
+ });
283
+ const fingerprint = lessonFingerprint({
284
+ kind: input.kind,
285
+ title: typeof input.title === 'string' ? input.title : undefined,
286
+ action: typeof input.action === 'string' ? input.action : '',
287
+ when: typeof input.when === 'string' ? input.when : '',
288
+ scope,
289
+ });
290
+ const existing = Array.from(state.lessons.values()).find((lesson) => lessonFingerprint(lesson) === fingerprint);
291
+ const appended = [];
292
+ for (const raw of input.evidence)
293
+ appended.push(this.persistEvidenceLocked(raw, state));
294
+ if (existing !== undefined) {
295
+ const newIds = appended.map((item) => item.id).filter((id) => !existing.evidenceIds.includes(id));
296
+ let merged = existing;
297
+ 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);
303
+ }
304
+ return { lesson: clone(merged), created: false };
305
+ }
306
+ const evidenceIds = appended.map((item) => item.id);
307
+ const lesson = validateLesson({
308
+ schemaVersion: 1,
309
+ id: 'lsn_' + randomUUID(),
310
+ revision: 1,
311
+ kind: input.kind,
312
+ title: input.title,
313
+ action: input.action,
314
+ when: input.when,
315
+ exceptions: input.exceptions === undefined ? [] : input.exceptions,
316
+ scope,
317
+ applicability: input.applicability === undefined ? [] : input.applicability,
318
+ state: 'candidate',
319
+ evidenceIds,
320
+ independentSupportCount: mergeEvidenceSupport({ evidenceIds }, Array.from(state.evidence.values())),
321
+ review: { decision: 'unreviewed' },
322
+ createdAt: now,
323
+ updatedAt: now,
324
+ conflictIds: input.conflictIds === undefined ? [] : input.conflictIds,
325
+ });
326
+ const stored = this.maskLesson(lesson);
327
+ this.appendEvent({
328
+ schemaVersion: 1,
329
+ id: 'evt_' + randomUUID(),
330
+ at: now,
331
+ kind: 'lesson.create',
332
+ lessonId: stored.id,
333
+ idempotencyKey,
334
+ payload: stored,
335
+ });
336
+ state.lessons.set(stored.id, stored);
337
+ if (!state.idempotency.has(idempotencyKey))
338
+ state.idempotency.set(idempotencyKey, stored);
339
+ this.writeIndex(state);
340
+ return { lesson: clone(stored), created: true };
341
+ });
342
+ }
343
+ /** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision') 且不写。 */
344
+ updateLesson(id, patch, expectedRevision, idempotencyKey) {
345
+ assertIdempotencyKey(idempotencyKey);
346
+ if (!isRecord(patch))
347
+ throw new KnowledgeError('invalid', 'patch 必须是对象');
348
+ return this.withLock(() => {
349
+ const state = this.loadAll();
350
+ const cached = state.idempotency.get(idempotencyKey);
351
+ if (cached !== undefined)
352
+ return clone(cached);
353
+ const lesson = state.lessons.get(id);
354
+ if (lesson === undefined)
355
+ throw new KnowledgeError('invalid', '经验不存在:' + id);
356
+ 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;
367
+ }
368
+ if (clean.state !== undefined && clean.state !== lesson.state && !canTransition(lesson.state, clean.state)) {
369
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + clean.state);
370
+ }
371
+ if (clean.evidenceIds !== undefined) {
372
+ if (!Array.isArray(clean.evidenceIds))
373
+ throw new KnowledgeError('invalid', 'evidenceIds 必须是数组');
374
+ clean.independentSupportCount = mergeEvidenceSupport({ evidenceIds: clean.evidenceIds }, Array.from(state.evidence.values()));
375
+ }
376
+ return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey));
377
+ });
378
+ }
379
+ /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
380
+ reviewLesson(id, decision, expectedRevision, idempotencyKey, actor) {
381
+ assertIdempotencyKey(idempotencyKey);
382
+ if (!isEnum(decision, REVIEW_DECISIONS))
383
+ throw new KnowledgeError('invalid', 'review decision 取值不合法');
384
+ const maskedActor = actor === undefined ? undefined : this.mask ? maskSecrets(actor) : actor;
385
+ return this.withLock(() => {
386
+ const state = this.loadEvents();
387
+ const cached = state.idempotency.get(idempotencyKey);
388
+ if (cached !== undefined)
389
+ return clone(cached);
390
+ const lesson = state.lessons.get(id);
391
+ if (lesson === undefined)
392
+ throw new KnowledgeError('invalid', '经验不存在:' + id);
393
+ if (lesson.revision !== expectedRevision) {
394
+ throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision);
395
+ }
396
+ const nextState = decision === 'accepted' ? 'usable' : decision === 'rejected' ? 'rejected' : 'candidate';
397
+ if (nextState !== lesson.state && !canTransition(lesson.state, nextState)) {
398
+ throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState);
399
+ }
400
+ const now = new Date().toISOString();
401
+ const merged = validateLesson({
402
+ ...lesson,
403
+ state: nextState,
404
+ review: maskedActor === undefined
405
+ ? { decision, at: now }
406
+ : { decision, actor: maskedActor, at: now },
407
+ revision: lesson.revision + 1,
408
+ updatedAt: now,
409
+ });
410
+ const payload = { decision, state: nextState };
411
+ if (maskedActor !== undefined)
412
+ payload.actor = maskedActor;
413
+ this.appendEvent({
414
+ schemaVersion: 1,
415
+ id: 'evt_' + randomUUID(),
416
+ at: now,
417
+ kind: 'lesson.review',
418
+ lessonId: lesson.id,
419
+ revision: merged.revision,
420
+ idempotencyKey,
421
+ payload,
422
+ });
423
+ state.lessons.set(merged.id, merged);
424
+ if (!state.idempotency.has(idempotencyKey))
425
+ state.idempotency.set(idempotencyKey, merged);
426
+ this.writeIndex(state);
427
+ return clone(merged);
428
+ });
429
+ }
430
+ /** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
431
+ applyTransition(id, to, expectedRevision, idempotencyKey) {
432
+ assertIdempotencyKey(idempotencyKey);
433
+ if (!isEnum(to, LESSON_STATES))
434
+ throw new KnowledgeError('invalid', '目标状态取值不合法');
435
+ return this.withLock(() => {
436
+ const state = this.loadEvents();
437
+ const cached = state.idempotency.get(idempotencyKey);
438
+ if (cached !== undefined)
439
+ return clone(cached);
440
+ const lesson = state.lessons.get(id);
441
+ if (lesson === undefined)
442
+ throw new KnowledgeError('invalid', '经验不存在:' + id);
443
+ if (lesson.revision !== expectedRevision) {
444
+ throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision);
445
+ }
446
+ if (!canTransition(lesson.state, to)) {
447
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + to);
448
+ }
449
+ return clone(this.appendLessonUpdate(state, lesson, { state: to }, idempotencyKey));
450
+ });
451
+ }
452
+ /** 从事件重建派生索引;不删除、不改写 events.jsonl / evidence.jsonl。 */
453
+ rebuildIndex() {
454
+ this.withLock(() => {
455
+ const state = this.loadAll();
456
+ this.writeIndexStrict(state);
457
+ });
458
+ }
459
+ /** 统计。truncated=true 表示回放被 MAX_REPLAY_EVENTS 截断(不静默)。 */
460
+ stats() {
461
+ const state = this.loadAll();
462
+ const byState = emptyStateCounts();
463
+ for (const lesson of state.lessons.values())
464
+ byState[lesson.state]++;
465
+ return {
466
+ lessons: state.lessons.size,
467
+ evidence: state.evidence.size,
468
+ events: state.events,
469
+ byState,
470
+ truncated: state.truncated,
471
+ badLines: state.badLines,
472
+ };
473
+ }
474
+ // ---------- 内部实现 ----------
475
+ ensureDir() {
476
+ if (!existsSync(this.dir))
477
+ mkdirSync(this.dir, { recursive: true });
478
+ }
479
+ withLock(operation) {
480
+ this.ensureDir();
481
+ const release = acquireLock(this.lockPath, { timeoutMs: this.lockTimeoutMs, staleMs: this.lockStaleMs });
482
+ try {
483
+ return operation();
484
+ }
485
+ finally {
486
+ release();
487
+ }
488
+ }
489
+ /** 有界读取 JSONL:只保留最后 maxReplayEvents 条非空行,并报告总行数与截断。 */
490
+ readBoundedLines(file) {
491
+ if (!existsSync(file))
492
+ return { lines: [], total: 0, truncated: false };
493
+ const max = this.maxReplayEvents;
494
+ const fd = openSync(file, 'r');
495
+ try {
496
+ const decoder = new StringDecoder('utf8');
497
+ const buffer = Buffer.allocUnsafe(64 * 1024);
498
+ const ring = [];
499
+ let ringStart = 0;
500
+ let total = 0;
501
+ 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
+ };
512
+ for (;;) {
513
+ const read = readSync(fd, buffer, 0, buffer.length, null);
514
+ if (read <= 0)
515
+ break;
516
+ carry += decoder.write(buffer.subarray(0, read));
517
+ let index = carry.indexOf('\n');
518
+ while (index >= 0) {
519
+ let line = carry.slice(0, index);
520
+ carry = carry.slice(index + 1);
521
+ if (line.endsWith('\r'))
522
+ line = line.slice(0, -1);
523
+ if (line.trim() !== '')
524
+ push(line);
525
+ index = carry.indexOf('\n');
526
+ }
527
+ }
528
+ carry += decoder.end();
529
+ 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 };
533
+ }
534
+ finally {
535
+ closeSync(fd);
536
+ }
537
+ }
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
+ };
549
+ for (const line of bounded.lines)
550
+ this.applyEventLine(state, line);
551
+ return state;
552
+ }
553
+ /** 事件流 + 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) {
559
+ let raw;
560
+ try {
561
+ raw = JSON.parse(line);
562
+ }
563
+ catch {
564
+ state.badLines++;
565
+ continue;
566
+ }
567
+ try {
568
+ const evidence = validateEvidence(raw);
569
+ if (!state.evidence.has(evidence.id))
570
+ state.evidence.set(evidence.id, evidence);
571
+ }
572
+ catch {
573
+ state.badLines++;
574
+ }
575
+ }
576
+ return state;
577
+ }
578
+ applyEventLine(state, line) {
579
+ let raw;
580
+ try {
581
+ raw = JSON.parse(line);
582
+ }
583
+ catch {
584
+ state.badLines++;
585
+ return;
586
+ }
587
+ if (!isEventShape(raw)) {
588
+ state.badLines++;
589
+ return;
590
+ }
591
+ const event = raw;
592
+ try {
593
+ switch (event.kind) {
594
+ case 'lesson.create': {
595
+ const lesson = validateLesson(event.payload);
596
+ const stored = { ...lesson, id: event.lessonId };
597
+ state.lessons.set(stored.id, stored);
598
+ if (!state.idempotency.has(event.idempotencyKey))
599
+ state.idempotency.set(event.idempotencyKey, stored);
600
+ return;
601
+ }
602
+ case 'lesson.update': {
603
+ const previous = state.lessons.get(event.lessonId);
604
+ if (previous === undefined || event.revision <= previous.revision) {
605
+ state.badLines++;
606
+ return;
607
+ }
608
+ const merged = validateLesson({
609
+ ...previous,
610
+ ...event.payload,
611
+ id: previous.id,
612
+ revision: event.revision,
613
+ updatedAt: event.at,
614
+ });
615
+ if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
616
+ state.badLines++;
617
+ return;
618
+ }
619
+ state.lessons.set(merged.id, merged);
620
+ if (!state.idempotency.has(event.idempotencyKey))
621
+ state.idempotency.set(event.idempotencyKey, merged);
622
+ return;
623
+ }
624
+ case 'lesson.review': {
625
+ const previous = state.lessons.get(event.lessonId);
626
+ if (previous === undefined || event.revision <= previous.revision) {
627
+ state.badLines++;
628
+ return;
629
+ }
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 };
633
+ const merged = validateLesson({
634
+ ...previous,
635
+ review,
636
+ state: event.payload.state,
637
+ id: previous.id,
638
+ revision: event.revision,
639
+ updatedAt: event.at,
640
+ });
641
+ if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
642
+ state.badLines++;
643
+ return;
644
+ }
645
+ state.lessons.set(merged.id, merged);
646
+ if (!state.idempotency.has(event.idempotencyKey))
647
+ state.idempotency.set(event.idempotencyKey, merged);
648
+ return;
649
+ }
650
+ case 'evidence.add': {
651
+ const evidence = validateEvidence(event.payload);
652
+ state.evidence.set(evidence.id, evidence);
653
+ return;
654
+ }
655
+ default:
656
+ state.badLines++;
657
+ }
658
+ }
659
+ catch {
660
+ state.badLines++;
661
+ }
662
+ }
663
+ appendEvent(event) {
664
+ this.appendLine(this.eventsPath, event);
665
+ }
666
+ appendLine(file, value) {
667
+ this.ensureDir();
668
+ try {
669
+ appendFileSync(file, JSON.stringify(value) + '\n', 'utf8');
670
+ }
671
+ catch (error) {
672
+ throw new KnowledgeError('io', '写入 ' + file + ' 失败:' + errorMessage(error));
673
+ }
674
+ }
675
+ findEvidenceByNaturalKey(state, input) {
676
+ const sessionKey = evidenceSessionKey(input);
677
+ const hash = typeof input.sourceHash === 'string' && input.sourceHash !== '' ? input.sourceHash : undefined;
678
+ if (sessionKey === undefined && hash === undefined)
679
+ return undefined;
680
+ for (const item of state.evidence.values()) {
681
+ if (sessionKey !== undefined && evidenceSessionKey(item) === sessionKey)
682
+ return item;
683
+ if (hash !== undefined && item.sourceHash === hash)
684
+ return item;
685
+ }
686
+ return undefined;
687
+ }
688
+ /** 在锁内追加/复用一条证据;同天然身份键返回已有记录且不写。 */
689
+ persistEvidenceLocked(input, state) {
690
+ if (!isRecord(input))
691
+ throw new KnowledgeError('invalid', '证据输入必须是对象');
692
+ const existing = this.findEvidenceByNaturalKey(state, input);
693
+ if (existing !== undefined)
694
+ return existing;
695
+ const now = new Date().toISOString();
696
+ const raw = {
697
+ schemaVersion: 1,
698
+ id: 'evd_' + randomUUID(),
699
+ kind: input.kind,
700
+ observedAt: typeof input.observedAt === 'string' ? input.observedAt : now,
701
+ summary: input.summary,
702
+ verification: input.verification,
703
+ };
704
+ if (input.sessionId !== undefined)
705
+ raw.sessionId = input.sessionId;
706
+ if (input.recordSeq !== undefined)
707
+ raw.recordSeq = input.recordSeq;
708
+ if (input.projectId !== undefined)
709
+ raw.projectId = input.projectId;
710
+ if (input.sourceHash !== undefined)
711
+ raw.sourceHash = input.sourceHash;
712
+ const evidence = this.maskEvidence(validateEvidence(raw));
713
+ const natural = evidenceNaturalKey(evidence);
714
+ this.appendLine(this.evidencePath, evidence);
715
+ this.appendEvent({
716
+ schemaVersion: 1,
717
+ id: 'evt_' + randomUUID(),
718
+ at: now,
719
+ kind: 'evidence.add',
720
+ idempotencyKey: 'evidence:' + (natural === undefined ? evidence.id : natural),
721
+ payload: evidence,
722
+ });
723
+ state.evidence.set(evidence.id, evidence);
724
+ return evidence;
725
+ }
726
+ /** 在锁内追加 lesson.update 并同步内存与索引。 */
727
+ appendLessonUpdate(state, previous, patch, idempotencyKey) {
728
+ if (patch.state !== undefined && patch.state !== previous.state && !canTransition(previous.state, patch.state)) {
729
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + patch.state);
730
+ }
731
+ const now = new Date().toISOString();
732
+ const merged = this.maskLesson(validateLesson({
733
+ ...previous,
734
+ ...patch,
735
+ id: previous.id,
736
+ schemaVersion: 1,
737
+ revision: previous.revision + 1,
738
+ updatedAt: now,
739
+ }));
740
+ const payload = {};
741
+ for (const key of Object.keys(patch)) {
742
+ if (key === 'id' || key === 'schemaVersion' || key === 'revision' || key === 'createdAt' || key === 'updatedAt')
743
+ continue;
744
+ payload[key] = merged[key];
745
+ }
746
+ this.appendEvent({
747
+ schemaVersion: 1,
748
+ id: 'evt_' + randomUUID(),
749
+ at: now,
750
+ kind: 'lesson.update',
751
+ lessonId: merged.id,
752
+ revision: merged.revision,
753
+ idempotencyKey,
754
+ payload,
755
+ });
756
+ state.lessons.set(merged.id, merged);
757
+ if (!state.idempotency.has(idempotencyKey))
758
+ state.idempotency.set(idempotencyKey, merged);
759
+ this.writeIndex(state);
760
+ return merged;
761
+ }
762
+ maskLesson(lesson) {
763
+ if (!this.mask)
764
+ return lesson;
765
+ return {
766
+ ...lesson,
767
+ title: maskSecrets(lesson.title),
768
+ action: maskSecrets(lesson.action),
769
+ when: maskSecrets(lesson.when),
770
+ exceptions: lesson.exceptions.map((item) => maskSecrets(item)),
771
+ };
772
+ }
773
+ maskEvidence(evidence) {
774
+ if (!this.mask)
775
+ return evidence;
776
+ return { ...evidence, summary: maskSecrets(evidence.summary) };
777
+ }
778
+ buildIndexPayload(state) {
779
+ const lessons = {};
780
+ const stateCounts = emptyStateCounts();
781
+ for (const lesson of state.lessons.values()) {
782
+ lessons[lesson.id] = lesson;
783
+ stateCounts[lesson.state]++;
784
+ }
785
+ return {
786
+ schemaVersion: 1,
787
+ generatedAt: new Date().toISOString(),
788
+ lessons,
789
+ stateCounts,
790
+ byState: stateCounts,
791
+ };
792
+ }
793
+ /** 自动更新派生索引:失败降级(权威事件已落盘),不推翻调用结果。 */
794
+ writeIndex(state) {
795
+ try {
796
+ this.writeIndexStrict(state);
797
+ }
798
+ catch {
799
+ // index.json 只是派生加速层,丢失可 rebuildIndex() 重建。
800
+ }
801
+ }
802
+ writeIndexStrict(state) {
803
+ this.ensureDir();
804
+ const target = this.indexPath;
805
+ const tmp = target + '.tmp-' + process.pid + '-' + randomUUID();
806
+ try {
807
+ writeFileSync(tmp, JSON.stringify(this.buildIndexPayload(state), null, 2) + '\n', 'utf8');
808
+ renameSync(tmp, target);
809
+ }
810
+ catch (error) {
811
+ try {
812
+ rmSync(tmp, { force: true });
813
+ }
814
+ catch {
815
+ // 清理临时文件失败不覆盖原始 io 错误
816
+ }
817
+ throw new KnowledgeError('io', '写入 index.json 失败:' + errorMessage(error));
818
+ }
819
+ }
820
+ }
821
+ function assertIdempotencyKey(idempotencyKey) {
822
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
823
+ throw new KnowledgeError('invalid', 'idempotencyKey 必须是非空字符串');
824
+ }
825
+ }