@stardustlc/dsh-dream 0.5.1 → 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.
@@ -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);
@@ -536,12 +574,20 @@ export class KnowledgeStore {
536
574
  });
537
575
  }
538
576
  /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
539
- reviewLesson(id, decision, expectedRevision, idempotencyKey, actor) {
577
+ reviewLesson(id, decision, expectedRevision, idempotencyKey, actor, note, resolution) {
540
578
  assertIdempotencyKey(idempotencyKey);
541
579
  if (!isEnum(decision, REVIEW_DECISIONS))
542
580
  throw new KnowledgeError('invalid', 'review decision 取值不合法');
543
581
  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 });
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
+ });
545
591
  return this.withLock((touch) => {
546
592
  const state = this.loadCompleteState(touch);
547
593
  const cached = state.idempotency.get(idempotencyKey);
@@ -567,18 +613,27 @@ export class KnowledgeStore {
567
613
  throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState, { lessonId: id });
568
614
  }
569
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;
570
623
  const merged = validateLesson({
571
624
  ...lesson,
572
625
  state: nextState,
573
- review: maskedActor === undefined
574
- ? { decision, at: now }
575
- : { decision, actor: maskedActor, at: now },
626
+ review: nextReview,
576
627
  revision: lesson.revision + 1,
577
628
  updatedAt: now,
578
629
  });
579
630
  const payload = { decision, state: nextState };
580
631
  if (maskedActor !== undefined)
581
632
  payload.actor = maskedActor;
633
+ if (maskedNote !== undefined)
634
+ payload.note = maskedNote;
635
+ if (resolution !== undefined)
636
+ payload.resolution = resolution;
582
637
  const event = {
583
638
  schemaVersion: 1,
584
639
  id: 'evt_' + randomUUID(),
@@ -599,6 +654,97 @@ export class KnowledgeStore {
599
654
  return clone(merged);
600
655
  });
601
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
+ }
602
748
  /** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
603
749
  applyTransition(id, to, expectedRevision, idempotencyKey) {
604
750
  assertIdempotencyKey(idempotencyKey);
@@ -653,10 +799,11 @@ export class KnowledgeStore {
653
799
  badLines: state.badLines,
654
800
  orphanEvents: state.orphanEvents,
655
801
  unsupportedVersions: state.unsupportedVersions,
802
+ skippedEvents: state.skippedEvents,
656
803
  firstReplayedEventId: state.firstReplayedEventId,
657
804
  replayMode: state.replayMode,
658
805
  replayedEvents: state.replayedEvents,
659
- skippedEvents: Math.max(0, state.events - state.replayedEvents),
806
+ unreplayedEvents: Math.max(0, state.events - state.replayedEvents),
660
807
  };
661
808
  }
662
809
  /** 只读诊断:锁、文件、计数与最近一次派生索引写失败;绝不获取锁、绝不写盘。 */
@@ -703,6 +850,7 @@ export class KnowledgeStore {
703
850
  badLines: state.badLines,
704
851
  orphanEvents: state.orphanEvents,
705
852
  unsupportedVersions: state.unsupportedVersions,
853
+ skippedEvents: state.skippedEvents,
706
854
  truncated: state.events > this.maxReplayEvents,
707
855
  byState,
708
856
  },
@@ -789,11 +937,29 @@ export class KnowledgeStore {
789
937
  if (entry.lesson !== undefined)
790
938
  return clone(entry.lesson);
791
939
  const exact = this.replayUntilIdempotencyKey(idempotencyKey);
792
- if (exact !== undefined)
793
- return clone(exact);
940
+ if (exact !== undefined && exact.lesson !== undefined)
941
+ return clone(exact.lesson);
794
942
  const current = state.lessons.get(entry.lessonId);
795
943
  return current === undefined ? undefined : clone(current);
796
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
+ }
797
963
  replayUntilIdempotencyKey(idempotencyKey) {
798
964
  const state = createEmptyReplayState();
799
965
  let found;
@@ -802,8 +968,8 @@ export class KnowledgeStore {
802
968
  state.events++;
803
969
  this.applyEventLine(state, line);
804
970
  const hit = state.idempotency.get(idempotencyKey);
805
- if (hit !== undefined && hit.lesson !== undefined) {
806
- found = hit.lesson;
971
+ if (hit !== undefined && (hit.lesson !== undefined || hit.batch !== undefined)) {
972
+ found = hit;
807
973
  return false;
808
974
  }
809
975
  return true;
@@ -887,6 +1053,7 @@ export class KnowledgeStore {
887
1053
  state.badLines = snapshot.checkpoint.counters.badLines;
888
1054
  state.orphanEvents = snapshot.checkpoint.counters.orphanEvents;
889
1055
  state.unsupportedVersions = snapshot.checkpoint.counters.unsupportedVersions;
1056
+ state.skippedEvents = snapshot.checkpoint.counters.skippedEvents;
890
1057
  state.snapshotEvidenceCount = snapshot.checkpoint.counters.evidenceCount;
891
1058
  state.idempotencyComplete = snapshot.checkpoint.idempotencyComplete === true;
892
1059
  state.lastEventId = snapshot.checkpoint.lastEventId;
@@ -995,6 +1162,11 @@ export class KnowledgeStore {
995
1162
  state.unsupportedVersions++;
996
1163
  return;
997
1164
  }
1165
+ const kind = raw.kind;
1166
+ if (typeof kind !== 'string' || !KNOWN_EVENT_KINDS.has(kind)) {
1167
+ state.skippedEvents++;
1168
+ return;
1169
+ }
998
1170
  if (!isEventShape(raw)) {
999
1171
  state.badLines++;
1000
1172
  return;
@@ -1049,9 +1221,13 @@ export class KnowledgeStore {
1049
1221
  state.orphanEvents++;
1050
1222
  return;
1051
1223
  }
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 };
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;
1055
1231
  const merged = validateLesson({
1056
1232
  ...previous,
1057
1233
  review,
@@ -1073,6 +1249,40 @@ export class KnowledgeStore {
1073
1249
  }
1074
1250
  return;
1075
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
+ }
1284
+ return;
1285
+ }
1076
1286
  case 'evidence.add': {
1077
1287
  const evidence = validateEvidence(event.payload);
1078
1288
  state.evidence.set(evidence.id, evidence);
@@ -1106,6 +1316,8 @@ export class KnowledgeStore {
1106
1316
  if (sessionKey === undefined && hash === undefined)
1107
1317
  return undefined;
1108
1318
  for (const item of state.evidence.values()) {
1319
+ if (input.verification === 'claimed' && item.verification === 'read')
1320
+ continue;
1109
1321
  if (sessionKey !== undefined && evidenceSessionKey(item) === sessionKey)
1110
1322
  return item;
1111
1323
  if (hash !== undefined && item.sourceHash === hash)
@@ -1118,12 +1330,12 @@ export class KnowledgeStore {
1118
1330
  if (!isRecord(input))
1119
1331
  throw new KnowledgeError('invalid', '证据输入必须是对象');
1120
1332
  const existing = this.findEvidenceByNaturalKey(state, input);
1121
- if (existing !== undefined)
1333
+ if (existing !== undefined && !(existing.verification === 'claimed' && input.verification === 'read'))
1122
1334
  return existing;
1123
1335
  const now = new Date().toISOString();
1124
1336
  const raw = {
1125
1337
  schemaVersion: 1,
1126
- id: 'evd_' + randomUUID(),
1338
+ id: existing?.id ?? 'evd_' + randomUUID(),
1127
1339
  kind: input.kind,
1128
1340
  observedAt: typeof input.observedAt === 'string' ? input.observedAt : now,
1129
1341
  summary: input.summary,
@@ -1137,6 +1349,8 @@ export class KnowledgeStore {
1137
1349
  raw.projectId = input.projectId;
1138
1350
  if (input.sourceHash !== undefined)
1139
1351
  raw.sourceHash = input.sourceHash;
1352
+ if (input.verificationReason !== undefined)
1353
+ raw.verificationReason = input.verificationReason;
1140
1354
  const evidence = this.maskEvidence(validateEvidence(raw));
1141
1355
  const natural = evidenceNaturalKey(evidence);
1142
1356
  const event = {
@@ -1194,13 +1408,18 @@ export class KnowledgeStore {
1194
1408
  maskLesson(lesson) {
1195
1409
  if (!this.mask)
1196
1410
  return lesson;
1197
- return {
1411
+ const masked = {
1198
1412
  ...lesson,
1199
1413
  title: maskSecrets(lesson.title),
1200
1414
  action: maskSecrets(lesson.action),
1201
1415
  when: maskSecrets(lesson.when),
1202
1416
  exceptions: lesson.exceptions.map((item) => maskSecrets(item)),
1203
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;
1204
1423
  }
1205
1424
  maskEvidence(evidence) {
1206
1425
  if (!this.mask)
@@ -1295,6 +1514,7 @@ export class KnowledgeStore {
1295
1514
  badLines: counterNumber('badLines'),
1296
1515
  orphanEvents: counterNumber('orphanEvents'),
1297
1516
  unsupportedVersions: counterNumber('unsupportedVersions'),
1517
+ skippedEvents: counterNumber('skippedEvents'),
1298
1518
  evidenceCount: counterNumber('evidenceCount'),
1299
1519
  };
1300
1520
  const lessonsRaw = raw.lessons;
@@ -1340,14 +1560,20 @@ export class KnowledgeStore {
1340
1560
  const record = { lessonId: value.lessonId };
1341
1561
  if (typeof value.requestHash === 'string')
1342
1562
  record.requestHash = value.requestHash;
1563
+ if (Array.isArray(value.lessonIds)) {
1564
+ record.lessonIds = value.lessonIds.filter((id) => typeof id === 'string');
1565
+ }
1343
1566
  idempotency.set(key, record);
1344
1567
  }
1345
1568
  }
1346
1569
  const serialized = {};
1347
1570
  for (const [key, entry] of idempotency) {
1348
- serialized[key] = entry.requestHash === undefined
1349
- ? { lessonId: entry.lessonId }
1350
- : { lessonId: entry.lessonId, requestHash: entry.requestHash };
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;
1351
1577
  }
1352
1578
  return {
1353
1579
  lessons,
@@ -1374,9 +1600,12 @@ export class KnowledgeStore {
1374
1600
  const prefixBytes = Math.min(size, PREFIX_HASH_BYTES);
1375
1601
  const idempotency = {};
1376
1602
  for (const [key, entry] of state.idempotency) {
1377
- idempotency[key] = entry.requestHash === undefined
1378
- ? { lessonId: entry.lessonId }
1379
- : { lessonId: entry.lessonId, requestHash: entry.requestHash };
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;
1380
1609
  }
1381
1610
  return {
1382
1611
  offset: size,
@@ -1389,6 +1618,7 @@ export class KnowledgeStore {
1389
1618
  badLines: state.badLines,
1390
1619
  orphanEvents: state.orphanEvents,
1391
1620
  unsupportedVersions: state.unsupportedVersions,
1621
+ skippedEvents: state.skippedEvents,
1392
1622
  evidenceCount: state.evidence.size,
1393
1623
  },
1394
1624
  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,6 +45,8 @@ export interface Lesson {
36
45
  decision: ReviewDecision;
37
46
  actor?: string;
38
47
  at?: string;
48
+ note?: string;
49
+ resolution?: LessonReviewResolution;
39
50
  };
40
51
  createdAt: string;
41
52
  updatedAt: string;
@@ -58,6 +69,8 @@ export interface Evidence {
58
69
  /** read=插件读到原记录;claimed=仅模型声称。 */
59
70
  verification: 'read' | 'claimed';
60
71
  sourceHash?: string;
72
+ /** 插件生成的核验结果;不是模型给出的保证。 */
73
+ verificationReason?: string;
61
74
  }
62
75
  /** dream_learn / 迁移的输入。 */
63
76
  export interface LessonInput {
@@ -110,6 +123,8 @@ export type KnowledgeEvent = {
110
123
  decision: ReviewDecision;
111
124
  actor?: string;
112
125
  state: LessonState;
126
+ note?: string;
127
+ resolution?: LessonReviewResolution;
113
128
  };
114
129
  } | {
115
130
  schemaVersion: 1;
@@ -119,6 +134,20 @@ export type KnowledgeEvent = {
119
134
  idempotencyKey: string;
120
135
  requestHash?: string;
121
136
  payload: Evidence;
137
+ } | {
138
+ schemaVersion: 1;
139
+ id: string;
140
+ at: string;
141
+ kind: 'lesson.batch';
142
+ idempotencyKey: string;
143
+ requestHash?: string;
144
+ payload: {
145
+ updates: Array<{
146
+ lessonId: string;
147
+ revision: number;
148
+ patch: Partial<Lesson>;
149
+ }>;
150
+ };
122
151
  };
123
152
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
124
153
  export declare class KnowledgeError extends Error {
package/lib/knowledge.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * @module dsh-dream/knowledge
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
+ /** 审阅备注最大长度;超出截断而不是报错。 */
11
+ export const REVIEW_NOTE_MAX_LENGTH = 500;
10
12
  /** 知识层错误码:invalid=数据不合法;revision=乐观锁不符;duplicate=重复;io=读写/锁失败。 */
11
13
  export class KnowledgeError extends Error {
12
14
  code;
@@ -118,6 +120,21 @@ function readApplicability(value) {
118
120
  }
119
121
  return out;
120
122
  }
123
+ function readResolution(value) {
124
+ if (value === undefined || value === null)
125
+ return undefined;
126
+ if (!isRecord(value))
127
+ fail('review.resolution', '必须是对象');
128
+ const kind = readString(value.kind, 'review.resolution.kind', { required: true, nonEmpty: true });
129
+ const targetId = readString(value.targetId, 'review.resolution.targetId', { nonEmpty: true });
130
+ const affectedIds = readStringArray(value.affectedIds, 'review.resolution.affectedIds');
131
+ const resolution = { kind };
132
+ if (targetId !== undefined)
133
+ resolution.targetId = targetId;
134
+ if (affectedIds !== undefined)
135
+ resolution.affectedIds = affectedIds;
136
+ return resolution;
137
+ }
121
138
  function readReview(value) {
122
139
  if (value === undefined || value === null)
123
140
  return { decision: 'unreviewed' };
@@ -133,6 +150,15 @@ function readReview(value) {
133
150
  review.actor = actor;
134
151
  if (at !== undefined)
135
152
  review.at = at;
153
+ const noteRaw = readString(value.note, 'review.note');
154
+ if (noteRaw !== undefined) {
155
+ const trimmed = noteRaw.trim();
156
+ if (trimmed !== '')
157
+ review.note = trimmed.length > REVIEW_NOTE_MAX_LENGTH ? trimmed.slice(0, REVIEW_NOTE_MAX_LENGTH) : trimmed;
158
+ }
159
+ const resolution = readResolution(value.resolution);
160
+ if (resolution !== undefined)
161
+ review.resolution = resolution;
136
162
  return review;
137
163
  }
138
164
  /** 当前内存模型 / 写盘的 lesson schemaVersion。 */
@@ -241,6 +267,7 @@ export function validateEvidence(value) {
241
267
  const sessionId = readString(value.sessionId, 'evidence.sessionId', { nonEmpty: true });
242
268
  const projectId = readString(value.projectId, 'evidence.projectId', { nonEmpty: true });
243
269
  const sourceHash = readString(value.sourceHash, 'evidence.sourceHash', { nonEmpty: true });
270
+ const verificationReason = readString(value.verificationReason, 'evidence.verificationReason', { nonEmpty: true });
244
271
  const recordSeqRaw = value.recordSeq;
245
272
  let recordSeq;
246
273
  if (recordSeqRaw !== undefined && recordSeqRaw !== null) {
@@ -267,6 +294,8 @@ export function validateEvidence(value) {
267
294
  evidence.projectId = projectId;
268
295
  if (sourceHash !== undefined)
269
296
  evidence.sourceHash = sourceHash;
297
+ if (verificationReason !== undefined)
298
+ evidence.verificationReason = verificationReason;
270
299
  return evidence;
271
300
  }
272
301
  /**
package/lib/mask.d.ts CHANGED
@@ -10,4 +10,6 @@ export declare const SECRET_PATTERNS: Array<{
10
10
  label: string;
11
11
  }>;
12
12
  /** 对文本做脱敏;无命中时原样返回。 */
13
- export declare function maskSecrets(text: string): string;
13
+ export declare function maskSecrets(text: string, publicReferences?: readonly string[]): string;
14
+ /** 只认可插件生成的引用形状;不得把任意 *Id / *Hash 字段当作脱敏豁免。 */
15
+ export declare function isDreamReference(value: string): boolean;