@stardustlc/dsh-dream 0.5.1 → 0.7.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.
@@ -214,6 +214,7 @@ function acquireLock(lockPath, options) {
214
214
  sleepSync(25);
215
215
  }
216
216
  }
217
+ const KNOWN_EVENT_KINDS = new Set(['lesson.create', 'lesson.update', 'lesson.review', 'lesson.batch', 'evidence.add']);
217
218
  /** 事件行形状检查(字段值再做 schema 校验)。 */
218
219
  function isEventShape(value) {
219
220
  if (!isRecord(value) || value.schemaVersion !== 1)
@@ -234,6 +235,16 @@ function isEventShape(value) {
234
235
  typeof value.revision === 'number' &&
235
236
  Number.isInteger(value.revision) &&
236
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
+ }
237
248
  case 'evidence.add':
238
249
  return value.payload !== undefined;
239
250
  default:
@@ -268,6 +279,7 @@ function createEmptyReplayState() {
268
279
  badLines: 0,
269
280
  orphanEvents: 0,
270
281
  unsupportedVersions: 0,
282
+ skippedEvents: 0,
271
283
  replayMode: 'full',
272
284
  idempotencyComplete: true,
273
285
  entitiesFromSnapshot: false,
@@ -346,9 +358,9 @@ export class KnowledgeStore {
346
358
  /**
347
359
  * 创建候选经验。同 idempotencyKey + 同 requestHash 幂等;同指纹(文本 + 范围相同)确定性合并,
348
360
  * 只把新证据并入已有经验,不新增经验。
349
- * R2′:有 read 证据 → 初始 usable(含 user-correction read 时 review=accepted/user);只有 claimed 或无证据 → candidate。
361
+ * 经调用方核验的 read 来源可用于项目经验;来源读取不代表用户采纳,review 始终 unreviewed。
350
362
  */
351
- createLesson(input, idempotencyKey) {
363
+ createLesson(input, idempotencyKey, options = {}) {
352
364
  assertIdempotencyKey(idempotencyKey);
353
365
  if (!isRecord(input))
354
366
  throw new KnowledgeError('invalid', '经验输入必须是对象');
@@ -358,6 +370,7 @@ export class KnowledgeStore {
358
370
  const evidenceInputs = input.evidence === undefined ? [] : input.evidence;
359
371
  const requestHash = this.hashRequest({
360
372
  operation: 'lesson.create',
373
+ ...(options.requireReview === true ? { requireReview: true } : {}),
361
374
  kind: input.kind === undefined ? null : input.kind,
362
375
  title: input.title === undefined ? null : input.title,
363
376
  action: input.action === undefined ? null : input.action,
@@ -424,27 +437,20 @@ export class KnowledgeStore {
424
437
  .map((id) => state.evidence.get(id))
425
438
  .filter((item) => item !== undefined);
426
439
  const hasRead = mergedEvidence.some((item) => item.verification === 'read');
427
- const hasUserCorrectionRead = mergedEvidence.some((item) => item.kind === 'user-correction' && item.verification === 'read');
428
440
  const patch = {};
429
441
  if (newIds.length > 0) {
430
442
  patch.evidenceIds = mergedIds;
431
- patch.independentSupportCount = mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values()));
432
443
  }
444
+ const supportCount = mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values()));
445
+ if (supportCount !== existing.independentSupportCount)
446
+ patch.independentSupportCount = supportCount;
433
447
  // R2′:候选经验遇到 read 证据可升为 usable;disputed/stale/rejected 状态保持不变。
434
- if (existing.state === 'candidate' && hasRead) {
448
+ if (existing.state === 'candidate' && hasRead && !options.requireReview) {
435
449
  patch.state = 'usable';
436
- if (hasUserCorrectionRead) {
437
- patch.review = { decision: 'accepted', actor: 'user', at: now };
438
- }
439
- else if (existing.review.decision !== 'unreviewed') {
450
+ if (existing.review.decision !== 'unreviewed') {
440
451
  patch.review = { decision: 'unreviewed' };
441
452
  }
442
453
  }
443
- else if (existing.state === 'usable' &&
444
- hasUserCorrectionRead &&
445
- existing.review.decision !== 'accepted') {
446
- patch.review = { decision: 'accepted', actor: 'user', at: now };
447
- }
448
454
  if (Object.keys(patch).length === 0)
449
455
  return { lesson: clone(existing), created: false };
450
456
  const merged = this.appendLessonUpdate(state, existing, patch, idempotencyKey, requestHash, touch);
@@ -455,11 +461,8 @@ export class KnowledgeStore {
455
461
  .map((id) => state.evidence.get(id))
456
462
  .filter((item) => item !== undefined);
457
463
  const hasRead = allEvidence.some((item) => item.verification === 'read');
458
- const hasUserCorrectionRead = allEvidence.some((item) => item.kind === 'user-correction' && item.verification === 'read');
459
- const initialState = hasRead ? 'usable' : 'candidate';
460
- const initialReview = hasUserCorrectionRead
461
- ? { decision: 'accepted', actor: 'user', at: now }
462
- : { decision: 'unreviewed' };
464
+ const initialState = hasRead && !options.requireReview ? 'usable' : 'candidate';
465
+ const initialReview = { decision: 'unreviewed' };
463
466
  const lesson = validateLesson({
464
467
  schemaVersion: 1,
465
468
  id: 'lsn_' + randomUUID(),
@@ -499,6 +502,41 @@ export class KnowledgeStore {
499
502
  return { lesson: clone(stored), created: true };
500
503
  });
501
504
  }
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
+ }
502
540
  /** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')(details 带 currentRevision)且不写。 */
503
541
  updateLesson(id, patch, expectedRevision, idempotencyKey) {
504
542
  assertIdempotencyKey(idempotencyKey);
@@ -535,13 +573,56 @@ export class KnowledgeStore {
535
573
  return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey, requestHash, touch));
536
574
  });
537
575
  }
576
+ /** 记录一次使用反馈,不改变审阅、状态、证据和最后核验时间。 */
577
+ recordFeedback(id, vote, note, expectedRevision, idempotencyKey) {
578
+ assertIdempotencyKey(idempotencyKey);
579
+ if (vote !== 'useful' && vote !== 'not-applicable')
580
+ throw new KnowledgeError('invalid', '反馈必须是 useful / not-applicable');
581
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1)
582
+ throw new KnowledgeError('invalid', '反馈必须带有效的经验版本');
583
+ if (note !== undefined && (typeof note !== 'string' || note.length > 500))
584
+ throw new KnowledgeError('invalid', '反馈备注最多 500 字符');
585
+ const cleanNote = note === undefined || note.trim() === '' ? undefined : this.mask ? maskSecrets(note.trim()) : note.trim();
586
+ const requestHash = this.hashRequest({ operation: 'lesson.feedback', id, vote, note: cleanNote ?? null, expectedRevision });
587
+ return this.withLock((touch) => {
588
+ const state = this.loadCompleteState(touch);
589
+ const cached = state.idempotency.get(idempotencyKey);
590
+ if (cached !== undefined) {
591
+ this.assertSameRequest(cached, requestHash, idempotencyKey);
592
+ const result = this.resolveIdempotentLesson(state, idempotencyKey);
593
+ if (result === undefined)
594
+ throw new KnowledgeError('io', '无法恢复反馈结果');
595
+ return result;
596
+ }
597
+ const previous = state.lessons.get(id);
598
+ if (previous === undefined)
599
+ throw new KnowledgeError('invalid', '经验不存在:' + id);
600
+ if (previous.revision !== expectedRevision)
601
+ throw new KnowledgeError('revision', '经验已更新,请刷新后再反馈', { currentRevision: previous.revision });
602
+ const old = previous.feedback ?? { useful: 0, notApplicable: 0, recent: [] };
603
+ const feedback = {
604
+ useful: old.useful + (vote === 'useful' ? 1 : 0),
605
+ notApplicable: old.notApplicable + (vote === 'not-applicable' ? 1 : 0),
606
+ recent: [...old.recent, { vote, at: new Date().toISOString(), ...(cleanNote === undefined ? {} : { note: cleanNote }) }].slice(-20),
607
+ };
608
+ return clone(this.appendLessonUpdate(state, previous, { feedback }, idempotencyKey, requestHash, touch));
609
+ });
610
+ }
538
611
  /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
539
- reviewLesson(id, decision, expectedRevision, idempotencyKey, actor) {
612
+ reviewLesson(id, decision, expectedRevision, idempotencyKey, actor, note, resolution) {
540
613
  assertIdempotencyKey(idempotencyKey);
541
614
  if (!isEnum(decision, REVIEW_DECISIONS))
542
615
  throw new KnowledgeError('invalid', 'review decision 取值不合法');
543
616
  const maskedActor = actor === undefined ? undefined : this.mask ? maskSecrets(actor) : actor;
544
- const requestHash = this.hashRequest({ operation: 'lesson.review', lessonId: id, decision, actor: maskedActor === undefined ? null : maskedActor });
617
+ const maskedNote = note === undefined ? undefined : this.mask ? maskSecrets(note) : note;
618
+ const requestHash = this.hashRequest({
619
+ operation: 'lesson.review',
620
+ lessonId: id,
621
+ decision,
622
+ actor: maskedActor === undefined ? null : maskedActor,
623
+ note: maskedNote === undefined ? null : maskedNote,
624
+ resolution: resolution === undefined ? null : resolution,
625
+ });
545
626
  return this.withLock((touch) => {
546
627
  const state = this.loadCompleteState(touch);
547
628
  const cached = state.idempotency.get(idempotencyKey);
@@ -567,18 +648,27 @@ export class KnowledgeStore {
567
648
  throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState, { lessonId: id });
568
649
  }
569
650
  const now = new Date().toISOString();
651
+ const nextReview = { decision, at: now };
652
+ if (maskedActor !== undefined)
653
+ nextReview.actor = maskedActor;
654
+ if (maskedNote !== undefined)
655
+ nextReview.note = maskedNote;
656
+ if (resolution !== undefined)
657
+ nextReview.resolution = resolution;
570
658
  const merged = validateLesson({
571
659
  ...lesson,
572
660
  state: nextState,
573
- review: maskedActor === undefined
574
- ? { decision, at: now }
575
- : { decision, actor: maskedActor, at: now },
661
+ review: nextReview,
576
662
  revision: lesson.revision + 1,
577
663
  updatedAt: now,
578
664
  });
579
665
  const payload = { decision, state: nextState };
580
666
  if (maskedActor !== undefined)
581
667
  payload.actor = maskedActor;
668
+ if (maskedNote !== undefined)
669
+ payload.note = maskedNote;
670
+ if (resolution !== undefined)
671
+ payload.resolution = resolution;
582
672
  const event = {
583
673
  schemaVersion: 1,
584
674
  id: 'evt_' + randomUUID(),
@@ -599,6 +689,97 @@ export class KnowledgeStore {
599
689
  return clone(merged);
600
690
  });
601
691
  }
692
+ /**
693
+ * 批量原子更新(M2-②):一个事件承载全部变更;先统一校验,
694
+ * 任一条不通过 → 零写入并抛 KnowledgeError(details.failedId)。
695
+ */
696
+ updateLessonsBatch(updates, idempotencyKey) {
697
+ assertIdempotencyKey(idempotencyKey);
698
+ if (!Array.isArray(updates) || updates.length === 0)
699
+ throw new KnowledgeError('invalid', 'updates 必须是非空数组');
700
+ for (const update of updates) {
701
+ if (!isRecord(update) || typeof update.id !== 'string' || update.id.trim() === '' || !isRecord(update.patch)) {
702
+ throw new KnowledgeError('invalid', 'updates 每项必须包含 id/patch/expectedRevision');
703
+ }
704
+ if (typeof update.expectedRevision !== 'number' || !Number.isInteger(update.expectedRevision)) {
705
+ throw new KnowledgeError('invalid', 'expectedRevision 必须是整数', { failedId: update.id });
706
+ }
707
+ }
708
+ const requestHash = this.hashRequest({
709
+ operation: 'lesson.batch',
710
+ updates: updates.map((update) => ({ id: update.id, expectedRevision: update.expectedRevision, patch: this.hashablePatch(update.patch) })),
711
+ });
712
+ return this.withLock((touch) => {
713
+ const state = this.loadCompleteState(touch);
714
+ const cached = state.idempotency.get(idempotencyKey);
715
+ if (cached !== undefined) {
716
+ this.assertSameRequest(cached, requestHash, idempotencyKey);
717
+ const batch = this.resolveIdempotentBatch(state, idempotencyKey);
718
+ if (batch === undefined)
719
+ throw new KnowledgeError('io', '无法恢复批量幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
720
+ return batch;
721
+ }
722
+ const now = new Date().toISOString();
723
+ const planned = [];
724
+ for (const update of updates) {
725
+ const previous = state.lessons.get(update.id);
726
+ if (previous === undefined)
727
+ throw new KnowledgeError('invalid', '经验不存在:' + update.id, { failedId: update.id });
728
+ if (previous.revision !== update.expectedRevision) {
729
+ throw new KnowledgeError('revision', '经验 ' + update.id + ' 期望 revision ' + update.expectedRevision + ',实际 ' + previous.revision, {
730
+ failedId: update.id,
731
+ expectedRevision: update.expectedRevision,
732
+ currentRevision: previous.revision,
733
+ });
734
+ }
735
+ const clean = this.cleanPatch(update.patch);
736
+ if (clean.state !== undefined && clean.state !== previous.state && !canTransition(previous.state, clean.state)) {
737
+ throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + clean.state, { failedId: update.id });
738
+ }
739
+ if (clean.evidenceIds !== undefined) {
740
+ clean.independentSupportCount = mergeEvidenceSupport({ evidenceIds: clean.evidenceIds }, Array.from(state.evidence.values()));
741
+ }
742
+ const merged = this.maskLesson(validateLesson({
743
+ ...previous,
744
+ ...clean,
745
+ id: previous.id,
746
+ schemaVersion: 1,
747
+ revision: previous.revision + 1,
748
+ updatedAt: now,
749
+ }));
750
+ planned.push({ previous, merged, patch: clean });
751
+ }
752
+ const event = {
753
+ schemaVersion: 1,
754
+ id: 'evt_' + randomUUID(),
755
+ at: now,
756
+ kind: 'lesson.batch',
757
+ idempotencyKey,
758
+ requestHash,
759
+ payload: {
760
+ updates: planned.map(({ previous, merged, patch: clean }) => {
761
+ const patch = {};
762
+ for (const key of Object.keys(clean)) {
763
+ ;
764
+ patch[key] = merged[key];
765
+ }
766
+ return { lessonId: previous.id, revision: merged.revision, patch };
767
+ }),
768
+ },
769
+ };
770
+ this.appendEvent(state, event);
771
+ const applied = [];
772
+ for (const { merged } of planned) {
773
+ state.lessons.set(merged.id, merged);
774
+ applied.push(merged);
775
+ }
776
+ if (!state.idempotency.has(idempotencyKey)) {
777
+ state.idempotency.set(idempotencyKey, { lessonId: applied[0].id, requestHash, batch: applied, lessonIds: applied.map((lesson) => lesson.id) });
778
+ }
779
+ this.writeIndex(state, touch);
780
+ return clone(applied);
781
+ });
782
+ }
602
783
  /** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
603
784
  applyTransition(id, to, expectedRevision, idempotencyKey) {
604
785
  assertIdempotencyKey(idempotencyKey);
@@ -653,10 +834,11 @@ export class KnowledgeStore {
653
834
  badLines: state.badLines,
654
835
  orphanEvents: state.orphanEvents,
655
836
  unsupportedVersions: state.unsupportedVersions,
837
+ skippedEvents: state.skippedEvents,
656
838
  firstReplayedEventId: state.firstReplayedEventId,
657
839
  replayMode: state.replayMode,
658
840
  replayedEvents: state.replayedEvents,
659
- skippedEvents: Math.max(0, state.events - state.replayedEvents),
841
+ unreplayedEvents: Math.max(0, state.events - state.replayedEvents),
660
842
  };
661
843
  }
662
844
  /** 只读诊断:锁、文件、计数与最近一次派生索引写失败;绝不获取锁、绝不写盘。 */
@@ -703,6 +885,7 @@ export class KnowledgeStore {
703
885
  badLines: state.badLines,
704
886
  orphanEvents: state.orphanEvents,
705
887
  unsupportedVersions: state.unsupportedVersions,
888
+ skippedEvents: state.skippedEvents,
706
889
  truncated: state.events > this.maxReplayEvents,
707
890
  byState,
708
891
  },
@@ -789,11 +972,29 @@ export class KnowledgeStore {
789
972
  if (entry.lesson !== undefined)
790
973
  return clone(entry.lesson);
791
974
  const exact = this.replayUntilIdempotencyKey(idempotencyKey);
792
- if (exact !== undefined)
793
- return clone(exact);
975
+ if (exact !== undefined && exact.lesson !== undefined)
976
+ return clone(exact.lesson);
794
977
  const current = state.lessons.get(entry.lessonId);
795
978
  return current === undefined ? undefined : clone(current);
796
979
  }
980
+ resolveIdempotentBatch(state, idempotencyKey) {
981
+ const entry = state.idempotency.get(idempotencyKey);
982
+ if (entry === undefined)
983
+ return undefined;
984
+ if (entry.batch !== undefined)
985
+ return clone(entry.batch);
986
+ const exact = this.replayUntilIdempotencyKey(idempotencyKey);
987
+ if (exact !== undefined && exact.batch !== undefined)
988
+ return clone(exact.batch);
989
+ const ids = entry.lessonIds !== undefined ? entry.lessonIds : [entry.lessonId];
990
+ const out = [];
991
+ for (const id of ids) {
992
+ const lesson = state.lessons.get(id);
993
+ if (lesson !== undefined)
994
+ out.push(clone(lesson));
995
+ }
996
+ return out.length === 0 ? undefined : out;
997
+ }
797
998
  replayUntilIdempotencyKey(idempotencyKey) {
798
999
  const state = createEmptyReplayState();
799
1000
  let found;
@@ -802,8 +1003,8 @@ export class KnowledgeStore {
802
1003
  state.events++;
803
1004
  this.applyEventLine(state, line);
804
1005
  const hit = state.idempotency.get(idempotencyKey);
805
- if (hit !== undefined && hit.lesson !== undefined) {
806
- found = hit.lesson;
1006
+ if (hit !== undefined && (hit.lesson !== undefined || hit.batch !== undefined)) {
1007
+ found = hit;
807
1008
  return false;
808
1009
  }
809
1010
  return true;
@@ -887,6 +1088,7 @@ export class KnowledgeStore {
887
1088
  state.badLines = snapshot.checkpoint.counters.badLines;
888
1089
  state.orphanEvents = snapshot.checkpoint.counters.orphanEvents;
889
1090
  state.unsupportedVersions = snapshot.checkpoint.counters.unsupportedVersions;
1091
+ state.skippedEvents = snapshot.checkpoint.counters.skippedEvents;
890
1092
  state.snapshotEvidenceCount = snapshot.checkpoint.counters.evidenceCount;
891
1093
  state.idempotencyComplete = snapshot.checkpoint.idempotencyComplete === true;
892
1094
  state.lastEventId = snapshot.checkpoint.lastEventId;
@@ -995,6 +1197,11 @@ export class KnowledgeStore {
995
1197
  state.unsupportedVersions++;
996
1198
  return;
997
1199
  }
1200
+ const kind = raw.kind;
1201
+ if (typeof kind !== 'string' || !KNOWN_EVENT_KINDS.has(kind)) {
1202
+ state.skippedEvents++;
1203
+ return;
1204
+ }
998
1205
  if (!isEventShape(raw)) {
999
1206
  state.badLines++;
1000
1207
  return;
@@ -1049,9 +1256,13 @@ export class KnowledgeStore {
1049
1256
  state.orphanEvents++;
1050
1257
  return;
1051
1258
  }
1052
- const review = event.payload.actor === undefined
1053
- ? { decision: event.payload.decision, at: event.at }
1054
- : { decision: event.payload.decision, actor: event.payload.actor, at: event.at };
1259
+ const review = { decision: event.payload.decision, at: event.at };
1260
+ if (event.payload.actor !== undefined)
1261
+ review.actor = event.payload.actor;
1262
+ if (event.payload.note !== undefined)
1263
+ review.note = event.payload.note;
1264
+ if (event.payload.resolution !== undefined)
1265
+ review.resolution = event.payload.resolution;
1055
1266
  const merged = validateLesson({
1056
1267
  ...previous,
1057
1268
  review,
@@ -1073,6 +1284,40 @@ export class KnowledgeStore {
1073
1284
  }
1074
1285
  return;
1075
1286
  }
1287
+ case 'lesson.batch': {
1288
+ const applied = [];
1289
+ const payload = event.payload;
1290
+ for (const item of payload.updates) {
1291
+ const previous = state.lessons.get(item.lessonId);
1292
+ if (previous === undefined || item.revision <= previous.revision) {
1293
+ state.orphanEvents++;
1294
+ continue;
1295
+ }
1296
+ const merged = validateLesson({
1297
+ ...previous,
1298
+ ...item.patch,
1299
+ id: previous.id,
1300
+ revision: item.revision,
1301
+ updatedAt: event.at,
1302
+ });
1303
+ if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
1304
+ state.orphanEvents++;
1305
+ continue;
1306
+ }
1307
+ state.lessons.set(merged.id, merged);
1308
+ applied.push(merged);
1309
+ }
1310
+ if (!state.idempotency.has(event.idempotencyKey)) {
1311
+ const first = applied[0];
1312
+ const record = first === undefined
1313
+ ? { lessonId: payload.updates[0] === undefined ? '' : payload.updates[0].lessonId }
1314
+ : { lessonId: first.id, batch: applied, lessonIds: applied.map((lesson) => lesson.id) };
1315
+ if (event.requestHash !== undefined)
1316
+ record.requestHash = event.requestHash;
1317
+ state.idempotency.set(event.idempotencyKey, record);
1318
+ }
1319
+ return;
1320
+ }
1076
1321
  case 'evidence.add': {
1077
1322
  const evidence = validateEvidence(event.payload);
1078
1323
  state.evidence.set(evidence.id, evidence);
@@ -1106,6 +1351,8 @@ export class KnowledgeStore {
1106
1351
  if (sessionKey === undefined && hash === undefined)
1107
1352
  return undefined;
1108
1353
  for (const item of state.evidence.values()) {
1354
+ if (input.verification === 'claimed' && item.verification === 'read')
1355
+ continue;
1109
1356
  if (sessionKey !== undefined && evidenceSessionKey(item) === sessionKey)
1110
1357
  return item;
1111
1358
  if (hash !== undefined && item.sourceHash === hash)
@@ -1118,12 +1365,12 @@ export class KnowledgeStore {
1118
1365
  if (!isRecord(input))
1119
1366
  throw new KnowledgeError('invalid', '证据输入必须是对象');
1120
1367
  const existing = this.findEvidenceByNaturalKey(state, input);
1121
- if (existing !== undefined)
1368
+ if (existing !== undefined && !(existing.verification === 'claimed' && input.verification === 'read'))
1122
1369
  return existing;
1123
1370
  const now = new Date().toISOString();
1124
1371
  const raw = {
1125
1372
  schemaVersion: 1,
1126
- id: 'evd_' + randomUUID(),
1373
+ id: existing?.id ?? 'evd_' + randomUUID(),
1127
1374
  kind: input.kind,
1128
1375
  observedAt: typeof input.observedAt === 'string' ? input.observedAt : now,
1129
1376
  summary: input.summary,
@@ -1137,6 +1384,8 @@ export class KnowledgeStore {
1137
1384
  raw.projectId = input.projectId;
1138
1385
  if (input.sourceHash !== undefined)
1139
1386
  raw.sourceHash = input.sourceHash;
1387
+ if (input.verificationReason !== undefined)
1388
+ raw.verificationReason = input.verificationReason;
1140
1389
  const evidence = this.maskEvidence(validateEvidence(raw));
1141
1390
  const natural = evidenceNaturalKey(evidence);
1142
1391
  const event = {
@@ -1194,13 +1443,23 @@ export class KnowledgeStore {
1194
1443
  maskLesson(lesson) {
1195
1444
  if (!this.mask)
1196
1445
  return lesson;
1197
- return {
1446
+ const masked = {
1198
1447
  ...lesson,
1199
1448
  title: maskSecrets(lesson.title),
1200
1449
  action: maskSecrets(lesson.action),
1201
1450
  when: maskSecrets(lesson.when),
1202
1451
  exceptions: lesson.exceptions.map((item) => maskSecrets(item)),
1203
1452
  };
1453
+ if (lesson.review.note !== undefined) {
1454
+ const refs = [lesson.id, ...(lesson.review.resolution?.affectedIds ?? []), ...(lesson.review.resolution?.targetId ? [lesson.review.resolution.targetId] : [])];
1455
+ masked.review = { ...lesson.review, note: maskSecrets(lesson.review.note, refs) };
1456
+ }
1457
+ if (lesson.feedback !== undefined)
1458
+ masked.feedback = {
1459
+ ...lesson.feedback,
1460
+ recent: lesson.feedback.recent.map(item => ({ ...item, ...(item.note === undefined ? {} : { note: maskSecrets(item.note) }) })),
1461
+ };
1462
+ return masked;
1204
1463
  }
1205
1464
  maskEvidence(evidence) {
1206
1465
  if (!this.mask)
@@ -1295,6 +1554,7 @@ export class KnowledgeStore {
1295
1554
  badLines: counterNumber('badLines'),
1296
1555
  orphanEvents: counterNumber('orphanEvents'),
1297
1556
  unsupportedVersions: counterNumber('unsupportedVersions'),
1557
+ skippedEvents: counterNumber('skippedEvents'),
1298
1558
  evidenceCount: counterNumber('evidenceCount'),
1299
1559
  };
1300
1560
  const lessonsRaw = raw.lessons;
@@ -1340,14 +1600,20 @@ export class KnowledgeStore {
1340
1600
  const record = { lessonId: value.lessonId };
1341
1601
  if (typeof value.requestHash === 'string')
1342
1602
  record.requestHash = value.requestHash;
1603
+ if (Array.isArray(value.lessonIds)) {
1604
+ record.lessonIds = value.lessonIds.filter((id) => typeof id === 'string');
1605
+ }
1343
1606
  idempotency.set(key, record);
1344
1607
  }
1345
1608
  }
1346
1609
  const serialized = {};
1347
1610
  for (const [key, entry] of idempotency) {
1348
- serialized[key] = entry.requestHash === undefined
1349
- ? { lessonId: entry.lessonId }
1350
- : { lessonId: entry.lessonId, requestHash: entry.requestHash };
1611
+ const record = { lessonId: entry.lessonId };
1612
+ if (entry.requestHash !== undefined)
1613
+ record.requestHash = entry.requestHash;
1614
+ if (entry.lessonIds !== undefined)
1615
+ record.lessonIds = entry.lessonIds;
1616
+ serialized[key] = record;
1351
1617
  }
1352
1618
  return {
1353
1619
  lessons,
@@ -1374,9 +1640,12 @@ export class KnowledgeStore {
1374
1640
  const prefixBytes = Math.min(size, PREFIX_HASH_BYTES);
1375
1641
  const idempotency = {};
1376
1642
  for (const [key, entry] of state.idempotency) {
1377
- idempotency[key] = entry.requestHash === undefined
1378
- ? { lessonId: entry.lessonId }
1379
- : { lessonId: entry.lessonId, requestHash: entry.requestHash };
1643
+ const record = { lessonId: entry.lessonId };
1644
+ if (entry.requestHash !== undefined)
1645
+ record.requestHash = entry.requestHash;
1646
+ if (entry.lessonIds !== undefined)
1647
+ record.lessonIds = entry.lessonIds;
1648
+ idempotency[key] = record;
1380
1649
  }
1381
1650
  return {
1382
1651
  offset: size,
@@ -1389,6 +1658,7 @@ export class KnowledgeStore {
1389
1658
  badLines: state.badLines,
1390
1659
  orphanEvents: state.orphanEvents,
1391
1660
  unsupportedVersions: state.unsupportedVersions,
1661
+ skippedEvents: state.skippedEvents,
1392
1662
  evidenceCount: state.evidence.size,
1393
1663
  },
1394
1664
  idempotency,
@@ -4,6 +4,15 @@ export type LessonKind = 'preference' | 'procedure' | 'pitfall' | 'fact';
4
4
  export type LessonState = 'candidate' | 'usable' | 'disputed' | 'stale' | 'rejected';
5
5
  /** 审阅结论。 */
6
6
  export type ReviewDecision = 'unreviewed' | 'accepted' | 'rejected';
7
+ /** 解析 / 冲突处理的可选痕迹(事件与经验都带得下;旧数据没有也合法)。 */
8
+ export interface LessonReviewResolution {
9
+ /** prefer | drop | merge 等;不强制枚举,由 tools 层约定。 */
10
+ kind: string;
11
+ targetId?: string;
12
+ affectedIds?: string[];
13
+ }
14
+ /** 审阅备注最大长度;超出截断而不是报错。 */
15
+ export declare const REVIEW_NOTE_MAX_LENGTH = 500;
7
16
  /** 一条可复用经验。 */
8
17
  export interface Lesson {
9
18
  schemaVersion: 1;
@@ -36,7 +45,11 @@ export interface Lesson {
36
45
  decision: ReviewDecision;
37
46
  actor?: string;
38
47
  at?: string;
48
+ note?: string;
49
+ resolution?: LessonReviewResolution;
39
50
  };
51
+ /** 使用反馈独立于审阅和证据;只保留最近 20 次明细,事件流保留历史。 */
52
+ feedback?: LessonFeedback;
40
53
  createdAt: string;
41
54
  updatedAt: string;
42
55
  lastValidatedAt?: string;
@@ -44,6 +57,15 @@ export interface Lesson {
44
57
  supersedes?: string[];
45
58
  conflictIds: string[];
46
59
  }
60
+ export interface LessonFeedback {
61
+ useful: number;
62
+ notApplicable: number;
63
+ recent: Array<{
64
+ vote: 'useful' | 'not-applicable';
65
+ at: string;
66
+ note?: string;
67
+ }>;
68
+ }
47
69
  /** 一条证据:只存脱敏摘要 + 定位信息。 */
48
70
  export interface Evidence {
49
71
  schemaVersion: 1;
@@ -58,6 +80,8 @@ export interface Evidence {
58
80
  /** read=插件读到原记录;claimed=仅模型声称。 */
59
81
  verification: 'read' | 'claimed';
60
82
  sourceHash?: string;
83
+ /** 插件生成的核验结果;不是模型给出的保证。 */
84
+ verificationReason?: string;
61
85
  }
62
86
  /** dream_learn / 迁移的输入。 */
63
87
  export interface LessonInput {
@@ -110,6 +134,8 @@ export type KnowledgeEvent = {
110
134
  decision: ReviewDecision;
111
135
  actor?: string;
112
136
  state: LessonState;
137
+ note?: string;
138
+ resolution?: LessonReviewResolution;
113
139
  };
114
140
  } | {
115
141
  schemaVersion: 1;
@@ -119,6 +145,20 @@ export type KnowledgeEvent = {
119
145
  idempotencyKey: string;
120
146
  requestHash?: string;
121
147
  payload: Evidence;
148
+ } | {
149
+ schemaVersion: 1;
150
+ id: string;
151
+ at: string;
152
+ kind: 'lesson.batch';
153
+ idempotencyKey: string;
154
+ requestHash?: string;
155
+ payload: {
156
+ updates: Array<{
157
+ lessonId: string;
158
+ revision: number;
159
+ patch: Partial<Lesson>;
160
+ }>;
161
+ };
122
162
  };
123
163
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
124
164
  export declare class KnowledgeError extends Error {