@stardustlc/dsh-dream 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import { resolveConfig } from './config.js';
10
10
  import { buildDreamTools } from './tools.js';
11
11
  import { installDreamWeb } from './web.js';
12
+ import { installDreamActions } from './web-actions.js';
12
13
  /** cordis 服务注入:要用 ctx.tools 与 ctx.skills。 */
13
14
  export const name = 'dream';
14
15
  export const inject = ['tools', 'skills'];
@@ -56,7 +57,8 @@ export function apply(ctx, config) {
56
57
  }
57
58
  // 面板同源路由:宿主有 webServer 才由 web.ts 用 ctx.inject 挂上;headless 夹具没有 inject 就跳过。
58
59
  if (typeof ctx.inject === 'function') {
59
- installDreamWeb(ctx, { journalDir: cfg.journalDir });
60
+ installDreamWeb(ctx, { journalDir: cfg.journalDir, config: cfg });
61
+ installDreamActions(ctx, { config: cfg });
60
62
  }
61
63
  if (typeof ctx.on === 'function') {
62
64
  ctx.on('dispose', () => {
@@ -59,6 +59,8 @@ export declare class KnowledgeStore {
59
59
  attachEvidence(id: string, inputs: EvidenceInput[], expectedRevision: number, idempotencyKey: string): Lesson;
60
60
  /** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')(details 带 currentRevision)且不写。 */
61
61
  updateLesson(id: string, patch: Partial<Lesson>, expectedRevision: number, idempotencyKey: string): Lesson;
62
+ /** 记录一次使用反馈,不改变审阅、状态、证据和最后核验时间。 */
63
+ recordFeedback(id: string, vote: 'useful' | 'not-applicable', note: string | undefined, expectedRevision: number, idempotencyKey: string): Lesson;
62
64
  /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
63
65
  reviewLesson(id: string, decision: ReviewDecision, expectedRevision: number, idempotencyKey: string, actor?: string, note?: string, resolution?: LessonReviewResolution): Lesson;
64
66
  /**
@@ -573,6 +573,41 @@ export class KnowledgeStore {
573
573
  return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey, requestHash, touch));
574
574
  });
575
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
+ }
576
611
  /** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
577
612
  reviewLesson(id, decision, expectedRevision, idempotencyKey, actor, note, resolution) {
578
613
  assertIdempotencyKey(idempotencyKey);
@@ -1419,6 +1454,11 @@ export class KnowledgeStore {
1419
1454
  const refs = [lesson.id, ...(lesson.review.resolution?.affectedIds ?? []), ...(lesson.review.resolution?.targetId ? [lesson.review.resolution.targetId] : [])];
1420
1455
  masked.review = { ...lesson.review, note: maskSecrets(lesson.review.note, refs) };
1421
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
+ };
1422
1462
  return masked;
1423
1463
  }
1424
1464
  maskEvidence(evidence) {
@@ -48,6 +48,8 @@ export interface Lesson {
48
48
  note?: string;
49
49
  resolution?: LessonReviewResolution;
50
50
  };
51
+ /** 使用反馈独立于审阅和证据;只保留最近 20 次明细,事件流保留历史。 */
52
+ feedback?: LessonFeedback;
51
53
  createdAt: string;
52
54
  updatedAt: string;
53
55
  lastValidatedAt?: string;
@@ -55,6 +57,15 @@ export interface Lesson {
55
57
  supersedes?: string[];
56
58
  conflictIds: string[];
57
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
+ }
58
69
  /** 一条证据:只存脱敏摘要 + 定位信息。 */
59
70
  export interface Evidence {
60
71
  schemaVersion: 1;
package/lib/knowledge.js CHANGED
@@ -161,6 +161,28 @@ function readReview(value) {
161
161
  review.resolution = resolution;
162
162
  return review;
163
163
  }
164
+ function readFeedback(value) {
165
+ if (value === undefined)
166
+ return undefined;
167
+ if (!isRecord(value))
168
+ fail('feedback', '必须是对象');
169
+ for (const key of ['useful', 'notApplicable']) {
170
+ if (!Number.isSafeInteger(value[key]) || value[key] < 0)
171
+ fail('feedback.' + key, '必须是非负安全整数');
172
+ }
173
+ if (!Array.isArray(value.recent) || value.recent.length > 20)
174
+ fail('feedback.recent', '最多 20 条');
175
+ const recent = value.recent.map((item) => {
176
+ if (!isRecord(item) || (item.vote !== 'useful' && item.vote !== 'not-applicable'))
177
+ fail('feedback.recent.vote', '取值不合法');
178
+ const at = readIsoDate(item.at, 'feedback.recent.at', true);
179
+ const note = readString(item.note, 'feedback.recent.note');
180
+ if (note !== undefined && note.length > REVIEW_NOTE_MAX_LENGTH)
181
+ fail('feedback.recent.note', '超过长度上限');
182
+ return { vote: item.vote, at, ...(note === undefined ? {} : { note }) };
183
+ });
184
+ return { useful: value.useful, notApplicable: value.notApplicable, recent };
185
+ }
164
186
  /** 当前内存模型 / 写盘的 lesson schemaVersion。 */
165
187
  export const LESSON_SCHEMA_VERSION = 1;
166
188
  /** 已知迁移链占位:v2 落地时在此登记;存储层默认只识别不自动迁移。 */
@@ -247,6 +269,9 @@ export function validateLesson(value, options = {}) {
247
269
  lesson.reviewAfter = reviewAfter;
248
270
  if (supersedes !== undefined)
249
271
  lesson.supersedes = supersedes;
272
+ const feedback = readFeedback(value.feedback);
273
+ if (feedback !== undefined)
274
+ lesson.feedback = feedback;
250
275
  return lesson;
251
276
  }
252
277
  /** 校验并归一化一条证据;失败抛 KnowledgeError('invalid')。未知字段会被丢弃。 */
package/lib/tools.d.ts CHANGED
@@ -25,3 +25,5 @@ export interface DreamToolDefinition {
25
25
  export declare function buildDreamTools(config: ResolvedDreamConfig): DreamToolDefinition[];
26
26
  /** 把会话摘要拼成一段可读文本(供模型一次性阅读)。 */
27
27
  export declare function buildDigestText(digest: SessionDigest): string;
28
+ /** actor comes from the trusted caller, never from tool or browser arguments. */
29
+ export declare function executeDreamReview(cfg: ResolvedDreamConfig, rawArgs: unknown, exec: unknown, actor?: 'model' | 'human'): Promise<unknown>;
package/lib/tools.js CHANGED
@@ -844,7 +844,6 @@ export function buildDreamTools(config) {
844
844
  },
845
845
  timeoutMs: 15000,
846
846
  };
847
- const REVIEW_ACTIONS = ['accept', 'reject', 'reopen', 'mark-stale', 'mark-disputed', 'resolve-conflict', 'attach-evidence'];
848
847
  const dreamReview = {
849
848
  name: 'dream_review',
850
849
  description: '审梦:采纳/驳回/标记冲突或过期/追加验证证据,必须带 expectedRevision(revision 不符会拒绝且不写入)。模型调用 actor 一律记为 model,不得冒充用户采纳;attach-evidence 只追加证据,不自动改变 state。',
@@ -870,210 +869,7 @@ export function buildDreamTools(config) {
870
869
  },
871
870
  },
872
871
  async execute(rawArgs, exec) {
873
- const args = asRecord(rawArgs);
874
- const action = optionalString(args, 'action');
875
- if (action === undefined || !REVIEW_ACTIONS.includes(action)) {
876
- throw new Error('action 必须是 ' + REVIEW_ACTIONS.join(' / ') + '。');
877
- }
878
- const targetId = optionalString(args, 'targetId');
879
- const lessonIdArg = optionalString(args, 'lessonId');
880
- const lessonId = targetId ?? lessonIdArg;
881
- if (lessonId === undefined) {
882
- throw new KnowledgeError('invalid', '缺少 lessonId(resolve-conflict 可用 targetId)。');
883
- }
884
- const revisionRaw = args.expectedRevision;
885
- if (typeof revisionRaw !== 'number' || !Number.isInteger(revisionRaw) || revisionRaw < 1) {
886
- throw new Error('expectedRevision 必须是 >= 1 的整数。');
887
- }
888
- const expectedRevision = revisionRaw;
889
- const store = knowledgeStoreOf(cfg);
890
- const before = store.getLesson(lessonId);
891
- if (before === undefined)
892
- throw new KnowledgeError('invalid', '经验不存在:' + lessonId);
893
- if (before.revision !== expectedRevision) {
894
- const error = new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
895
- error.currentRevision = before.revision;
896
- error.details = { currentRevision: before.revision };
897
- throw error;
898
- }
899
- const affectedIdsArg = stringArray(args, 'affectedIds');
900
- const conflictWith = affectedIdsArg.length > 0 ? affectedIdsArg : stringArray(args, 'conflictWith');
901
- const note = optionalString(args, 'note');
902
- const maskedNote = note === undefined ? undefined : maskIfNeeded(cfg, note);
903
- const resolutionArg = optionalString(args, 'resolution');
904
- const evidenceInputs = action === 'attach-evidence'
905
- ? parseEvidenceArgs(args, 'evidence', before.scope.projectId).map(entry => verifyEvidence(entry, {
906
- sessionsRoot: cfg.sessionsRoot, projectId: before.scope.projectId, workspaceRoot: before.scope.workspaceRoot, cwd: execCwdOf(exec),
907
- })).map(entry => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }))
908
- : [];
909
- const idempotencyKey = 'dream_review:' + sha256Short(JSON.stringify({
910
- action, lessonId, expectedRevision, conflictWith, resolution: resolutionArg ?? null, note: maskedNote ?? null,
911
- evidence: evidenceInputs,
912
- }));
913
- // 模型调用永远记为 model;入参里的 actor 一律忽略(用户采纳只能来自宿主授权的真实用户指令)。
914
- const actor = 'model';
915
- let lesson;
916
- switch (action) {
917
- case 'accept':
918
- lesson = store.reviewLesson(lessonId, 'accepted', expectedRevision, idempotencyKey, actor);
919
- break;
920
- case 'reject':
921
- lesson = store.reviewLesson(lessonId, 'rejected', expectedRevision, idempotencyKey, actor);
922
- break;
923
- case 'reopen':
924
- if (before.state !== 'rejected')
925
- throw new KnowledgeError('invalid', 'reopen 只用于把已驳回经验重新送审。');
926
- lesson = store.reviewLesson(lessonId, 'unreviewed', expectedRevision, idempotencyKey, actor);
927
- break;
928
- case 'mark-stale':
929
- lesson = store.applyTransition(lessonId, 'stale', expectedRevision, idempotencyKey);
930
- break;
931
- case 'attach-evidence':
932
- lesson = store.attachEvidence(lessonId, evidenceInputs, expectedRevision, idempotencyKey);
933
- break;
934
- case 'mark-disputed': {
935
- // M2:primary 与所有 conflictWith 一律置 disputed,双方 conflictIds 互相登记(去重、可多对多)。
936
- const members = requireConflictGroup(store, lessonId, before, conflictWith);
937
- const group = members.map((member) => member.id);
938
- const updates = [];
939
- for (const member of members) {
940
- const others = group.filter((id) => id !== member.id);
941
- const conflictIds = [...member.lesson.conflictIds];
942
- for (const id of others) {
943
- if (!conflictIds.includes(id))
944
- conflictIds.push(id);
945
- }
946
- const noteSatisfied = maskedNote === undefined || member.lesson.review.note === maskedNote;
947
- const alreadyDisputed = member.lesson.state === 'disputed'
948
- && conflictIds.length === member.lesson.conflictIds.length
949
- && conflictIds.every((id) => member.lesson.conflictIds.includes(id))
950
- && noteSatisfied;
951
- if (alreadyDisputed)
952
- continue;
953
- const patch = { state: 'disputed', conflictIds };
954
- if (maskedNote !== undefined)
955
- patch.review = { ...member.lesson.review, note: maskedNote };
956
- updates.push({ id: member.id, patch, expectedRevision: member.lesson.revision });
957
- }
958
- lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
959
- break;
960
- }
961
- case 'resolve-conflict': {
962
- const resolution = optionalString(args, 'resolution');
963
- if (resolution !== 'prefer' && resolution !== 'drop' && resolution !== 'merge') {
964
- throw new KnowledgeError('invalid', 'resolution 必须是 prefer / drop / merge。');
965
- }
966
- if (maskedNote === undefined) {
967
- throw new KnowledgeError('invalid', 'resolve-conflict 必须带 note(解析依据),以便事件可回溯。');
968
- }
969
- if (conflictWith.length === 0) {
970
- throw new KnowledgeError('invalid', 'resolve-conflict 必须带至少一个 affectedIds/conflictWith 目标 id。');
971
- }
972
- const members = requireConflictGroup(store, lessonId, before, conflictWith);
973
- const group = members.map((member) => member.id);
974
- // 校验:所有当事方必须 disputed,且互相登记 conflictIds;否则零写入。
975
- for (const member of members) {
976
- if (member.lesson.state !== 'disputed') {
977
- throw new KnowledgeError('invalid', '冲突解析要求 ' + member.id + ' 处于 disputed(实际 ' + member.lesson.state + ')');
978
- }
979
- for (const other of group) {
980
- if (other !== member.id && !member.lesson.conflictIds.includes(other)) {
981
- throw new KnowledgeError('invalid', '冲突未双向登记:' + member.id + ' 的 conflictIds 缺少 ' + other);
982
- }
983
- }
984
- }
985
- const now = new Date().toISOString();
986
- const affectedIds = group.filter((id) => id !== lessonId);
987
- const resolutionPayload = { kind: resolution, targetId: lessonId, affectedIds };
988
- const remainingOf = (member) => member.lesson.conflictIds.filter((id) => !group.includes(id));
989
- const planned = [];
990
- if (resolution === 'prefer' || resolution === 'drop') {
991
- for (const member of members) {
992
- const isPrimary = member.id === lessonId;
993
- const patch = { conflictIds: remainingOf(member) };
994
- if (resolution === 'prefer') {
995
- if (isPrimary) {
996
- if (remainingOf(member).length === 0)
997
- patch.state = 'usable';
998
- patch.review = { ...member.lesson.review, at: now, note: maskedNote, resolution: resolutionPayload };
999
- }
1000
- else {
1001
- patch.state = 'rejected';
1002
- patch.review = { decision: 'rejected', actor: 'model', at: now, note: '冲突解析:败给 ' + lessonId, resolution: resolutionPayload };
1003
- }
1004
- }
1005
- else if (isPrimary) {
1006
- patch.state = 'rejected';
1007
- patch.review = { decision: 'rejected', actor: 'model', at: now, note: '冲突解析:放弃本条目', resolution: resolutionPayload };
1008
- }
1009
- else {
1010
- if (remainingOf(member).length === 0)
1011
- patch.state = 'usable';
1012
- patch.review = { ...member.lesson.review, at: now, note: maskedNote, resolution: resolutionPayload };
1013
- }
1014
- planned.push({ id: member.id, isPrimary, patch });
1015
- }
1016
- }
1017
- else {
1018
- const primaryMember = members.find((member) => member.id === lessonId);
1019
- const mergedEvidence = [...(primaryMember?.lesson.evidenceIds ?? [])];
1020
- for (const member of members) {
1021
- if (member.id === lessonId)
1022
- continue;
1023
- for (const id of member.lesson.evidenceIds) {
1024
- if (!mergedEvidence.includes(id))
1025
- mergedEvidence.push(id);
1026
- }
1027
- }
1028
- for (const member of members) {
1029
- const isPrimary = member.id === lessonId;
1030
- const patch = { conflictIds: remainingOf(member) };
1031
- if (isPrimary) {
1032
- const supersedes = [...(member.lesson.supersedes ?? [])];
1033
- for (const id of group) {
1034
- if (id !== member.id && !supersedes.includes(id))
1035
- supersedes.push(id);
1036
- }
1037
- if (remainingOf(member).length === 0)
1038
- patch.state = 'usable';
1039
- patch.supersedes = supersedes;
1040
- patch.evidenceIds = mergedEvidence;
1041
- patch.review = { ...member.lesson.review, at: now, note: '冲突解析:并入 ' + affectedIds.join('、'), resolution: resolutionPayload };
1042
- }
1043
- else {
1044
- patch.state = 'stale';
1045
- patch.review = { ...member.lesson.review, at: now, note: maskedNote, resolution: resolutionPayload };
1046
- }
1047
- planned.push({ id: member.id, isPrimary, patch });
1048
- }
1049
- }
1050
- const updates = [];
1051
- for (const item of planned) {
1052
- const member = members.find((entry) => entry.id === item.id);
1053
- if (member === undefined)
1054
- continue;
1055
- const current = member.lesson;
1056
- const desiredConflictIds = item.patch.conflictIds ?? [];
1057
- const reviewPatch = item.patch.review;
1058
- const evidencePatch = item.patch.evidenceIds;
1059
- const desiredState = item.patch.state;
1060
- const noOp = desiredState === current.state
1061
- && desiredConflictIds.length === current.conflictIds.length
1062
- && desiredConflictIds.every((id) => current.conflictIds.includes(id))
1063
- && (reviewPatch === undefined || (current.review.decision === reviewPatch.decision && current.review.note === reviewPatch.note))
1064
- && (evidencePatch === undefined
1065
- || (evidencePatch.length === current.evidenceIds.length && evidencePatch.every((id) => current.evidenceIds.includes(id))));
1066
- if (noOp)
1067
- continue;
1068
- updates.push({ id: item.id, patch: item.patch, expectedRevision: current.revision });
1069
- }
1070
- lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
1071
- break;
1072
- }
1073
- default:
1074
- throw new Error('不支持的动作:' + action);
1075
- }
1076
- return { ok: true, lesson, previousState: before.state, state: lesson.state, revision: lesson.revision };
872
+ return executeDreamReview(cfg, rawArgs, exec, 'model');
1077
873
  },
1078
874
  timeoutMs: 15000,
1079
875
  };
@@ -1102,3 +898,211 @@ export function buildDigestText(digest) {
1102
898
  }
1103
899
  return lines.join('\n');
1104
900
  }
901
+ const REVIEW_ACTIONS = ['accept', 'reject', 'reopen', 'mark-stale', 'mark-disputed', 'resolve-conflict', 'attach-evidence'];
902
+ /** actor comes from the trusted caller, never from tool or browser arguments. */
903
+ export async function executeDreamReview(cfg, rawArgs, exec, actor = 'model') {
904
+ const args = asRecord(rawArgs);
905
+ const action = optionalString(args, 'action');
906
+ if (action === undefined || !REVIEW_ACTIONS.includes(action)) {
907
+ throw new Error('action 必须是 ' + REVIEW_ACTIONS.join(' / ') + '。');
908
+ }
909
+ const targetId = optionalString(args, 'targetId');
910
+ const lessonIdArg = optionalString(args, 'lessonId');
911
+ const lessonId = targetId ?? lessonIdArg;
912
+ if (lessonId === undefined) {
913
+ throw new KnowledgeError('invalid', '缺少 lessonId(resolve-conflict 可用 targetId)。');
914
+ }
915
+ const revisionRaw = args.expectedRevision;
916
+ if (typeof revisionRaw !== 'number' || !Number.isInteger(revisionRaw) || revisionRaw < 1) {
917
+ throw new Error('expectedRevision 必须是 >= 1 的整数。');
918
+ }
919
+ const expectedRevision = revisionRaw;
920
+ const store = knowledgeStoreOf(cfg);
921
+ const before = store.getLesson(lessonId);
922
+ if (before === undefined)
923
+ throw new KnowledgeError('invalid', '经验不存在:' + lessonId);
924
+ if (before.revision !== expectedRevision) {
925
+ const error = new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
926
+ error.currentRevision = before.revision;
927
+ error.details = { currentRevision: before.revision };
928
+ throw error;
929
+ }
930
+ const affectedIdsArg = stringArray(args, 'affectedIds');
931
+ const conflictWith = affectedIdsArg.length > 0 ? affectedIdsArg : stringArray(args, 'conflictWith');
932
+ const note = optionalString(args, 'note');
933
+ const maskedNote = note === undefined ? undefined : maskIfNeeded(cfg, note);
934
+ const resolutionArg = optionalString(args, 'resolution');
935
+ const evidenceInputs = action === 'attach-evidence'
936
+ ? parseEvidenceArgs(args, 'evidence', before.scope.projectId).map(entry => verifyEvidence(entry, {
937
+ sessionsRoot: cfg.sessionsRoot, projectId: before.scope.projectId, workspaceRoot: before.scope.workspaceRoot, cwd: execCwdOf(exec),
938
+ })).map(entry => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }))
939
+ : [];
940
+ const idempotencyKey = 'dream_review:' + sha256Short(JSON.stringify({
941
+ actor, action, lessonId, expectedRevision, conflictWith, resolution: resolutionArg ?? null, note: maskedNote ?? null,
942
+ evidence: evidenceInputs,
943
+ }));
944
+ let lesson;
945
+ switch (action) {
946
+ case 'accept':
947
+ lesson = store.reviewLesson(lessonId, 'accepted', expectedRevision, idempotencyKey, actor);
948
+ break;
949
+ case 'reject':
950
+ lesson = store.reviewLesson(lessonId, 'rejected', expectedRevision, idempotencyKey, actor);
951
+ break;
952
+ case 'reopen':
953
+ if (before.state !== 'rejected')
954
+ throw new KnowledgeError('invalid', 'reopen 只用于把已驳回经验重新送审。');
955
+ lesson = store.reviewLesson(lessonId, 'unreviewed', expectedRevision, idempotencyKey, actor);
956
+ break;
957
+ case 'mark-stale':
958
+ lesson = actor === 'human'
959
+ ? store.updateLesson(lessonId, { state: 'stale', review: { ...before.review, actor, at: new Date().toISOString(), ...(maskedNote === undefined ? {} : { note: maskedNote }) } }, expectedRevision, idempotencyKey)
960
+ : store.applyTransition(lessonId, 'stale', expectedRevision, idempotencyKey);
961
+ break;
962
+ case 'attach-evidence':
963
+ lesson = store.attachEvidence(lessonId, evidenceInputs, expectedRevision, idempotencyKey);
964
+ break;
965
+ case 'mark-disputed': {
966
+ // M2:primary 与所有 conflictWith 一律置 disputed,双方 conflictIds 互相登记(去重、可多对多)。
967
+ const members = requireConflictGroup(store, lessonId, before, conflictWith);
968
+ const group = members.map((member) => member.id);
969
+ const updates = [];
970
+ for (const member of members) {
971
+ const others = group.filter((id) => id !== member.id);
972
+ const conflictIds = [...member.lesson.conflictIds];
973
+ for (const id of others) {
974
+ if (!conflictIds.includes(id))
975
+ conflictIds.push(id);
976
+ }
977
+ const noteSatisfied = maskedNote === undefined || member.lesson.review.note === maskedNote;
978
+ const alreadyDisputed = member.lesson.state === 'disputed'
979
+ && conflictIds.length === member.lesson.conflictIds.length
980
+ && conflictIds.every((id) => member.lesson.conflictIds.includes(id))
981
+ && noteSatisfied;
982
+ if (alreadyDisputed)
983
+ continue;
984
+ const patch = { state: 'disputed', conflictIds };
985
+ if (maskedNote !== undefined)
986
+ patch.review = { ...member.lesson.review, note: maskedNote };
987
+ updates.push({ id: member.id, patch, expectedRevision: member.lesson.revision });
988
+ }
989
+ lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
990
+ break;
991
+ }
992
+ case 'resolve-conflict': {
993
+ const resolution = optionalString(args, 'resolution');
994
+ if (resolution !== 'prefer' && resolution !== 'drop' && resolution !== 'merge') {
995
+ throw new KnowledgeError('invalid', 'resolution 必须是 prefer / drop / merge。');
996
+ }
997
+ if (maskedNote === undefined) {
998
+ throw new KnowledgeError('invalid', 'resolve-conflict 必须带 note(解析依据),以便事件可回溯。');
999
+ }
1000
+ if (conflictWith.length === 0) {
1001
+ throw new KnowledgeError('invalid', 'resolve-conflict 必须带至少一个 affectedIds/conflictWith 目标 id。');
1002
+ }
1003
+ const members = requireConflictGroup(store, lessonId, before, conflictWith);
1004
+ const group = members.map((member) => member.id);
1005
+ // 校验:所有当事方必须 disputed,且互相登记 conflictIds;否则零写入。
1006
+ for (const member of members) {
1007
+ if (member.lesson.state !== 'disputed') {
1008
+ throw new KnowledgeError('invalid', '冲突解析要求 ' + member.id + ' 处于 disputed(实际 ' + member.lesson.state + ')');
1009
+ }
1010
+ for (const other of group) {
1011
+ if (other !== member.id && !member.lesson.conflictIds.includes(other)) {
1012
+ throw new KnowledgeError('invalid', '冲突未双向登记:' + member.id + ' 的 conflictIds 缺少 ' + other);
1013
+ }
1014
+ }
1015
+ }
1016
+ const now = new Date().toISOString();
1017
+ const affectedIds = group.filter((id) => id !== lessonId);
1018
+ const resolutionPayload = { kind: resolution, targetId: lessonId, affectedIds };
1019
+ const remainingOf = (member) => member.lesson.conflictIds.filter((id) => !group.includes(id));
1020
+ const planned = [];
1021
+ if (resolution === 'prefer' || resolution === 'drop') {
1022
+ for (const member of members) {
1023
+ const isPrimary = member.id === lessonId;
1024
+ const patch = { conflictIds: remainingOf(member) };
1025
+ if (resolution === 'prefer') {
1026
+ if (isPrimary) {
1027
+ if (remainingOf(member).length === 0)
1028
+ patch.state = 'usable';
1029
+ patch.review = { ...member.lesson.review, ...(actor === 'human' ? { actor, decision: 'accepted' } : {}), at: now, note: maskedNote, resolution: resolutionPayload };
1030
+ }
1031
+ else {
1032
+ patch.state = 'rejected';
1033
+ patch.review = { decision: 'rejected', actor, at: now, note: '冲突解析:败给 ' + lessonId, resolution: resolutionPayload };
1034
+ }
1035
+ }
1036
+ else if (isPrimary) {
1037
+ patch.state = 'rejected';
1038
+ patch.review = { decision: 'rejected', actor, at: now, note: '冲突解析:放弃本条目', resolution: resolutionPayload };
1039
+ }
1040
+ else {
1041
+ if (remainingOf(member).length === 0)
1042
+ patch.state = 'usable';
1043
+ patch.review = { ...member.lesson.review, ...(actor === 'human' ? { actor, decision: 'accepted' } : {}), at: now, note: maskedNote, resolution: resolutionPayload };
1044
+ }
1045
+ planned.push({ id: member.id, isPrimary, patch });
1046
+ }
1047
+ }
1048
+ else {
1049
+ const primaryMember = members.find((member) => member.id === lessonId);
1050
+ const mergedEvidence = [...(primaryMember?.lesson.evidenceIds ?? [])];
1051
+ for (const member of members) {
1052
+ if (member.id === lessonId)
1053
+ continue;
1054
+ for (const id of member.lesson.evidenceIds) {
1055
+ if (!mergedEvidence.includes(id))
1056
+ mergedEvidence.push(id);
1057
+ }
1058
+ }
1059
+ for (const member of members) {
1060
+ const isPrimary = member.id === lessonId;
1061
+ const patch = { conflictIds: remainingOf(member) };
1062
+ if (isPrimary) {
1063
+ const supersedes = [...(member.lesson.supersedes ?? [])];
1064
+ for (const id of group) {
1065
+ if (id !== member.id && !supersedes.includes(id))
1066
+ supersedes.push(id);
1067
+ }
1068
+ if (remainingOf(member).length === 0)
1069
+ patch.state = 'usable';
1070
+ patch.supersedes = supersedes;
1071
+ patch.evidenceIds = mergedEvidence;
1072
+ patch.review = { ...member.lesson.review, ...(actor === 'human' ? { actor, decision: 'accepted' } : {}), at: now, note: '冲突解析:并入 ' + affectedIds.join('、'), resolution: resolutionPayload };
1073
+ }
1074
+ else {
1075
+ patch.state = 'stale';
1076
+ patch.review = { ...member.lesson.review, ...(actor === 'human' ? { actor } : {}), at: now, note: maskedNote, resolution: resolutionPayload };
1077
+ }
1078
+ planned.push({ id: member.id, isPrimary, patch });
1079
+ }
1080
+ }
1081
+ const updates = [];
1082
+ for (const item of planned) {
1083
+ const member = members.find((entry) => entry.id === item.id);
1084
+ if (member === undefined)
1085
+ continue;
1086
+ const current = member.lesson;
1087
+ const desiredConflictIds = item.patch.conflictIds ?? [];
1088
+ const reviewPatch = item.patch.review;
1089
+ const evidencePatch = item.patch.evidenceIds;
1090
+ const desiredState = item.patch.state;
1091
+ const noOp = desiredState === current.state
1092
+ && desiredConflictIds.length === current.conflictIds.length
1093
+ && desiredConflictIds.every((id) => current.conflictIds.includes(id))
1094
+ && (reviewPatch === undefined || (current.review.decision === reviewPatch.decision && current.review.note === reviewPatch.note))
1095
+ && (evidencePatch === undefined
1096
+ || (evidencePatch.length === current.evidenceIds.length && evidencePatch.every((id) => current.evidenceIds.includes(id))));
1097
+ if (noOp)
1098
+ continue;
1099
+ updates.push({ id: item.id, patch: item.patch, expectedRevision: current.revision });
1100
+ }
1101
+ lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
1102
+ break;
1103
+ }
1104
+ default:
1105
+ throw new Error('不支持的动作:' + action);
1106
+ }
1107
+ return { ok: true, lesson, previousState: before.state, state: lesson.state, revision: lesson.revision };
1108
+ }
@@ -0,0 +1,11 @@
1
+ import { type ResolvedDreamConfig } from './config.js';
2
+ export declare const DREAM_ACTION_ROUTE = "/api/dsh-dream/actions";
3
+ type Options = {
4
+ journalDir?: string;
5
+ config?: Record<string, unknown> | ResolvedDreamConfig | null;
6
+ };
7
+ /** Connection has authenticated the operator before this handler is called. */
8
+ export declare function createDreamActionHandler(options?: Options): (request: Request) => Promise<Response>;
9
+ /** No unauthenticated webServer fallback: old hosts keep their read-only panel. */
10
+ export declare function installDreamActions(ctx: any, options?: Options): void;
11
+ export {};