@stardustlc/dsh-dream 0.6.0 → 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.
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 {};
@@ -0,0 +1,175 @@
1
+ /** Mutations are mounted exclusively on Connection's authenticated Fetch carrier. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { realpathSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { resolveConfig } from './config.js';
6
+ import { KnowledgeError } from './knowledge.js';
7
+ import { KnowledgeStore } from './knowledge-store.js';
8
+ import { maskSecrets } from './mask.js';
9
+ import { buildDreamTools, executeDreamReview } from './tools.js';
10
+ import { rollbackBridge } from './bridge.js';
11
+ import { maskDeep } from './web.js';
12
+ export const DREAM_ACTION_ROUTE = '/api/dsh-dream/actions';
13
+ const BODY_LIMIT = 64 * 1024;
14
+ const PREVIEW_LIFETIME = 10 * 60 * 1000;
15
+ const MAX_PREVIEWS = 100;
16
+ function json(status, value) {
17
+ return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
18
+ }
19
+ function fail(status, code, message) {
20
+ return json(status, { ok: false, error: { code, message: maskSecrets(message) } });
21
+ }
22
+ function text(body, key) {
23
+ const value = body[key];
24
+ if (typeof value !== 'string' || value.trim() === '' || value.length > 2000)
25
+ throw new KnowledgeError('invalid', key + ' 必须是非空文本,最多 2000 字符');
26
+ return value.trim();
27
+ }
28
+ function checkRevision(store, body) {
29
+ const id = text(body, 'lessonId');
30
+ const lesson = store.getLesson(id);
31
+ if (lesson === undefined)
32
+ throw new KnowledgeError('invalid', '经验不存在,请刷新列表');
33
+ if (typeof body.expectedRevision !== 'number' || !Number.isInteger(body.expectedRevision) || body.expectedRevision < 1)
34
+ throw new KnowledgeError('invalid', '缺少有效的经验版本');
35
+ if (lesson.revision !== body.expectedRevision)
36
+ throw new KnowledgeError('revision', '经验已更新,请刷新后重新操作');
37
+ return lesson;
38
+ }
39
+ /** Connection has authenticated the operator before this handler is called. */
40
+ export function createDreamActionHandler(options = {}) {
41
+ const cfg = { ...resolveConfig(options.config), ...(options.journalDir === undefined ? {} : { journalDir: options.journalDir }) };
42
+ const store = new KnowledgeStore(join(cfg.journalDir, 'knowledge'), { maskSecrets: cfg.maskSecrets });
43
+ const bridge = buildDreamTools(cfg).find(tool => tool.name === 'dream_bridge');
44
+ const previews = new Map();
45
+ return async (request) => {
46
+ if (request.method !== 'POST')
47
+ return fail(405, 'method', '请使用 POST');
48
+ if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
49
+ return fail(415, 'content-type', '需要 application/json');
50
+ // A web form or cross-origin simple request cannot supply this header.
51
+ if (request.headers.get('x-dsh-dream-action') !== '1')
52
+ return fail(403, 'forbidden', '请从 Dream 面板操作');
53
+ const site = request.headers.get('sec-fetch-site');
54
+ if (site !== null && site !== 'same-origin' && site !== 'none')
55
+ return fail(403, 'forbidden', '拒绝跨站操作');
56
+ const origin = request.headers.get('origin');
57
+ if (origin !== null && /^https?:/i.test(origin)) {
58
+ try {
59
+ // The official node:http bridge intentionally uses http://dsh.internal
60
+ // as its Fetch URL base; the preserved Host is the original authority.
61
+ const host = request.headers.get('host');
62
+ const authority = host === null ? new URL(request.url).host : new URL('http://' + host).host;
63
+ if (new URL(origin).host !== authority)
64
+ return fail(403, 'forbidden', '拒绝跨源操作');
65
+ }
66
+ catch {
67
+ return fail(403, 'forbidden', '无效来源');
68
+ }
69
+ }
70
+ try {
71
+ const reader = request.body?.getReader();
72
+ if (reader === undefined)
73
+ return fail(400, 'invalid', '缺少请求内容');
74
+ const chunks = [];
75
+ let size = 0;
76
+ try {
77
+ for (;;) {
78
+ const part = await reader.read();
79
+ if (part.done)
80
+ break;
81
+ size += part.value.byteLength;
82
+ if (size > BODY_LIMIT) {
83
+ await reader.cancel();
84
+ return fail(413, 'too-large', '请求内容超过 64 KB');
85
+ }
86
+ chunks.push(part.value);
87
+ }
88
+ }
89
+ finally {
90
+ reader.releaseLock();
91
+ }
92
+ let raw;
93
+ try {
94
+ raw = JSON.parse(Buffer.concat(chunks).toString('utf8'));
95
+ }
96
+ catch {
97
+ return fail(400, 'invalid', '请求不是有效 JSON');
98
+ }
99
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
100
+ return fail(400, 'invalid', '请求必须是对象');
101
+ const body = raw;
102
+ if (request.signal.aborted)
103
+ return fail(409, 'cancelled', '操作已取消');
104
+ const operation = text(body, 'operation');
105
+ if (operation === 'review') {
106
+ if (!['accept', 'reject', 'reopen', 'mark-stale', 'resolve-conflict'].includes(String(body.action)))
107
+ throw new KnowledgeError('invalid', '不支持此页面审阅动作');
108
+ checkRevision(store, body);
109
+ const result = await executeDreamReview(cfg, body, undefined, 'human');
110
+ return json(200, maskDeep(result));
111
+ }
112
+ if (operation === 'feedback') {
113
+ // The store checks revision inside its lock and keeps retry keys idempotent.
114
+ const id = text(body, 'lessonId');
115
+ const key = text(body, 'requestId');
116
+ if (!/^[a-zA-Z0-9_-]{8,100}$/.test(key))
117
+ throw new KnowledgeError('invalid', '反馈请求标识无效');
118
+ const note = body.note === undefined ? undefined : text(body, 'note');
119
+ const lesson = store.recordFeedback(id, body.vote, note, body.expectedRevision, 'web-feedback:' + key);
120
+ return json(200, maskDeep({ ok: true, lesson, message: '已记录使用反馈,审阅状态保持原样' }));
121
+ }
122
+ for (const [key, value] of previews)
123
+ if (value.expires < Date.now())
124
+ previews.delete(key);
125
+ if (operation === 'preview') {
126
+ const lesson = checkRevision(store, body);
127
+ const root = lesson.scope.workspaceRoot;
128
+ if (root === undefined || !isAbsolute(root))
129
+ throw new KnowledgeError('invalid', '这条经验没有项目目录,请在对应项目的对话中预览规则');
130
+ const canonicalRoot = realpathSync(root);
131
+ const result = await bridge.execute({ mode: 'preview', lessonIds: [lesson.id], path: 'AGENTS.md', projectId: lesson.scope.projectId }, { cwd: canonicalRoot });
132
+ if (result.ok !== true)
133
+ return json(409, maskDeep(result));
134
+ if (result.lessons.length === 0)
135
+ return fail(409, 'ineligible', '当前经验不满足写入资格,请先查看适用范围与审阅状态');
136
+ while (previews.size >= MAX_PREVIEWS)
137
+ previews.delete(previews.keys().next().value);
138
+ const token = randomUUID();
139
+ previews.set(token, { expires: Date.now() + PREVIEW_LIFETIME, root: canonicalRoot, lessonId: lesson.id, revision: lesson.revision, projectId: lesson.scope.projectId, sha: result.expected.sha256, previewId: result.previewId });
140
+ return json(200, { ...maskDeep(result), token });
141
+ }
142
+ if (operation === 'apply') {
143
+ const token = text(body, 'token');
144
+ const preview = previews.get(token);
145
+ if (preview === undefined)
146
+ return fail(409, 'preview-expired', '预览已过期,请重新预览');
147
+ const lesson = checkRevision(store, { lessonId: preview.lessonId, expectedRevision: preview.revision });
148
+ if (realpathSync(lesson.scope.workspaceRoot) !== preview.root)
149
+ return fail(409, 'revision', '项目目录已变化,请重新预览');
150
+ const result = await bridge.execute({ mode: 'apply', lessonIds: [preview.lessonId], path: 'AGENTS.md', projectId: preview.projectId, expectedSha256: preview.sha, previewId: preview.previewId }, { cwd: preview.root });
151
+ previews.delete(token);
152
+ return json(result.ok === true ? 200 : 409, maskDeep(result));
153
+ }
154
+ if (operation === 'rollback') {
155
+ const result = rollbackBridge({ journalDir: cfg.journalDir, projectRoot: '', backupId: text(body, 'backupId') });
156
+ return json(result.ok === true ? 200 : 409, maskDeep(result));
157
+ }
158
+ throw new KnowledgeError('invalid', '不支持此操作');
159
+ }
160
+ catch (error) {
161
+ if (error instanceof KnowledgeError)
162
+ return fail(error.code === 'revision' || error.code === 'duplicate' ? 409 : error.code === 'io' ? 500 : 400, error.code, error.message);
163
+ return fail(500, 'io', '操作失败:' + (error instanceof Error ? error.message : String(error)));
164
+ }
165
+ };
166
+ }
167
+ /** No unauthenticated webServer fallback: old hosts keep their read-only panel. */
168
+ export function installDreamActions(ctx, options = {}) {
169
+ const fetch = createDreamActionHandler(options);
170
+ ctx.inject(['connection'], (host) => {
171
+ if (typeof host.connection?.fetch?.register !== 'function')
172
+ return;
173
+ host.connection.fetch.register({ path: DREAM_ACTION_ROUTE, methods: ['POST'], requestBody: 'buffered', fetch });
174
+ });
175
+ }
package/lib/web.d.ts CHANGED
@@ -106,6 +106,8 @@ export declare function limitFromQuery(raw: unknown, fallback?: number): number;
106
106
  export declare function knowledgeDirOf(options: DreamWebOptions, journalDir?: string): string;
107
107
  /** 解析后的 bridgeDir:显式路径优先,否则 = <journalDir>/bridge。 */
108
108
  export declare function bridgeDirOf(options: DreamWebOptions, journalDir?: string): string;
109
+ /** 递归脱敏;结构化绝对路径按目录分段,避免 Unix 分隔符把正常路径连成长令牌。 */
110
+ export declare function maskDeep<T>(value: T, key?: string): T;
109
111
  /** 知识目录不存在(或尚未建立)时的空载荷:不写盘、不报 404。 */
110
112
  export declare function emptyKnowledgePayload(): KnowledgeWebPayload;
111
113
  /**
package/lib/web.js CHANGED
@@ -139,7 +139,7 @@ const HASH_KEY_RE = /^(?:sourceHash|beforeSha256|afterSha256|afterBlockSha256|af
139
139
  const REFERENCE_KEYS = new Set(['id', 'lessonId', 'lessonIds', 'evidenceIds', 'conflictIds', 'supersedes', 'targetId', 'affectedIds', 'backupId', 'previewId']);
140
140
  const PATH_KEYS = new Set(['target', 'path', 'resolvedPath', 'workspaceRoot']);
141
141
  /** 递归脱敏;结构化绝对路径按目录分段,避免 Unix 分隔符把正常路径连成长令牌。 */
142
- function maskDeep(value, key) {
142
+ export function maskDeep(value, key) {
143
143
  if (typeof value === 'string') {
144
144
  if (key !== undefined && HASH_KEY_RE.test(key) && /^[0-9a-f]{64}$/i.test(value))
145
145
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardustlc/dsh-dream",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DSH 做梦与经验记忆:核验来源、审阅经验,预览写入 AGENTS.md 并支持回滚;附梦境日记面板,默认隐私脱敏,零运行时依赖。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",