@stardustlc/dsh-dream 0.5.0 → 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 不得单独使用。' },
@@ -456,7 +649,7 @@ export function buildDreamTools(config) {
456
649
  },
457
650
  description: '适用范围(可选)。',
458
651
  },
459
- evidence: { type: 'array', required: true, items: evidenceItemsSpec(), description: '证据数组,必填;可为空数组(此时只能保存为 candidate)。' },
652
+ evidence: { type: 'array', items: evidenceItemsSpec(), description: '证据数组(可选,缺省为空数组;无证据时只能保存为 candidate)。' },
460
653
  }),
461
654
  output: {
462
655
  schema: baseSchema,
@@ -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,15 +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
- const { lesson, created } = store.createLesson(lessonInput, idempotencyKey);
719
+ const { lesson, created } = store.createLesson(lessonInput, idempotencyKey, { requireReview: true });
525
720
  const byId = new Map(store.listEvidence().map((item) => [item.id, item]));
526
721
  const evidence = lesson.evidenceIds
527
722
  .map((id) => byId.get(id))
528
723
  .filter((item) => item !== undefined)
529
- .map((item) => ({ id: item.id, verification: item.verification }));
724
+ .map((item) => ({ id: item.id, verification: item.verification, ...(item.verificationReason ? { verificationReason: item.verificationReason } : {}) }));
530
725
  const notes = [];
531
726
  if (evidenceInputs.length === 0) {
532
727
  notes.push('无证据:仅保存为 candidate,不会作为已证实结论注入任务建议。');
@@ -536,6 +731,10 @@ export function buildDreamTools(config) {
536
731
  const claimedCount = evidence.filter((item) => item.verification === 'claimed').length;
537
732
  notes.push('证据:' + readCount + ' 条已读(计入独立支持),' + claimedCount + ' 条仅声明(不计入独立支持)。');
538
733
  }
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('来源读取不等于结论成立或用户采纳;新经验仍需审阅。');
539
738
  if (!created)
540
739
  notes.push('已存在同指纹经验(' + lesson.id + '):复用既有记录并合并新证据,未新增经验。');
541
740
  if (!isGlobal && projectId === undefined && workspaceRoot === undefined) {
@@ -559,14 +758,17 @@ export function buildDreamTools(config) {
559
758
  };
560
759
  const dreamContext = {
561
760
  name: 'dream_context',
562
- description: '取梦:按当前任务返回少量真正适用的经验(只读,默认最多 5 条 / 3000 字符)。只返回 global 或与 projectId/workspaceRoot 精确匹配的经验;未知项目只返回 global,绝不跨项目扫描。rejected/stale/disputed 默认排除并在 skipped 说明原因;条件与例外完整返回,放不下整条跳过;没有相关经验时返回空数组。',
761
+ description: '取梦:按当前任务返回少量真正适用的经验(只读,默认最多 5 条 / 3000 字符)。只返回 global 或与 projectId/workspaceRoot 精确匹配的经验;未知项目只返回 global,绝不跨项目扫描。rejected/stale/disputed 默认排除;候选默认不注入(candidate-hold),无独立证据且非 usable 不注入(no-evidence),版本条件与包名不符不注入(version-mismatch),skipped 均给原因。未审阅候选不得当作已证实结论;只有明确审阅候选时才传 includeCandidates=true。when/exceptions 完整保留,元数据可按预算截断,预算放不下即停。',
563
762
  parameters: compileParameters({
564
763
  query: { type: 'string', required: true, description: '当前任务描述或检索关键词。' },
565
764
  projectId: { type: 'string', description: '当前项目 ID(可选;未知时只返回 global)。' },
566
765
  workspaceRoot: { type: 'string', description: '当前工作区根目录(可选)。' },
567
766
  limit: { type: 'integer', description: '条数上限 1-20(默认 5)。' },
568
767
  maxChars: { type: 'integer', description: '字符预算 1-20000(默认 3000)。' },
569
- packageVersion: { type: 'string', description: '当前相关依赖版本(可选)。' },
768
+ packageVersion: { type: 'string', description: '当前相关依赖版本(可选;需配合包名被识别才参与版本判定)。' },
769
+ packageName: { type: 'string', description: '当前相关依赖包名(可选;与 query 文本一起识别 applicability.package)。' },
770
+ platform: { type: 'string', description: '任务目标平台(windows / linux / macos 等);缺省从任务文本识别,否则使用宿主系统。平台不符的经验不注入。' },
771
+ includeCandidates: { type: 'boolean', description: '是否把候选经验纳入任务建议(默认 false;仅明确审阅候选时传 true,无证据候选仍不注入)。' },
570
772
  }),
571
773
  output: {
572
774
  schema: baseSchema,
@@ -575,12 +777,26 @@ export function buildDreamTools(config) {
575
777
  const items = Array.isArray(rec.items) ? rec.items : [];
576
778
  const budget = asRecord(rec.budget);
577
779
  if (items.length === 0) {
578
- return [{ type: 'text', text: '没有与当前任务相关的经验(范围:' + rec.scopeLabel + ')。' }];
780
+ const skipped = Array.isArray(rec.skipped) ? rec.skipped : [];
781
+ const countReason = (reason) => skipped.filter((entry) => asRecord(entry).reason === reason).length;
782
+ const hints = [];
783
+ const held = countReason('candidate-hold');
784
+ const noEvidence = countReason('no-evidence');
785
+ const mismatched = countReason('version-mismatch');
786
+ if (held > 0)
787
+ hints.push(held + ' 条候选待审阅(要审阅候选时显式传 includeCandidates=true)');
788
+ if (noEvidence > 0)
789
+ hints.push(noEvidence + ' 条无独立证据不注入');
790
+ if (mismatched > 0)
791
+ hints.push(mismatched + ' 条版本条件不匹配不注入');
792
+ const suffix = hints.length > 0 ? ';' + hints.join(';') : '';
793
+ return [{ type: 'text', text: '没有可注入的适用经验(范围:' + rec.scopeLabel + suffix + ')。' }];
579
794
  }
580
795
  const lines = ['适用经验 ' + items.length + ' 条(范围:' + rec.scopeLabel + ',字符 ' + budget.usedChars + '/' + budget.maxChars + '):'];
581
796
  for (const item of items) {
582
797
  const lesson = asRecord(item);
583
- lines.push('- [' + lesson.state + '|' + lesson.scopeLabel + '] ' + lesson.title);
798
+ const truncation = lesson.truncated === true ? '|元数据截断' : '';
799
+ lines.push('- [' + lesson.state + '|' + lesson.scopeLabel + '|rev ' + lesson.revision + truncation + '] ' + lesson.title);
584
800
  lines.push(' 何时:' + lesson.when);
585
801
  lines.push(' 行动:' + lesson.action);
586
802
  const exceptions = Array.isArray(lesson.exceptions) ? lesson.exceptions : [];
@@ -598,17 +814,23 @@ export function buildDreamTools(config) {
598
814
  const projectId = optionalString(args, 'projectId');
599
815
  const workspaceRoot = optionalString(args, 'workspaceRoot');
600
816
  const packageVersion = optionalString(args, 'packageVersion');
817
+ const packageName = optionalString(args, 'packageName');
601
818
  const { limit, maxChars } = resolveRetrievalBudget({
602
819
  limit: typeof args.limit === 'number' ? args.limit : undefined,
603
820
  maxChars: typeof args.maxChars === 'number' ? args.maxChars : undefined,
604
821
  });
605
822
  const retrievalQuery = {
606
823
  query,
824
+ platform: taskPlatform(query, optionalString(args, 'platform'), process.platform),
607
825
  limit,
608
826
  maxChars,
827
+ // R1:默认不注入候选;claimed-only / 无证据且非 usable 的条目也不注入(R1 附带规则)。
828
+ includeCandidates: args.includeCandidates === true,
829
+ includeNoEvidence: false,
609
830
  ...(projectId !== undefined ? { projectId } : {}),
610
831
  ...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
611
832
  ...(packageVersion !== undefined ? { packageVersion } : {}),
833
+ ...(packageName !== undefined ? { packageName } : {}),
612
834
  };
613
835
  const scopeLabel = queryScopeLabel(retrievalQuery);
614
836
  const dir = knowledgeDirOf(cfg);
@@ -622,17 +844,20 @@ export function buildDreamTools(config) {
622
844
  },
623
845
  timeoutMs: 15000,
624
846
  };
625
- 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'];
626
848
  const dreamReview = {
627
849
  name: 'dream_review',
628
850
  description: '审梦:采纳/驳回/标记冲突或过期/追加验证证据,必须带 expectedRevision(revision 不符会拒绝且不写入)。模型调用 actor 一律记为 model,不得冒充用户采纳;attach-evidence 只追加证据,不自动改变 state。',
629
851
  parameters: compileParameters({
630
852
  action: { type: 'string', required: true, enum: [...REVIEW_ACTIONS], description: '审阅动作。' },
631
- 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)。' },
632
856
  expectedRevision: { type: 'integer', required: true, description: '期望 revision(乐观锁,必须与当前一致)。' },
633
857
  evidence: { type: 'array', items: evidenceItemsSpec(), description: 'attach-evidence 时使用的证据数组。' },
634
- conflictWith: { type: 'array', items: { type: 'string' }, description: 'mark-disputed 记录冲突对象;resolve-conflict 时解除这些冲突 ID。' },
635
- 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 可选。' },
636
861
  }),
637
862
  output: {
638
863
  schema: baseSchema,
@@ -644,13 +869,18 @@ export function buildDreamTools(config) {
644
869
  }];
645
870
  },
646
871
  },
647
- async execute(rawArgs) {
872
+ async execute(rawArgs, exec) {
648
873
  const args = asRecord(rawArgs);
649
874
  const action = optionalString(args, 'action');
650
875
  if (action === undefined || !REVIEW_ACTIONS.includes(action)) {
651
876
  throw new Error('action 必须是 ' + REVIEW_ACTIONS.join(' / ') + '。');
652
877
  }
653
- 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
+ }
654
884
  const revisionRaw = args.expectedRevision;
655
885
  if (typeof revisionRaw !== 'number' || !Number.isInteger(revisionRaw) || revisionRaw < 1) {
656
886
  throw new Error('expectedRevision 必须是 >= 1 的整数。');
@@ -661,12 +891,24 @@ export function buildDreamTools(config) {
661
891
  if (before === undefined)
662
892
  throw new KnowledgeError('invalid', '经验不存在:' + lessonId);
663
893
  if (before.revision !== expectedRevision) {
664
- throw new KnowledgeError('revision', '经验 ' + lessonId + ' 期望 revision ' + expectedRevision + ',实际 ' + before.revision);
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;
665
898
  }
666
- const conflictWith = stringArray(args, 'conflictWith');
899
+ const affectedIdsArg = stringArray(args, 'affectedIds');
900
+ const conflictWith = affectedIdsArg.length > 0 ? affectedIdsArg : stringArray(args, 'conflictWith');
667
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
+ : [];
668
909
  const idempotencyKey = 'dream_review:' + sha256Short(JSON.stringify({
669
- action, lessonId, expectedRevision, conflictWith, note: note ?? null,
910
+ action, lessonId, expectedRevision, conflictWith, resolution: resolutionArg ?? null, note: maskedNote ?? null,
911
+ evidence: evidenceInputs,
670
912
  }));
671
913
  // 模型调用永远记为 model;入参里的 actor 一律忽略(用户采纳只能来自宿主授权的真实用户指令)。
672
914
  const actor = 'model';
@@ -678,50 +920,154 @@ export function buildDreamTools(config) {
678
920
  case 'reject':
679
921
  lesson = store.reviewLesson(lessonId, 'rejected', expectedRevision, idempotencyKey, actor);
680
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;
681
928
  case 'mark-stale':
682
929
  lesson = store.applyTransition(lessonId, 'stale', expectedRevision, idempotencyKey);
683
930
  break;
931
+ case 'attach-evidence':
932
+ lesson = store.attachEvidence(lessonId, evidenceInputs, expectedRevision, idempotencyKey);
933
+ break;
684
934
  case 'mark-disputed': {
685
- if (conflictWith.length === 0) {
686
- lesson = store.applyTransition(lessonId, 'disputed', expectedRevision, idempotencyKey);
687
- }
688
- else {
689
- const conflictIds = [...before.conflictIds];
690
- 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) {
691
943
  if (!conflictIds.includes(id))
692
944
  conflictIds.push(id);
693
945
  }
694
- 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 });
695
957
  }
958
+ lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
696
959
  break;
697
960
  }
698
961
  case 'resolve-conflict': {
699
- if (conflictWith.length === 0) {
700
- 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。');
701
965
  }
702
- else {
703
- const conflictIds = before.conflictIds.filter((id) => !conflictWith.includes(id));
704
- lesson = store.updateLesson(lessonId, { state: 'usable', conflictIds }, expectedRevision, idempotencyKey);
966
+ if (maskedNote === undefined) {
967
+ throw new KnowledgeError('invalid', 'resolve-conflict 必须带 note(解析依据),以便事件可回溯。');
705
968
  }
706
- break;
707
- }
708
- case 'attach-evidence': {
709
- const inputs = parseEvidenceArgs(args, 'evidence', before.scope.projectId);
710
- if (inputs.length === 0)
711
- throw new Error('attach-evidence 需要至少一条 evidence。');
712
- const masked = inputs.map((entry) => ({ ...entry, summary: maskIfNeeded(cfg, entry.summary) }));
713
- const appended = masked.map((entry) => store.appendEvidence(entry));
714
- const evidenceIds = [...before.evidenceIds];
715
- for (const item of appended) {
716
- if (!evidenceIds.includes(item.evidence.id))
717
- 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
+ }
718
984
  }
719
- if (evidenceIds.length === before.evidenceIds.length) {
720
- 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
+ }
721
1016
  }
722
1017
  else {
723
- 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 });
724
1069
  }
1070
+ lesson = updates.length === 0 ? before : store.updateLessonsBatch(updates, idempotencyKey).find(item => item.id === lessonId) ?? before;
725
1071
  break;
726
1072
  }
727
1073
  default: