@stardustlc/dsh-dream 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.en.md +39 -14
- package/README.md +43 -18
- package/lib/bridge.d.ts +205 -2
- package/lib/bridge.js +1053 -6
- package/lib/client.js +634 -27
- package/lib/evidence.d.ts +15 -0
- package/lib/evidence.js +123 -0
- package/lib/index.js +3 -1
- package/lib/knowledge-store.d.ts +23 -5
- package/lib/knowledge-store.js +311 -41
- package/lib/knowledge.d.ts +40 -0
- package/lib/knowledge.js +54 -0
- package/lib/mask.d.ts +3 -1
- package/lib/mask.js +8 -2
- package/lib/retrieval.d.ts +12 -0
- package/lib/retrieval.js +68 -3
- package/lib/tools.d.ts +2 -0
- package/lib/tools.js +436 -128
- package/lib/web-actions.d.ts +11 -0
- package/lib/web-actions.js +175 -0
- package/lib/web.d.ts +50 -1
- package/lib/web.js +153 -13
- package/package.json +2 -2
- package/skills/dream-protocol/SKILL.md +9 -4
package/lib/tools.js
CHANGED
|
@@ -5,15 +5,16 @@
|
|
|
5
5
|
* @module dsh-dream/tools
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
|
-
import { existsSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
8
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { maskSecrets } from './mask.js';
|
|
11
11
|
import { dreamStats, readDreams, saveDream, searchDreams } from './journal.js';
|
|
12
|
-
import { bridgeDreams } from './bridge.js';
|
|
12
|
+
import { applyBridge, bridgeDreams, previewBridge, rollbackBridge, selectBridgeLessons, } from './bridge.js';
|
|
13
13
|
import { digestSessionFile, listSessionFiles } from './sessions.js';
|
|
14
14
|
import { KnowledgeError, lessonFingerprint, } from './knowledge.js';
|
|
15
15
|
import { KnowledgeStore } from './knowledge-store.js';
|
|
16
|
-
import {
|
|
16
|
+
import { verifyEvidence } from './evidence.js';
|
|
17
|
+
import { queryScopeLabel, resolveRetrievalBudget, retrieveLessons, retrievedLessonChars, taskPlatform, } from './retrieval.js';
|
|
17
18
|
/** 编译一个值 schema 节点;支持 enum、对象数组 items 与嵌套对象(宿主 schema 子集要求对象显式 additionalProperties)。 */
|
|
18
19
|
function compileValueNode(prop) {
|
|
19
20
|
const node = {};
|
|
@@ -129,6 +130,40 @@ function knowledgeDirOf(cfg) {
|
|
|
129
130
|
function knowledgeStoreOf(cfg) {
|
|
130
131
|
return new KnowledgeStore(knowledgeDirOf(cfg), { maskSecrets: cfg.maskSecrets });
|
|
131
132
|
}
|
|
133
|
+
function execCwdOf(exec) {
|
|
134
|
+
const root = asRecord(exec);
|
|
135
|
+
const candidates = [asRecord(asRecord(root.agent).session), asRecord(root.session), root];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
const cwd = candidate.cwd;
|
|
138
|
+
if (typeof cwd === 'string' && cwd.trim() !== '')
|
|
139
|
+
return cwd.trim();
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
/** 软链/junction 场景:跟随链接后的真实写入路径(与 bridge resolveBridgeTarget 同口径)。 */
|
|
144
|
+
function resolvedBridgePath(projectRoot, explicitPath) {
|
|
145
|
+
try {
|
|
146
|
+
const lexical = explicitPath !== undefined ? resolve(projectRoot, explicitPath) : join(projectRoot, 'AGENTS.md');
|
|
147
|
+
if (existsSync(lexical))
|
|
148
|
+
return realpathSync(lexical);
|
|
149
|
+
return join(realpathSync(dirname(lexical)), basename(lexical));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** 合并 bridge 自身 skipped 与工具层前置校验 skipped(按 lessonId 去重,bridge 结果优先)。 */
|
|
156
|
+
function mergeSkipped(primary, extra) {
|
|
157
|
+
const out = [...primary];
|
|
158
|
+
const seen = new Set(primary.map((entry) => entry.lessonId));
|
|
159
|
+
for (const entry of extra) {
|
|
160
|
+
if (seen.has(entry.lessonId))
|
|
161
|
+
continue;
|
|
162
|
+
seen.add(entry.lessonId);
|
|
163
|
+
out.push(entry);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
132
167
|
function sha256Short(value) {
|
|
133
168
|
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32);
|
|
134
169
|
}
|
|
@@ -173,6 +208,11 @@ function parseEvidenceArgs(args, key, projectId) {
|
|
|
173
208
|
entry.recordSeq = recordSeq;
|
|
174
209
|
if (projectId !== undefined)
|
|
175
210
|
entry.projectId = projectId;
|
|
211
|
+
for (const key of ['quote', 'sourceHash', 'artifactPath']) {
|
|
212
|
+
const value = optionalString(item, key);
|
|
213
|
+
if (value !== undefined)
|
|
214
|
+
entry[key] = value;
|
|
215
|
+
}
|
|
176
216
|
out.push(entry);
|
|
177
217
|
});
|
|
178
218
|
return out;
|
|
@@ -210,11 +250,31 @@ function evidenceItemsSpec() {
|
|
|
210
250
|
sessionId: { type: 'string', description: '会话 ID(session 证据建议提供)。' },
|
|
211
251
|
recordSeq: { oneOf: [{ type: 'string' }, { type: 'integer' }], description: '记录序号(数字或字符串)。' },
|
|
212
252
|
summary: { type: 'string', description: '已脱敏的一句话摘要。' },
|
|
213
|
-
|
|
253
|
+
quote: { type: 'string', description: '来源可见正文中的原文片段;摘要为转述时必填。引用原文只用于核验,不持久化。' },
|
|
254
|
+
sourceHash: { type: 'string', description: '可选的来源 SHA-256;插件自行计算并核对,不能只凭此值证明来源。' },
|
|
255
|
+
artifactPath: { type: 'string', description: 'local-artifact 的文本路径;只能读取宿主当前工作区内文件。' },
|
|
256
|
+
verification: { type: 'string', enum: ['read', 'claimed'], description: 'read=请求插件核验引用;找不到或不匹配自动降为 claimed。读取不代表用户采纳。' },
|
|
214
257
|
},
|
|
215
258
|
required: ['kind', 'summary', 'verification'],
|
|
216
259
|
};
|
|
217
260
|
}
|
|
261
|
+
/** 冲突组校验:primary + conflictWith 全部存在、无自身/重复;任何写前完成。 */
|
|
262
|
+
function requireConflictGroup(store, lessonId, primary, conflictWith) {
|
|
263
|
+
const group = [lessonId];
|
|
264
|
+
for (const id of conflictWith) {
|
|
265
|
+
if (id === lessonId)
|
|
266
|
+
throw new KnowledgeError('invalid', 'conflictWith 不得包含 lessonId 自身:' + id);
|
|
267
|
+
if (group.includes(id))
|
|
268
|
+
throw new KnowledgeError('invalid', 'conflictWith 存在重复 id:' + id);
|
|
269
|
+
group.push(id);
|
|
270
|
+
}
|
|
271
|
+
return group.map((id) => {
|
|
272
|
+
const lesson = id === lessonId ? primary : store.getLesson(id);
|
|
273
|
+
if (lesson === undefined)
|
|
274
|
+
throw new KnowledgeError('invalid', '冲突对象不存在:' + id);
|
|
275
|
+
return { id, lesson };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
218
278
|
const baseSchema = { type: 'object', additionalProperties: true };
|
|
219
279
|
/** 构建六个做梦工具。 */
|
|
220
280
|
export function buildDreamTools(config) {
|
|
@@ -378,28 +438,161 @@ export function buildDreamTools(config) {
|
|
|
378
438
|
};
|
|
379
439
|
const dreamBridge = {
|
|
380
440
|
name: 'dream_bridge',
|
|
381
|
-
description: '
|
|
441
|
+
description: '渡梦(M2):把已采纳且适用的经验桥接进项目 AGENTS.md 的 Dream 管理块(<!-- dsh-dream:start -->…<!-- dsh-dream:end -->),块外内容原样保留。mode:"preview" 只读看 diff;复核后 mode:"apply" 必须带 preview.expected.sha256(文件变了会 conflict 零写入);mode:"rollback" 带 backupId 恢复。缺省 mode 为 legacy 直写(已弃用,响应含 deprecation 提示)。资格:经验 state=usable,且 scope 与当前项目匹配(目标目录 realpath / 显式 projectId / global 且已采纳),频次不作准入。',
|
|
382
442
|
parameters: compileParameters({
|
|
383
|
-
|
|
384
|
-
|
|
443
|
+
mode: { type: 'string', enum: ['preview', 'apply', 'rollback'], description: 'preview=只读预览;apply=带 expectedSha256 落盘;rollback=按 backupId 恢复。缺省=legacy 直写(弃用)。' },
|
|
444
|
+
path: { type: 'string', description: '目标文件(可选;缺省为执行上下文 cwd 下的 AGENTS.md,取不到 cwd 会报错不猜)。' },
|
|
445
|
+
lessonIds: { type: 'array', items: { type: 'string' }, description: '参与桥接的经验 id(必须 usable 且 scope 匹配;缺省=按 store 顺序自动选取资格通过者)。' },
|
|
446
|
+
maxLessons: { type: 'integer', description: '经验上限:M2 模式默认 5、硬上限 20;legacy 模式默认 10、上限 30。' },
|
|
447
|
+
expectedSha256: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'apply 必填:preview.expected.sha256 原样回传;新文件用 null;不符则 conflict 零写入。' },
|
|
448
|
+
previewId: { type: 'string', description: 'apply 可带:preview.previewId,用于校验是同一份预览;块内容不一致 → preview-mismatch,需重新 preview。' },
|
|
449
|
+
backupId: { type: 'string', description: 'rollback 必填:apply 成功返回的 backupId。' },
|
|
450
|
+
projectId: { type: 'string', description: '当前项目 id(可选;用于匹配只带 projectId 的经验,纯增量)。' },
|
|
385
451
|
}),
|
|
386
452
|
output: {
|
|
387
453
|
schema: baseSchema,
|
|
388
454
|
render: (_args, value) => {
|
|
389
455
|
const rec = asRecord(value);
|
|
456
|
+
const mode = rec.mode;
|
|
457
|
+
if (rec.ok === false) {
|
|
458
|
+
const failure = asRecord(rec.error);
|
|
459
|
+
return [{ type: 'text', text: '桥接失败(' + (typeof mode === 'string' ? mode : 'legacy') + '):' + failure.code + ' ' + failure.message }];
|
|
460
|
+
}
|
|
461
|
+
if (mode === 'preview') {
|
|
462
|
+
const lessons = Array.isArray(rec.lessons) ? rec.lessons : [];
|
|
463
|
+
const skipped = Array.isArray(rec.skipped) ? rec.skipped : [];
|
|
464
|
+
const expected = asRecord(rec.expected);
|
|
465
|
+
const lines = ['桥接预览(' + rec.action + '):' + lessons.length + ' 条经验,当前 sha256=' + expected.sha256 + ',previewId=' + rec.previewId];
|
|
466
|
+
for (const item of lessons) {
|
|
467
|
+
const lesson = asRecord(item);
|
|
468
|
+
lines.push('- [' + lesson.lessonId + '@' + lesson.revision + '] ' + lesson.title);
|
|
469
|
+
}
|
|
470
|
+
for (const item of skipped) {
|
|
471
|
+
const entry = asRecord(item);
|
|
472
|
+
lines.push('· 跳过 ' + entry.lessonId + ':' + entry.reason);
|
|
473
|
+
}
|
|
474
|
+
const diff = typeof rec.diff === 'string' ? rec.diff : '';
|
|
475
|
+
if (diff !== '')
|
|
476
|
+
lines.push('', clip(diff, 2000));
|
|
477
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
478
|
+
}
|
|
479
|
+
if (mode === 'apply') {
|
|
480
|
+
if (rec.unchanged === true)
|
|
481
|
+
return [{ type: 'text', text: '桥接未变更:管理块内容已一致,字节未写。' }];
|
|
482
|
+
return [{ type: 'text', text: '桥接已应用:' + rec.path + ',backupId=' + rec.backupId + '。' + (typeof rec.warning === 'string' ? '\n' + rec.warning : '') }];
|
|
483
|
+
}
|
|
484
|
+
if (mode === 'rollback') {
|
|
485
|
+
return [{ type: 'text', text: '桥接已回滚:' + rec.path + ',backupId=' + rec.backupId + '。' }];
|
|
486
|
+
}
|
|
390
487
|
const actionText = rec.action === 'created' ? '新建了' : rec.action === 'replaced' ? '更新了' : '追加到了';
|
|
391
|
-
|
|
488
|
+
const deprecation = asRecord(rec.deprecation);
|
|
489
|
+
const deprecationText = typeof deprecation.message === 'string' && deprecation.message !== '' ? '(' + deprecation.message + ')' : '';
|
|
490
|
+
return [{ type: 'text', text: '已把 ' + rec.lessonsCount + ' 条梦境教训桥接:' + actionText + ' ' + rec.path + '。' + deprecationText }];
|
|
392
491
|
},
|
|
393
492
|
},
|
|
394
|
-
async execute(rawArgs) {
|
|
493
|
+
async execute(rawArgs, exec) {
|
|
395
494
|
const args = asRecord(rawArgs);
|
|
396
|
-
const
|
|
495
|
+
const mode = optionalString(args, 'mode');
|
|
496
|
+
if (mode !== undefined && mode !== 'preview' && mode !== 'apply' && mode !== 'rollback') {
|
|
497
|
+
throw new Error('mode 必须是 preview / apply / rollback(缺省为 legacy 直写)。');
|
|
498
|
+
}
|
|
499
|
+
const explicitPath = optionalString(args, 'path');
|
|
500
|
+
const cwd = execCwdOf(exec);
|
|
501
|
+
let projectRoot;
|
|
502
|
+
if (explicitPath !== undefined) {
|
|
503
|
+
projectRoot = cwd ?? dirname(resolve(explicitPath));
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
if (cwd === undefined) {
|
|
507
|
+
throw new Error('未提供 path 且无法从执行上下文取得 cwd:无法定位项目内 AGENTS.md,请显式传 path。');
|
|
508
|
+
}
|
|
509
|
+
projectRoot = cwd;
|
|
510
|
+
}
|
|
511
|
+
const resolvedPath = resolvedBridgePath(projectRoot, explicitPath);
|
|
397
512
|
const maxRaw = args.maxLessons;
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
513
|
+
const legacyMax = typeof maxRaw === 'number' && Number.isInteger(maxRaw) ? Math.min(30, Math.max(1, maxRaw)) : 10;
|
|
514
|
+
if (mode === undefined) {
|
|
515
|
+
const legacyTarget = explicitPath !== undefined ? resolve(explicitPath) : join(projectRoot, 'AGENTS.md');
|
|
516
|
+
const legacyResult = bridgeDreams(cfg.journalDir, legacyTarget, legacyMax);
|
|
517
|
+
return {
|
|
518
|
+
ok: true,
|
|
519
|
+
path: legacyTarget,
|
|
520
|
+
...(resolvedPath !== undefined ? { resolvedPath } : {}),
|
|
521
|
+
...legacyResult,
|
|
522
|
+
deprecation: {
|
|
523
|
+
code: 'legacy-write',
|
|
524
|
+
message: '缺省 mode 为 legacy 直写(已弃用);建议改用 mode:"preview" → 复核 diff → mode:"apply" + expectedSha256(并带同一 previewId)。',
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const maxLessons = typeof maxRaw === 'number' && Number.isInteger(maxRaw) ? maxRaw : undefined;
|
|
529
|
+
const lessonIds = stringArray(args, 'lessonIds');
|
|
530
|
+
const projectId = optionalString(args, 'projectId');
|
|
531
|
+
const store = knowledgeStoreOf(cfg);
|
|
532
|
+
const lessons = store.listLessons();
|
|
533
|
+
if (mode === 'rollback') {
|
|
534
|
+
const backupId = requiredString(args, 'backupId', 'backupId');
|
|
535
|
+
const result = rollbackBridge({
|
|
536
|
+
journalDir: cfg.journalDir,
|
|
537
|
+
projectRoot,
|
|
538
|
+
...(explicitPath !== undefined ? { path: explicitPath } : {}),
|
|
539
|
+
backupId,
|
|
540
|
+
});
|
|
541
|
+
if (result.ok === true)
|
|
542
|
+
return { mode, ...result, resolvedPath: result.path };
|
|
543
|
+
return { mode, ...result, ...(resolvedPath !== undefined ? { resolvedPath } : {}) };
|
|
544
|
+
}
|
|
545
|
+
// 工具层前置资格校验 + 合并 skipped(bridge 层会再兜一次)。
|
|
546
|
+
const preSelection = selectBridgeLessons(lessons, { projectId, workspaceRoot: projectRoot }, {
|
|
547
|
+
...(lessonIds.length > 0 ? { lessonIds } : {}),
|
|
548
|
+
...(maxLessons !== undefined ? { maxLessons } : {}),
|
|
549
|
+
});
|
|
550
|
+
const bridgeOptions = {
|
|
551
|
+
journalDir: cfg.journalDir,
|
|
552
|
+
projectRoot,
|
|
553
|
+
...(explicitPath !== undefined ? { path: explicitPath } : {}),
|
|
554
|
+
lessons,
|
|
555
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
556
|
+
workspaceRoot: projectRoot,
|
|
557
|
+
...(lessonIds.length > 0 ? { lessonIds } : {}),
|
|
558
|
+
...(maxLessons !== undefined ? { maxLessons } : {}),
|
|
559
|
+
maskSecrets: cfg.maskSecrets,
|
|
560
|
+
};
|
|
561
|
+
if (mode === 'preview') {
|
|
562
|
+
const result = previewBridge(bridgeOptions);
|
|
563
|
+
return {
|
|
564
|
+
mode,
|
|
565
|
+
...result,
|
|
566
|
+
...(resolvedPath !== undefined ? { resolvedPath } : {}),
|
|
567
|
+
skipped: mergeSkipped(result.ok === true ? result.skipped : [], preSelection.skipped),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (!Object.hasOwn(args, 'expectedSha256') || (args.expectedSha256 !== null && (typeof args.expectedSha256 !== 'string' || args.expectedSha256.trim() === ''))) {
|
|
571
|
+
throw new Error('apply 必须提供 expectedSha256:原样回传 preview.expected.sha256(文件不存在时为 null)。');
|
|
572
|
+
}
|
|
573
|
+
const expectedSha256 = args.expectedSha256;
|
|
574
|
+
const previewId = optionalString(args, 'previewId');
|
|
575
|
+
const result = applyBridge({
|
|
576
|
+
...bridgeOptions,
|
|
577
|
+
expectedSha256,
|
|
578
|
+
...(previewId !== undefined ? { previewId } : {}),
|
|
579
|
+
});
|
|
580
|
+
if (result.ok === true) {
|
|
581
|
+
return {
|
|
582
|
+
mode,
|
|
583
|
+
...result,
|
|
584
|
+
resolvedPath: result.path,
|
|
585
|
+
skipped: mergeSkipped(result.skipped, preSelection.skipped),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
mode,
|
|
590
|
+
...result,
|
|
591
|
+
...(resolvedPath !== undefined ? { resolvedPath } : {}),
|
|
592
|
+
skipped: mergeSkipped([], preSelection.skipped),
|
|
593
|
+
};
|
|
401
594
|
},
|
|
402
|
-
timeoutMs:
|
|
595
|
+
timeoutMs: 30000,
|
|
403
596
|
};
|
|
404
597
|
const dreamHealth = {
|
|
405
598
|
name: 'dream_health',
|
|
@@ -434,7 +627,7 @@ export function buildDreamTools(config) {
|
|
|
434
627
|
};
|
|
435
628
|
const dreamLearn = {
|
|
436
629
|
name: 'dream_learn',
|
|
437
|
-
description: '
|
|
630
|
+
description: '学梦:把一条可复用经验连同证据提交为候选。写清 title(一句话结论)、action(可执行动作)、when(适用条件)与 exceptions(例外);会话证据需 sessionId+recordSeq 和可匹配的 quote(省略时用 summary),本地产物需当前工作目录内的 artifactPath。verification=read 必须通过插件实际核验;失败降为 claimed 并说明原因,不计独立支持。新经验均先留在 candidate,读到来源不代表用户采纳。',
|
|
438
631
|
parameters: compileParameters({
|
|
439
632
|
kind: { type: 'string', required: true, enum: ['preference', 'procedure', 'pitfall', 'fact'], description: '经验类别。' },
|
|
440
633
|
title: { type: 'string', required: true, description: '一句话结论;脱离 when 不得单独使用。' },
|
|
@@ -471,7 +664,7 @@ export function buildDreamTools(config) {
|
|
|
471
664
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
472
665
|
},
|
|
473
666
|
},
|
|
474
|
-
async execute(rawArgs) {
|
|
667
|
+
async execute(rawArgs, exec) {
|
|
475
668
|
const args = asRecord(rawArgs);
|
|
476
669
|
const kind = parseLessonKind(args);
|
|
477
670
|
const title = maskIfNeeded(cfg, requiredString(args, 'title', '经验结论'));
|
|
@@ -488,6 +681,7 @@ export function buildDreamTools(config) {
|
|
|
488
681
|
...(entry.platform !== undefined ? { platform: maskIfNeeded(cfg, entry.platform) } : {}),
|
|
489
682
|
}));
|
|
490
683
|
const evidenceInputs = parseEvidenceArgs(args, 'evidence', projectId)
|
|
684
|
+
.map((entry) => verifyEvidence(entry, { sessionsRoot: cfg.sessionsRoot, projectId, workspaceRoot, cwd: execCwdOf(exec) }))
|
|
491
685
|
.map((entry) => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }));
|
|
492
686
|
const scope = {
|
|
493
687
|
global: isGlobal,
|
|
@@ -518,25 +712,16 @@ export function buildDreamTools(config) {
|
|
|
518
712
|
recordSeq: entry.recordSeq ?? null,
|
|
519
713
|
summary: entry.summary,
|
|
520
714
|
verification: entry.verification,
|
|
715
|
+
sourceHash: entry.sourceHash ?? null,
|
|
521
716
|
})),
|
|
522
717
|
}));
|
|
523
718
|
const store = knowledgeStoreOf(cfg);
|
|
524
|
-
|
|
525
|
-
// R2:user-correction + read 证据视为用户已采纳;初始 candidate 经验直接进入 usable,
|
|
526
|
-
// 并在 review 记 actor=user。若存储层未来也实现 R2,这里会因 state 已 usable 而跳过(兼容)。
|
|
527
|
-
const hasUserCorrectionRead = evidenceInputs.some((entry) => entry.kind === 'user-correction' && entry.verification === 'read');
|
|
528
|
-
const reviewIsUserAccepted = lesson.review.decision === 'accepted' && lesson.review.actor === 'user';
|
|
529
|
-
const userReviewAllowed = lesson.state === 'candidate' || lesson.state === 'usable';
|
|
530
|
-
// 兼容两种时序:R2′ 未落盘时 createLesson 返回 candidate;已落盘时返回 usable(review 未必带 actor)。
|
|
531
|
-
// user-correction + read 一律记为用户已采纳;rejected/disputed/stale 不覆盖既有审阅结论。
|
|
532
|
-
if (hasUserCorrectionRead && !reviewIsUserAccepted && userReviewAllowed) {
|
|
533
|
-
lesson = store.reviewLesson(lesson.id, 'accepted', lesson.revision, 'dream_learn:user-correction:' + lesson.id + ':' + lesson.revision, 'user');
|
|
534
|
-
}
|
|
719
|
+
const { lesson, created } = store.createLesson(lessonInput, idempotencyKey, { requireReview: true });
|
|
535
720
|
const byId = new Map(store.listEvidence().map((item) => [item.id, item]));
|
|
536
721
|
const evidence = lesson.evidenceIds
|
|
537
722
|
.map((id) => byId.get(id))
|
|
538
723
|
.filter((item) => item !== undefined)
|
|
539
|
-
.map((item) => ({ id: item.id, verification: item.verification }));
|
|
724
|
+
.map((item) => ({ id: item.id, verification: item.verification, ...(item.verificationReason ? { verificationReason: item.verificationReason } : {}) }));
|
|
540
725
|
const notes = [];
|
|
541
726
|
if (evidenceInputs.length === 0) {
|
|
542
727
|
notes.push('无证据:仅保存为 candidate,不会作为已证实结论注入任务建议。');
|
|
@@ -546,14 +731,10 @@ export function buildDreamTools(config) {
|
|
|
546
731
|
const claimedCount = evidence.filter((item) => item.verification === 'claimed').length;
|
|
547
732
|
notes.push('证据:' + readCount + ' 条已读(计入独立支持),' + claimedCount + ' 条仅声明(不计入独立支持)。');
|
|
548
733
|
}
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
else {
|
|
554
|
-
notes.push('检测到 user-correction + read 证据,但经验当前状态为 ' + lesson.state + ':不覆盖既有审阅结论。');
|
|
555
|
-
}
|
|
556
|
-
}
|
|
734
|
+
if (evidenceInputs.some((item) => item.verification === 'claimed' && item.verificationReason !== 'model-claimed'))
|
|
735
|
+
notes.push('部分来源未能核验,已保留为仅声明;请补充真实 sessionId/recordSeq、原文 quote 或当前工作区内 artifactPath。');
|
|
736
|
+
if (created)
|
|
737
|
+
notes.push('来源读取不等于结论成立或用户采纳;新经验仍需审阅。');
|
|
557
738
|
if (!created)
|
|
558
739
|
notes.push('已存在同指纹经验(' + lesson.id + '):复用既有记录并合并新证据,未新增经验。');
|
|
559
740
|
if (!isGlobal && projectId === undefined && workspaceRoot === undefined) {
|
|
@@ -586,6 +767,7 @@ export function buildDreamTools(config) {
|
|
|
586
767
|
maxChars: { type: 'integer', description: '字符预算 1-20000(默认 3000)。' },
|
|
587
768
|
packageVersion: { type: 'string', description: '当前相关依赖版本(可选;需配合包名被识别才参与版本判定)。' },
|
|
588
769
|
packageName: { type: 'string', description: '当前相关依赖包名(可选;与 query 文本一起识别 applicability.package)。' },
|
|
770
|
+
platform: { type: 'string', description: '任务目标平台(windows / linux / macos 等);缺省从任务文本识别,否则使用宿主系统。平台不符的经验不注入。' },
|
|
589
771
|
includeCandidates: { type: 'boolean', description: '是否把候选经验纳入任务建议(默认 false;仅明确审阅候选时传 true,无证据候选仍不注入)。' },
|
|
590
772
|
}),
|
|
591
773
|
output: {
|
|
@@ -639,6 +821,7 @@ export function buildDreamTools(config) {
|
|
|
639
821
|
});
|
|
640
822
|
const retrievalQuery = {
|
|
641
823
|
query,
|
|
824
|
+
platform: taskPlatform(query, optionalString(args, 'platform'), process.platform),
|
|
642
825
|
limit,
|
|
643
826
|
maxChars,
|
|
644
827
|
// R1:默认不注入候选;claimed-only / 无证据且非 usable 的条目也不注入(R1 附带规则)。
|
|
@@ -661,17 +844,19 @@ export function buildDreamTools(config) {
|
|
|
661
844
|
},
|
|
662
845
|
timeoutMs: 15000,
|
|
663
846
|
};
|
|
664
|
-
const REVIEW_ACTIONS = ['accept', 'reject', 'mark-stale', 'mark-disputed', 'resolve-conflict', 'attach-evidence'];
|
|
665
847
|
const dreamReview = {
|
|
666
848
|
name: 'dream_review',
|
|
667
849
|
description: '审梦:采纳/驳回/标记冲突或过期/追加验证证据,必须带 expectedRevision(revision 不符会拒绝且不写入)。模型调用 actor 一律记为 model,不得冒充用户采纳;attach-evidence 只追加证据,不自动改变 state。',
|
|
668
850
|
parameters: compileParameters({
|
|
669
851
|
action: { type: 'string', required: true, enum: [...REVIEW_ACTIONS], description: '审阅动作。' },
|
|
670
|
-
lessonId: { type: 'string',
|
|
852
|
+
lessonId: { type: 'string', description: '经验 ID(accept/reject/mark-*/attach-evidence 必填;resolve-conflict 可用 targetId 替代)。' },
|
|
853
|
+
targetId: { type: 'string', description: 'resolve-conflict:目标经验 ID(优先于 lessonId)。' },
|
|
854
|
+
affectedIds: { type: 'array', items: { type: 'string' }, description: 'resolve-conflict:受影响/对方经验 ID(缺省则用 conflictWith)。' },
|
|
671
855
|
expectedRevision: { type: 'integer', required: true, description: '期望 revision(乐观锁,必须与当前一致)。' },
|
|
672
856
|
evidence: { type: 'array', items: evidenceItemsSpec(), description: 'attach-evidence 时使用的证据数组。' },
|
|
673
|
-
conflictWith: { type: 'array', items: { type: 'string' }, description: 'mark-disputed
|
|
674
|
-
|
|
857
|
+
conflictWith: { type: 'array', items: { type: 'string' }, description: 'mark-disputed:冲突对方 id 列表(双方互相登记且全部置 disputed);resolve-conflict:要一起解析的对方 id(必须与之互为 conflictIds)。' },
|
|
858
|
+
resolution: { type: 'string', enum: ['prefer', 'drop', 'merge'], description: 'resolve-conflict 必填:prefer=以 lessonId 为准;drop=放弃 lessonId;merge=并入 lessonId(对方 stale)。' },
|
|
859
|
+
note: { type: 'string', description: 'resolve-conflict 必填的解析依据;mark-disputed 可选。' },
|
|
675
860
|
}),
|
|
676
861
|
output: {
|
|
677
862
|
schema: baseSchema,
|
|
@@ -683,93 +868,8 @@ export function buildDreamTools(config) {
|
|
|
683
868
|
}];
|
|
684
869
|
},
|
|
685
870
|
},
|
|
686
|
-
async execute(rawArgs) {
|
|
687
|
-
|
|
688
|
-
const action = optionalString(args, 'action');
|
|
689
|
-
if (action === undefined || !REVIEW_ACTIONS.includes(action)) {
|
|
690
|
-
throw new Error('action 必须是 ' + REVIEW_ACTIONS.join(' / ') + '。');
|
|
691
|
-
}
|
|
692
|
-
const lessonId = requiredString(args, 'lessonId', '经验 ID');
|
|
693
|
-
const revisionRaw = args.expectedRevision;
|
|
694
|
-
if (typeof revisionRaw !== 'number' || !Number.isInteger(revisionRaw) || revisionRaw < 1) {
|
|
695
|
-
throw new Error('expectedRevision 必须是 >= 1 的整数。');
|
|
696
|
-
}
|
|
697
|
-
const expectedRevision = revisionRaw;
|
|
698
|
-
const store = knowledgeStoreOf(cfg);
|
|
699
|
-
const before = store.getLesson(lessonId);
|
|
700
|
-
if (before === undefined)
|
|
701
|
-
throw new KnowledgeError('invalid', '经验不存在:' + lessonId);
|
|
702
|
-
if (before.revision !== expectedRevision) {
|
|
703
|
-
const error = new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
|
|
704
|
-
error.currentRevision = before.revision;
|
|
705
|
-
error.details = { currentRevision: before.revision };
|
|
706
|
-
throw error;
|
|
707
|
-
}
|
|
708
|
-
const conflictWith = stringArray(args, 'conflictWith');
|
|
709
|
-
const note = optionalString(args, 'note');
|
|
710
|
-
const idempotencyKey = 'dream_review:' + sha256Short(JSON.stringify({
|
|
711
|
-
action, lessonId, expectedRevision, conflictWith, note: note ?? null,
|
|
712
|
-
}));
|
|
713
|
-
// 模型调用永远记为 model;入参里的 actor 一律忽略(用户采纳只能来自宿主授权的真实用户指令)。
|
|
714
|
-
const actor = 'model';
|
|
715
|
-
let lesson;
|
|
716
|
-
switch (action) {
|
|
717
|
-
case 'accept':
|
|
718
|
-
lesson = store.reviewLesson(lessonId, 'accepted', expectedRevision, idempotencyKey, actor);
|
|
719
|
-
break;
|
|
720
|
-
case 'reject':
|
|
721
|
-
lesson = store.reviewLesson(lessonId, 'rejected', expectedRevision, idempotencyKey, actor);
|
|
722
|
-
break;
|
|
723
|
-
case 'mark-stale':
|
|
724
|
-
lesson = store.applyTransition(lessonId, 'stale', expectedRevision, idempotencyKey);
|
|
725
|
-
break;
|
|
726
|
-
case 'mark-disputed': {
|
|
727
|
-
if (conflictWith.length === 0) {
|
|
728
|
-
lesson = store.applyTransition(lessonId, 'disputed', expectedRevision, idempotencyKey);
|
|
729
|
-
}
|
|
730
|
-
else {
|
|
731
|
-
const conflictIds = [...before.conflictIds];
|
|
732
|
-
for (const id of conflictWith) {
|
|
733
|
-
if (!conflictIds.includes(id))
|
|
734
|
-
conflictIds.push(id);
|
|
735
|
-
}
|
|
736
|
-
lesson = store.updateLesson(lessonId, { state: 'disputed', conflictIds }, expectedRevision, idempotencyKey);
|
|
737
|
-
}
|
|
738
|
-
break;
|
|
739
|
-
}
|
|
740
|
-
case 'resolve-conflict': {
|
|
741
|
-
if (conflictWith.length === 0) {
|
|
742
|
-
lesson = store.applyTransition(lessonId, 'usable', expectedRevision, idempotencyKey);
|
|
743
|
-
}
|
|
744
|
-
else {
|
|
745
|
-
const conflictIds = before.conflictIds.filter((id) => !conflictWith.includes(id));
|
|
746
|
-
lesson = store.updateLesson(lessonId, { state: 'usable', conflictIds }, expectedRevision, idempotencyKey);
|
|
747
|
-
}
|
|
748
|
-
break;
|
|
749
|
-
}
|
|
750
|
-
case 'attach-evidence': {
|
|
751
|
-
const inputs = parseEvidenceArgs(args, 'evidence', before.scope.projectId);
|
|
752
|
-
if (inputs.length === 0)
|
|
753
|
-
throw new Error('attach-evidence 需要至少一条 evidence。');
|
|
754
|
-
const masked = inputs.map((entry) => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }));
|
|
755
|
-
const appended = masked.map((entry) => store.appendEvidence(entry));
|
|
756
|
-
const evidenceIds = [...before.evidenceIds];
|
|
757
|
-
for (const item of appended) {
|
|
758
|
-
if (!evidenceIds.includes(item.evidence.id))
|
|
759
|
-
evidenceIds.push(item.evidence.id);
|
|
760
|
-
}
|
|
761
|
-
if (evidenceIds.length === before.evidenceIds.length) {
|
|
762
|
-
lesson = before;
|
|
763
|
-
}
|
|
764
|
-
else {
|
|
765
|
-
lesson = store.updateLesson(lessonId, { evidenceIds }, expectedRevision, idempotencyKey);
|
|
766
|
-
}
|
|
767
|
-
break;
|
|
768
|
-
}
|
|
769
|
-
default:
|
|
770
|
-
throw new Error('不支持的动作:' + action);
|
|
771
|
-
}
|
|
772
|
-
return { ok: true, lesson, previousState: before.state, state: lesson.state, revision: lesson.revision };
|
|
871
|
+
async execute(rawArgs, exec) {
|
|
872
|
+
return executeDreamReview(cfg, rawArgs, exec, 'model');
|
|
773
873
|
},
|
|
774
874
|
timeoutMs: 15000,
|
|
775
875
|
};
|
|
@@ -798,3 +898,211 @@ export function buildDigestText(digest) {
|
|
|
798
898
|
}
|
|
799
899
|
return lines.join('\n');
|
|
800
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
|
+
}
|