@stardustlc/dsh-dream 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 { queryScopeLabel, resolveRetrievalBudget, retrieveLessons, retrievedLessonChars, } from './retrieval.js';
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
- verification: { type: 'string', enum: ['read', 'claimed'], description: 'read=插件读到原记录;claimed=仅模型声称。' },
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: '渡梦:把梦境日记里的高频教训合并进目标 AGENTS.md(幂等,带标记块,重复执行只更新块内内容)。让梦真正变成长期记忆。没有梦时会拒绝并提示先做梦。',
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
- path: { type: 'string', required: true, description: '目标文件路径(必填,通常是项目根 AGENTS.md)。' },
384
- maxLessons: { type: 'integer', description: '最多桥接教训条数 1-30(默认 10)。' },
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
- return [{ type: 'text', text: '已把 ' + rec.lessonsCount + ' 条梦境教训桥接:' + actionText + ' ' + rec.path + '。梦醒之后,教训长存。' }];
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 targetPath = requiredString(args, 'path', '目标文件路径');
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 maxLessons = typeof maxRaw === 'number' && Number.isInteger(maxRaw) ? Math.min(30, Math.max(1, maxRaw)) : 10;
399
- const result = bridgeDreams(cfg.journalDir, targetPath, maxLessons);
400
- return { ok: true, path: targetPath, ...result };
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: 15000,
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: '学梦:把一条可复用经验连同证据提交为候选。必须写清 title(一句话结论)、action(可执行动作)、when(适用条件)与 exceptions(例外);evidence 用 sessionId+recordSeq 或 sourceHash 定位来源,verification=read 表示插件读到原记录、claimed 表示仅模型声称(不计入独立支持)。无证据的经验只以 candidate 保存,不会当作已证实结论注入。模型不得伪造用户采纳。',
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
- let { lesson, created } = store.createLesson(lessonInput, idempotencyKey);
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 (hasUserCorrectionRead) {
550
- if (lesson.state === 'usable' && lesson.review.decision === 'accepted' && lesson.review.actor === 'user') {
551
- notes.push('检测到 user-correction + read 证据:按 R2 视为用户已采纳(state=usable,review.actor=user)。');
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,20 @@ 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'];
847
+ const REVIEW_ACTIONS = ['accept', 'reject', 'reopen', 'mark-stale', 'mark-disputed', 'resolve-conflict', 'attach-evidence'];
665
848
  const dreamReview = {
666
849
  name: 'dream_review',
667
850
  description: '审梦:采纳/驳回/标记冲突或过期/追加验证证据,必须带 expectedRevision(revision 不符会拒绝且不写入)。模型调用 actor 一律记为 model,不得冒充用户采纳;attach-evidence 只追加证据,不自动改变 state。',
668
851
  parameters: compileParameters({
669
852
  action: { type: 'string', required: true, enum: [...REVIEW_ACTIONS], description: '审阅动作。' },
670
- lessonId: { type: 'string', required: true, description: '经验 ID。' },
853
+ lessonId: { type: 'string', description: '经验 ID(accept/reject/mark-*/attach-evidence 必填;resolve-conflict 可用 targetId 替代)。' },
854
+ targetId: { type: 'string', description: 'resolve-conflict:目标经验 ID(优先于 lessonId)。' },
855
+ affectedIds: { type: 'array', items: { type: 'string' }, description: 'resolve-conflict:受影响/对方经验 ID(缺省则用 conflictWith)。' },
671
856
  expectedRevision: { type: 'integer', required: true, description: '期望 revision(乐观锁,必须与当前一致)。' },
672
857
  evidence: { type: 'array', items: evidenceItemsSpec(), description: 'attach-evidence 时使用的证据数组。' },
673
- conflictWith: { type: 'array', items: { type: 'string' }, description: 'mark-disputed 记录冲突对象;resolve-conflict 时解除这些冲突 ID。' },
674
- note: { type: 'string', description: '备注(仅用于幂等键,不落盘为正文)。' },
858
+ conflictWith: { type: 'array', items: { type: 'string' }, description: 'mark-disputed:冲突对方 id 列表(双方互相登记且全部置 disputed);resolve-conflict:要一起解析的对方 id(必须与之互为 conflictIds)。' },
859
+ resolution: { type: 'string', enum: ['prefer', 'drop', 'merge'], description: 'resolve-conflict 必填:prefer=以 lessonId 为准;drop=放弃 lessonId;merge=并入 lessonId(对方 stale)。' },
860
+ note: { type: 'string', description: 'resolve-conflict 必填的解析依据;mark-disputed 可选。' },
675
861
  }),
676
862
  output: {
677
863
  schema: baseSchema,
@@ -683,13 +869,18 @@ export function buildDreamTools(config) {
683
869
  }];
684
870
  },
685
871
  },
686
- async execute(rawArgs) {
872
+ async execute(rawArgs, exec) {
687
873
  const args = asRecord(rawArgs);
688
874
  const action = optionalString(args, 'action');
689
875
  if (action === undefined || !REVIEW_ACTIONS.includes(action)) {
690
876
  throw new Error('action 必须是 ' + REVIEW_ACTIONS.join(' / ') + '。');
691
877
  }
692
- const lessonId = requiredString(args, 'lessonId', '经验 ID');
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
+ }
693
884
  const revisionRaw = args.expectedRevision;
694
885
  if (typeof revisionRaw !== 'number' || !Number.isInteger(revisionRaw) || revisionRaw < 1) {
695
886
  throw new Error('expectedRevision 必须是 >= 1 的整数。');
@@ -705,10 +896,19 @@ export function buildDreamTools(config) {
705
896
  error.details = { currentRevision: before.revision };
706
897
  throw error;
707
898
  }
708
- const conflictWith = stringArray(args, 'conflictWith');
899
+ const affectedIdsArg = stringArray(args, 'affectedIds');
900
+ const conflictWith = affectedIdsArg.length > 0 ? affectedIdsArg : stringArray(args, 'conflictWith');
709
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
+ : [];
710
909
  const idempotencyKey = 'dream_review:' + sha256Short(JSON.stringify({
711
- action, lessonId, expectedRevision, conflictWith, note: note ?? null,
910
+ action, lessonId, expectedRevision, conflictWith, resolution: resolutionArg ?? null, note: maskedNote ?? null,
911
+ evidence: evidenceInputs,
712
912
  }));
713
913
  // 模型调用永远记为 model;入参里的 actor 一律忽略(用户采纳只能来自宿主授权的真实用户指令)。
714
914
  const actor = 'model';
@@ -720,50 +920,154 @@ export function buildDreamTools(config) {
720
920
  case 'reject':
721
921
  lesson = store.reviewLesson(lessonId, 'rejected', expectedRevision, idempotencyKey, actor);
722
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;
723
928
  case 'mark-stale':
724
929
  lesson = store.applyTransition(lessonId, 'stale', expectedRevision, idempotencyKey);
725
930
  break;
931
+ case 'attach-evidence':
932
+ lesson = store.attachEvidence(lessonId, evidenceInputs, expectedRevision, idempotencyKey);
933
+ break;
726
934
  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) {
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) {
733
943
  if (!conflictIds.includes(id))
734
944
  conflictIds.push(id);
735
945
  }
736
- lesson = store.updateLesson(lessonId, { state: 'disputed', conflictIds }, expectedRevision, idempotencyKey);
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 });
737
957
  }
958
+ lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
738
959
  break;
739
960
  }
740
961
  case 'resolve-conflict': {
741
- if (conflictWith.length === 0) {
742
- lesson = store.applyTransition(lessonId, 'usable', expectedRevision, idempotencyKey);
962
+ const resolution = optionalString(args, 'resolution');
963
+ if (resolution !== 'prefer' && resolution !== 'drop' && resolution !== 'merge') {
964
+ throw new KnowledgeError('invalid', 'resolution 必须是 prefer / drop / merge。');
743
965
  }
744
- else {
745
- const conflictIds = before.conflictIds.filter((id) => !conflictWith.includes(id));
746
- lesson = store.updateLesson(lessonId, { state: 'usable', conflictIds }, expectedRevision, idempotencyKey);
966
+ if (maskedNote === undefined) {
967
+ throw new KnowledgeError('invalid', 'resolve-conflict 必须带 note(解析依据),以便事件可回溯。');
747
968
  }
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);
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
+ }
760
984
  }
761
- if (evidenceIds.length === before.evidenceIds.length) {
762
- lesson = before;
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
+ }
763
1016
  }
764
1017
  else {
765
- lesson = store.updateLesson(lessonId, { evidenceIds }, expectedRevision, idempotencyKey);
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 });
766
1069
  }
1070
+ lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
767
1071
  break;
768
1072
  }
769
1073
  default: