dsh-plugin-tool-management 0.13.0 → 0.15.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +560 -474
  2. package/README.md +91 -123
  3. package/README_EN.md +99 -126
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +391 -337
  21. package/lib/audit-log.js +157 -0
  22. package/lib/client.js +4204 -2454
  23. package/lib/compat/patch-dialect.js +3 -1
  24. package/lib/compat/preset-reach.js +43 -9
  25. package/lib/compat/probe.js +149 -7
  26. package/lib/context-inject.js +91 -35
  27. package/lib/index.js +479 -114
  28. package/lib/mcp/loader-token.js +53 -28
  29. package/lib/mcp/manager.js +149 -21
  30. package/lib/mcp/secret-guard.js +21 -0
  31. package/lib/mcp/state-section.js +13 -11
  32. package/lib/memories/archive-engine.js +70 -2
  33. package/lib/memories/archive.js +8 -1
  34. package/lib/memories/constants.js +72 -8
  35. package/lib/memories/index-io.js +7 -1
  36. package/lib/memories/projection.js +67 -31
  37. package/lib/memories/service.js +17 -2
  38. package/lib/memories/snapshot.js +113 -22
  39. package/lib/op-registry.js +26 -6
  40. package/lib/ops/candidates.js +11 -0
  41. package/lib/ops/compat.js +113 -31
  42. package/lib/ops/scene-records.js +34 -0
  43. package/lib/ops/scene-sync.js +12 -1
  44. package/lib/ops/sessions.js +24 -9
  45. package/lib/ops/snapshot.js +596 -0
  46. package/lib/prompts/service.js +25 -2
  47. package/lib/scenes/candidates.js +66 -5
  48. package/lib/sessions/bridge.js +5 -0
  49. package/lib/sessions/workspace.js +17 -5
  50. package/lib/skills/core.js +105 -3
  51. package/lib/skills/service.js +34 -2
  52. package/lib/subagents/service.js +37 -4
  53. package/lib/subagents/tools.js +11 -4
  54. package/lib/tools/deps.js +16 -1
  55. package/lib/tools/mcp.js +291 -50
  56. package/lib/tools/memory.js +131 -80
  57. package/lib/tools/prompt.js +27 -13
  58. package/lib/tools/scene.js +351 -0
  59. package/lib/tools/skills.js +93 -17
  60. package/lib/tools/subagent.js +56 -45
  61. package/lib/tools/table.js +208 -15
  62. package/package.json +24 -20
package/lib/index.js CHANGED
@@ -22,6 +22,8 @@ import { isInsideRootResolved } from './paths.js';
22
22
  import { DEFAULT_PROFILE_NAME, PROFILE_CANDIDATES } from './host-names.js';
23
23
  import { createMemoriesService } from './memories/service.js';
24
24
  import { createArchiveEngine } from './memories/archive-engine.js';
25
+ import { createSnapshotOps } from './ops/snapshot.js';
26
+ import { AUDIT_TARGET_CONFIG, clearAudit, readAudit, recordAudit, recordOpAudit } from './audit-log.js';
25
27
  import { readIndexSync } from './memories/index-io.js';
26
28
  import { runStateDoctor } from './ops/state-doctor.js';
27
29
  import { createSubagentService, decideToolFilter, defaultPersonasDir } from './subagents/service.js';
@@ -29,18 +31,20 @@ import { createSubagentCatalog } from './subagents/catalog.js';
29
31
  import { createSkillCatalog } from './skills/catalog.js';
30
32
  import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
31
33
  import { isApprovalNever } from './approval-policy.js';
32
- import { buildToolTableReport, normalizeToolTableSettings, } from './tools/table.js';
34
+ import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migrateLegacyToolNames, normalizeToolTableSettings, FACTORY_TOOL_TABLE_PRESET, PRESET_MAX_COUNT, PRESET_NAME_MAX_LENGTH, toolTableSettingsFrom, } from './tools/table.js';
33
35
  import { normalizeSceneSettings } from './scene-settings.js';
34
36
  import { checkPatchWrite, takePatchGuardWarnings } from './compat/patch-dialect.js';
35
37
  import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
36
38
  import { reachNoticeForAgent } from './compat/preset-reach.js';
37
39
  import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
38
40
  import { createMcpManager } from './mcp/manager.js';
41
+ // 确认卡回显"新 URL"时要打码(查询串里的凭据不该明文进卡片),口径与列表视图同一份实现。
42
+ import { maskUrlQuery } from './mcp/secret-guard.js';
39
43
  import { createCandidates } from './scenes/candidates.js';
40
44
  // 「已知工具」归一化与「当前可用工具数」原本定义在本文件(导出仅为测试接缝),
41
45
  // 2026-09-19 随 MCP 域搬进 ./mcp/manager.ts。这里保持原路径可导出,契约不变。
42
46
  export { normalizeKnownTools, countEnabledTools } from './mcp/manager.js';
43
- import { applyLoaderToken, applyLoaderTokenDisabled, readLoaderToken } from './mcp/loader-token.js';
47
+ import { applyLoaderToken, applyLoaderTokenDisabled, buildLoaderOverrideEntry, readLoaderToken } from './mcp/loader-token.js';
44
48
  import { hubBackupDir, hubPath, migrateHubLayoutSync, relocateEntries } from './hub.js';
45
49
  // 分域的 HTTP ops(2026-09-19 从本文件 handlers 表抽出,依赖显式传参)。
46
50
  import { buildCompatOps } from './ops/compat.js';
@@ -56,6 +60,7 @@ import { buildMcpTools } from './tools/mcp.js';
56
60
  import { buildSkillTools } from './tools/skills.js';
57
61
  import { buildPromptTools } from './tools/prompt.js';
58
62
  import { buildMemoryTools } from './tools/memory.js';
63
+ import { buildSceneTools } from './tools/scene.js';
59
64
  import { buildSubagentTools } from './tools/subagent.js';
60
65
  import { createScenePromptSync } from './scene-prompt-sync.js';
61
66
  import { extractText } from './imports/parsers.js';
@@ -160,6 +165,8 @@ export default {
160
165
  kind: 'read',
161
166
  fallback: 'inform-only',
162
167
  detail: '技能 provider 装配失败(' + message(e) + '):技能启用/停用与 agent 技能目录这一轮不生效。',
168
+ detailKey: 'skills-provider',
169
+ params: { reason: message(e) },
163
170
  });
164
171
  }
165
172
  // ---------- 提示词预设库(hub/prompts/)+ 切换 ----------
@@ -210,7 +217,7 @@ export default {
210
217
  memoriesRoot: String(config?.memoriesRoot || ''),
211
218
  stateDir: String(config?.memoriesStateDir || ''),
212
219
  scenesDir: String(config?.memoriesScenesDir || ''),
213
- // 场景记忆段预算(字节),默认 65536;仅用于测试与特殊部署调优。
220
+ // 场景记忆段预算(字节),默认 131072(= DEFAULT_MAX_BYTES,128 KiB);仅用于测试与特殊部署调优。
214
221
  ...(Number.isFinite(Number(config?.rulesMaxBytes))
215
222
  ? { maxBytes: Number(config.rulesMaxBytes) }
216
223
  : {}),
@@ -293,7 +300,41 @@ export default {
293
300
  // 用生效集去校验会把合法勾选判成 stale 丢掉。
294
301
  return (await skillScan()).all;
295
302
  }
303
+ // 整体替换关停名单的那一步抽成具名函数:引擎的 apply 依赖与「修改场景」表单的
304
+ // 即时生效(scene-sync 包装)共用同一条写路径,不能各抄一份。
305
+ const applyToolTableHiddenNow = async (hidden) => {
306
+ // 与 `tool-table` op 写分支同一条路径、同一顺序:落盘 → 重排可见性 → 两个目录重算。
307
+ // 绕开这套顺序就会留下「名单已经改了、工具下一轮还发给模型」这种最难查的状态。
308
+ const current = await readToolTableSettings();
309
+ const next = {
310
+ hidden: normalizeToolTableSettings({ hidden }).hidden.filter((name) => toolTableSizes.has(name)),
311
+ presets: current.presets,
312
+ };
313
+ const saved = await writeToolTableSettings(next);
314
+ if (!saved.ok)
315
+ throw new Error(saved.error);
316
+ mcp.scheduleToolRestrictions();
317
+ await Promise.all([
318
+ skillCatalog.refresh().catch(() => { }),
319
+ subagentCatalog.refresh().catch(() => { }),
320
+ ]);
321
+ };
296
322
  const archiveService = createArchiveEngine({
323
+ // 引擎每应用/还原一次整套档案记一条流水(source='engine',见 ./audit-log.ts)。
324
+ // 记在边界这一层而不是往五个 apply* 里散着写:一条流水说的是「引擎动了整个环境」,
325
+ // 与同一次进入里面板/模型那条记录天然按秒分组显示。
326
+ audit: (op, target) => recordAudit({ ts: Date.now(), op, target: target || AUDIT_TARGET_CONFIG, source: 'engine' }),
327
+ // ── 模型工具表(0.15.0 C1):场景可以绑一份方案,进场景整体换成那一份 ──
328
+ currentToolTableHidden: async () => [...(await readToolTableSettings()).hidden],
329
+ toolTablePresetHidden: async (name) => {
330
+ const s = await readToolTableSettings();
331
+ // 用户存的方案**优先**:万一有人把方案起名叫 'factory-default',他要的是自己那份。
332
+ const hit = s.presets.filter((p) => p.name === name)[0];
333
+ if (hit)
334
+ return [...hit.hidden];
335
+ return name === FACTORY_TOOL_TABLE_PRESET ? [...DEFAULT_HIDDEN_TOOLS] : null;
336
+ },
337
+ applyToolTableHidden: applyToolTableHiddenNow,
297
338
  loadSlice: async () => ({ ...(await memoriesService.readArchiveSlice()) }),
298
339
  saveSlice: (slice) => memoriesService.patchIndex(slice),
299
340
  configuredServers: async () => {
@@ -510,8 +551,10 @@ export default {
510
551
  .map((s) => slice.archives[s.name]?.subagents ?? []);
511
552
  };
512
553
  // ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
513
- // 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,500 tok 每轮都在。关掉某几个,
514
- // 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。
554
+ // 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
555
+ // 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十五条**
556
+ // (20 条里实发只剩 5 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
557
+ // 选择时铺(见下面的 `toolTableSettingsFrom`)。
515
558
  //
516
559
  // 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
517
560
  // 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
@@ -536,7 +579,20 @@ export default {
536
579
  return toolTableCache.value;
537
580
  await ensurePaths();
538
581
  const raw = await readJsonFile(hubPath(TOOL_TABLE_FILE));
539
- const value = normalizeToolTableSettings(raw);
582
+ // 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
583
+ // 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
584
+ // 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
585
+ // 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十五条不发,
586
+ // 实发 5 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
587
+ // 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
588
+ const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
589
+ const value = migrated.settings;
590
+ if (migrated.changed) {
591
+ try {
592
+ await writeJsonFile(hubPath(TOOL_TABLE_FILE), value);
593
+ }
594
+ catch { /* 回写失败:本次仍按迁移后的值生效 */ }
595
+ }
540
596
  const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
541
597
  toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
542
598
  // 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
@@ -552,8 +608,9 @@ export default {
552
608
  }
553
609
  /**
554
610
  * 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
555
- * 默认值并异步预热 —— 默认是"什么都没关",预热前的这一瞬与用户的选择可能不一致,但
556
- * 方向是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让工具凭空消失)。
611
+ * **空集**并异步预热 —— 空集不是出厂默认(出厂关着五条),是刻意的:预热前的这一瞬与
612
+ * 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
613
+ * 工具凭空消失)。
557
614
  */
558
615
  function toolTableHidden() {
559
616
  if (toolTableCache === null) {
@@ -564,27 +621,74 @@ export default {
564
621
  }
565
622
  const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
566
623
  const toolTableReport = (hidden = [...toolTableHidden()]) => buildToolTableReport(toolTableRows(), hidden);
567
- async function toolTableOp(args) {
568
- const current = await readToolTableSettings();
569
- if (!args || args.set !== true)
570
- return { ok: true, hidden: current.hidden, report: toolTableReport(current.hidden) };
571
- const next = normalizeToolTableSettings({ hidden: args.hidden });
572
- // 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
573
- // ("names unknown global tool")。注册失败的工具本来也不在表里。
574
- const hidden = next.hidden.filter((name) => toolTableSizes.has(name));
624
+ /** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
625
+ async function writeToolTableSettings(next) {
575
626
  await ensurePaths();
576
627
  // 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
577
628
  // 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
578
- const saved = await withWriteLock(async () => {
629
+ return withWriteLock(async () => {
579
630
  try {
580
- await writeJsonFile(hubPath(TOOL_TABLE_FILE), { hidden });
631
+ await writeJsonFile(hubPath(TOOL_TABLE_FILE), next);
581
632
  }
582
633
  catch (e) {
583
634
  return { ok: false, error: '设置保存失败: ' + message(e) };
584
635
  }
585
- toolTableCache = { at: Date.now(), value: { hidden }, off: new Set(hidden) };
636
+ toolTableCache = { at: Date.now(), value: next, off: new Set(next.hidden) };
586
637
  return { ok: true };
587
638
  });
639
+ }
640
+ /**
641
+ * 回给界面的同一份形状:当前名单 + 已存方案 + 出厂默认名单 + 体积报告。
642
+ *
643
+ * `defaultHidden` 单独回一份而不是塞进 `presets`:那条**不存在文件里**(它是代码里的
644
+ * `DEFAULT_HIDDEN_TOOLS`),写进文件就会出现"用户删不掉的一条数据"。界面把它排在最前、
645
+ * 不给删除键,词典出它的名字。
646
+ */
647
+ const toolTablePayload = (value) => ({
648
+ ok: true, hidden: value.hidden, presets: value.presets,
649
+ defaultHidden: [...DEFAULT_HIDDEN_TOOLS], report: toolTableReport(value.hidden),
650
+ });
651
+ /** 方案名:折叠空白 + 截到上限。空名由调用方报错(这里不猜"用户想叫什么")。 */
652
+ const presetNameOf = (raw) => String(raw ?? '').replaceAll(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX_LENGTH);
653
+ async function toolTableOp(args) {
654
+ const current = await readToolTableSettings();
655
+ if (!args || args.set !== true) {
656
+ // 存 / 删方案都不动 `hidden`,所以这两条路**不**重排可见性、也不刷目录(那是 `set`
657
+ // 那一条才有的三处联动)。失败原因回 `code` 不回中文串 —— 英文界面会原样露出中文。
658
+ if (args && args.presetSave !== undefined) {
659
+ const name = presetNameOf(args.presetSave);
660
+ if (name === '')
661
+ return { ok: false, code: 'nameEmpty' };
662
+ const added = addPreset(current, name, current.hidden);
663
+ // 同名不覆盖(用户裁定):撞名要用户换个名字,而不是悄悄改掉已有那份。
664
+ if (added.exists)
665
+ return { ok: false, code: 'nameTaken', name };
666
+ if (current.presets.length >= PRESET_MAX_COUNT) {
667
+ return { ok: false, code: 'limit', limit: PRESET_MAX_COUNT };
668
+ }
669
+ const saved = await writeToolTableSettings(added.settings);
670
+ if (!saved.ok)
671
+ return saved;
672
+ return toolTablePayload(added.settings);
673
+ }
674
+ if (args && args.presetDelete !== undefined) {
675
+ const name = presetNameOf(args.presetDelete);
676
+ const dropped = dropPreset(current, name);
677
+ if (!dropped.changed)
678
+ return { ok: false, code: 'notFound', name };
679
+ const saved = await writeToolTableSettings(dropped.settings);
680
+ if (!saved.ok)
681
+ return saved;
682
+ return toolTablePayload(dropped.settings);
683
+ }
684
+ return toolTablePayload(current);
685
+ }
686
+ // 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
687
+ // ("names unknown global tool")。注册失败的工具本来也不在表里。
688
+ const hidden = normalizeToolTableSettings({ hidden: args.hidden }).hidden
689
+ .filter((name) => toolTableSizes.has(name));
690
+ const next = { hidden, presets: current.presets };
691
+ const saved = await writeToolTableSettings(next);
588
692
  if (!saved.ok)
589
693
  return saved;
590
694
  // 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
@@ -594,7 +698,7 @@ export default {
594
698
  skillCatalog.refresh().catch(() => { }),
595
699
  subagentCatalog.refresh().catch(() => { }),
596
700
  ]);
597
- return { ok: true, hidden, report: toolTableReport(hidden) };
701
+ return toolTablePayload(next);
598
702
  }
599
703
  // ---------- 「有官方等价物 ⇒ 我们让位」----------
600
704
  // 官方 `skill` 工具(`@deepseek-ai/dsh-tool-skill`)与我们的 `skill_manager_read` 是同一
@@ -628,7 +732,7 @@ export default {
628
732
  }
629
733
  return out;
630
734
  };
631
- // ---------- 注入通道(场景和记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
735
+ // ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
632
736
  // 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
633
737
  // 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
634
738
  // 任何预设都到得了,内容没变不重发。**每个域一条自己的消息**(来源 kind 各不同,
@@ -737,7 +841,7 @@ export default {
737
841
  return { ok: true, settings: next };
738
842
  });
739
843
  }
740
- // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的五域文本。
844
+ // 注入实况(只读诊断):注入器的 live() 读回"最近活跃会话"可见表面上的各域文本。
741
845
  // 挂在 apply 作用域:`injection-live` op 与注入器不在同一层(effect 内部)。
742
846
  let contextInjectorLive = null;
743
847
  // 采纳遥测的入口(同样是 apply 作用域的中转):工具注册在 effect 之外,
@@ -759,16 +863,21 @@ export default {
759
863
  // `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
760
864
  // 所以不会分叉。
761
865
  //
762
- // 记忆域(2026-09-17 用户裁定):**只在顶层注入**。记忆是"父会话的现场",不是子代理
763
- // 完成任务所需的事实 —— 而且它带着「一律照办,覆盖你的默认做法」这种强主张,塞进
764
- // 一次性子会话只会与角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与
765
- // 整份场景记忆)。子代理手里有 `memory_manager_list/read`,需要什么自己取;父代理
766
- // 上下文里也有记忆,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
866
+ // 场景与记忆(2026-09-17 用户裁定;2026-09-23 拆成两个域):**都只在顶层注入**。
867
+ // 记忆是"父会话的现场",不是子代理完成任务所需的事实 —— 塞进一次性子会话只会与
868
+ // 角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与整份记忆)。
869
+ // 场景段同理:它说的是"父会话现在处在哪个模式",而子代理根本没有"模式"可言。
870
+ // 子代理手里有 `memory_manager_list/read` 与 `scene_manager_save`,需要什么自己取;
871
+ // 父代理上下文里也有,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
767
872
  // 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
768
873
  // 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
769
874
  // 工具,不知道清单就只能瞎调)。
770
875
  domains: () => [
771
- { key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
876
+ // 场景(**框架**):启用的场景 + 场景说明(用户的约定)。排第一 —— 先让模型知道
877
+ // "现在在哪个场景、这个场景的约定是什么",再读下面的记忆条目。
878
+ { key: 'scene', name: 'tool-management:scene', label: '场景', form: 'snapshot', text: () => memoriesService.sceneCatalogText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
879
+ // 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
880
+ { key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
772
881
  { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
773
882
  { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
774
883
  {
@@ -805,7 +914,9 @@ export default {
805
914
  label: '上下文注入通道',
806
915
  kind: 'read',
807
916
  fallback: 'inform-only',
808
- detail: '注入通道装配失败(' + message(e) + '):五个注入域的内容这一轮不会被送进模型。',
917
+ detail: '注入通道装配失败(' + message(e) + '):全部注入域的内容这一轮不会被送进模型。',
918
+ detailKey: 'context-injection',
919
+ params: { reason: message(e) },
809
920
  });
810
921
  }
811
922
  void readInjectSettings().catch(() => { });
@@ -832,6 +943,7 @@ export default {
832
943
  kind: 'read',
833
944
  fallback: 'inform-only',
834
945
  detail: 'cordis 导出的 symbols.original 与 Symbol.for("cordis.original") 不是同一个符号:能力探测可能把代理当原始对象,身份判定会失真。',
946
+ detailKey: 'cordis-original-symbol',
835
947
  });
836
948
  }
837
949
  }
@@ -1119,23 +1231,87 @@ export default {
1119
1231
  if (cached)
1120
1232
  return cached;
1121
1233
  let home = null;
1234
+ let derivedProfileDir = null;
1235
+ let derivedProfileName = null;
1236
+ let docObserved;
1237
+ let docError;
1122
1238
  try {
1123
1239
  // 注意:这里的 home 来自宿主文档路径的切片,与 `skills/core.js` 的
1124
1240
  // `resolveDshHome()`(`$DSH_HOME` ‖ `~/.dsh`)是**两条独立来源**,本插件同时用着两者。
1125
1241
  // 不合并是有意的:合并会改变所有 profile 补丁的落点,属于行为变更,留给结构性重构那一档。
1126
1242
  const doc = await settings.prepareDocument();
1127
1243
  if (typeof doc === 'string' && doc) {
1244
+ docObserved = doc;
1128
1245
  const i = Math.max(doc.lastIndexOf('\\'), doc.lastIndexOf('/'));
1129
- home = i > 0 ? doc.slice(0, i) : doc;
1246
+ const docDir = i > 0 ? doc.slice(0, i) : doc;
1247
+ // 0.1.7 起 `prepareDocument()` 返回的是 **profile 补丁**(`configEditor.documentPath`,
1248
+ // 形如 `<home>\profiles\web\cordis.patch.yml`);0.1.5 返回的才是主目录下的设置文档。
1249
+ // 直接切片会把 profile 目录当成主目录 —— globalPatch 随之指到 profile 补丁,
1250
+ // 全局补丁里那些 MCP 行就全部读不到了(0.15.0 实测)。所以先认这个形状:
1251
+ // 目录以 `profiles\<name>` 结尾 ⇒ 它自己就是 profileDir,主目录上溯两级。
1252
+ const profileShaped = /^(.+)[\\/]profiles[\\/]([^\\/]+)$/.exec(docDir);
1253
+ if (profileShaped) {
1254
+ home = profileShaped[1];
1255
+ derivedProfileDir = docDir;
1256
+ derivedProfileName = profileShaped[2];
1257
+ }
1258
+ else {
1259
+ home = docDir;
1260
+ }
1130
1261
  }
1131
1262
  }
1132
- catch (e) { /* ignore */ }
1133
- if (!home)
1263
+ catch (e) {
1264
+ docError = String(e?.message ?? e);
1265
+ }
1266
+ if (!home) {
1267
+ // 推导失败从「静默抛错」升级为「具名上报 + 抛错」:这条路径正是 0.1.7 升级事故的
1268
+ // 源头(prepareDocument 返回语义一变这里就指错目录,MCP 页与补丁读写全部失明),
1269
+ // 下次契约再变,兼容页必须第一眼看到推导挂了,而不是等某个页面空白再倒查。
1270
+ noteRuntime({
1271
+ id: 'paths.home-derivation',
1272
+ label: '主目录推导',
1273
+ kind: 'read',
1274
+ fallback: 'refuse-operation',
1275
+ detail: docError !== undefined
1276
+ ? `settings.prepareDocument() 抛错:${docError}。主目录推导失败,MCP 页与补丁读写不可用。`
1277
+ : 'settings.prepareDocument() 未返回可用路径(宿主路径契约可能又变了)。主目录推导失败,MCP 页与补丁读写不可用。',
1278
+ });
1134
1279
  throw new Error('无法确定 DSH 主目录(settings.prepareDocument 未返回路径)');
1280
+ }
1135
1281
  const sep = home.indexOf('\\') >= 0 ? '\\' : '/';
1136
- let profileDir = null;
1137
- let profileName = DEFAULT_PROFILE_NAME;
1138
- for (const name of PROFILE_CANDIDATES) {
1282
+ // prepareDocument 给出的就是**现存**的 profile 补丁路径(官方注释如此),所以
1283
+ // 推导出的 profileDir 直接可信;只有它没给出 profile 形状时才走下面的候选探测。
1284
+ const profileShapedPatchExists = derivedProfileDir !== null
1285
+ && (await exists(derivedProfileDir + sep + 'cordis.patch.yml'));
1286
+ // —— 推导自检(0.15.0):健康时不留 note(运行时上报一律渲染成兼容页的问题行,
1287
+ // 常驻健康行会违反「琥珀只留给故障」的口径),只在推导结果反常时记,恢复时收掉。
1288
+ // 两条不变量照着 0.1.7 事故的形状挑:那次推出的「主目录」其实存在(是 profile 目录),
1289
+ // 存在性检查抓不住,**profiles 子目录**检查才抓得住 —— 主目录下永远有 profiles\
1290
+ //(本插件自己的 profile 就住在里面)。官方再改一次语义,两道检查至少一道当场报警。
1291
+ const derivationNote = (detail) => {
1292
+ noteRuntime({
1293
+ id: 'paths.home-derivation',
1294
+ label: '主目录推导',
1295
+ kind: 'read',
1296
+ fallback: 'refuse-operation',
1297
+ detail,
1298
+ });
1299
+ };
1300
+ if (!(await exists(home))) {
1301
+ derivationNote(`推导出的主目录不存在:${home}(设置文档路径:${docObserved ?? '未知'})。推导结果不可信,MCP 页与补丁读写会落空。`);
1302
+ }
1303
+ else if (!(await exists(home + sep + 'profiles'))) {
1304
+ derivationNote(`推导出的主目录下没有 profiles 目录:${home}(设置文档路径:${docObserved ?? '未知'})。主目录很可能指错了 —— 直接切片一个 profile 补丁路径就是这个形状;MCP 页与补丁读写会读错文件。`);
1305
+ }
1306
+ else if (derivedProfileDir !== null && !profileShapedPatchExists) {
1307
+ derivationNote(`设置文档路径呈 profile 目录形状,但其中没有 cordis.patch.yml:${docObserved}。官方承诺返回「现存」的 profile 补丁路径,形状对不上说明契约又变了;已回退候选探测。`);
1308
+ }
1309
+ else {
1310
+ clearRuntimeNote('paths.home-derivation');
1311
+ }
1312
+ let profileDir = profileShapedPatchExists ? derivedProfileDir : null;
1313
+ let profileName = derivedProfileName ?? DEFAULT_PROFILE_NAME;
1314
+ for (const name of profileDir === null ? PROFILE_CANDIDATES : []) {
1139
1315
  if (await exists(home + sep + 'profiles' + sep + name + sep + 'cordis.patch.yml')) {
1140
1316
  profileDir = home + sep + 'profiles' + sep + name;
1141
1317
  profileName = name;
@@ -1166,6 +1342,10 @@ export default {
1166
1342
  profileName,
1167
1343
  projectPatch: profileDir + sep + 'cordis.patch.yml',
1168
1344
  globalPatch: home + sep + 'cordis.patch.yml',
1345
+ // 0.1.7 bundle 层:插件包内自己的 cordis.patch.yml(`dsh.bundle.patch`)定义 loader
1346
+ // 条目。它只是「覆盖目标存在吗」的凭证与读取参照,**绝不是令牌的写点** —— 那份文件
1347
+ // 是仓库镜像(sync 每次构建覆盖),把令牌写进去等于把明文密钥提交进仓库。
1348
+ bundlePatch: profileDir + sep + 'node_modules' + sep + 'dsh-plugin-tool-management' + sep + 'cordis.patch.yml',
1169
1349
  };
1170
1350
  return cached;
1171
1351
  }
@@ -1404,7 +1584,17 @@ export default {
1404
1584
  infoOf.set(abs, { token: read.token, disabled: read.disabled });
1405
1585
  }
1406
1586
  }
1407
- return { files, infoOf };
1587
+ // 0.1.7 bundle 挂载:loader 条目由插件包内的 cordis.patch.yml(bundle 层)提供,两份
1588
+ // 补丁里都没有可就地改写的行。bundleBase 只回答「覆盖目标存在吗」—— 写点是 profile
1589
+ // 补丁里的覆盖条目(见 tokenConfigure),bundle 文件本身永不落密钥。
1590
+ let bundleBase = false;
1591
+ try {
1592
+ bundleBase = readLoaderToken(await readPatch(p.bundlePatch)).found;
1593
+ }
1594
+ catch {
1595
+ bundleBase = false;
1596
+ }
1597
+ return { files, infoOf, bundleBase };
1408
1598
  }
1409
1599
  /**
1410
1600
  * 配置**文件里**写了令牌,但当前进程还没生效(或反过来)—— 也就是"改了配置还没重启"。
@@ -1494,13 +1684,39 @@ export default {
1494
1684
  if (value === CONFIG_TOKEN)
1495
1685
  return { ok: true, changed: false, note: '和当前令牌相同,未改动。', restartRequired: false };
1496
1686
  }
1497
- const { files } = await loaderTokenFiles();
1498
- if (!files.length) {
1499
- return { ok: false, error: '没找到本插件的 loader 行,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动设置 config.token(或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)。' };
1687
+ const p = await ensurePaths();
1688
+ const { files, bundleBase } = await loaderTokenFiles();
1689
+ if (!files.length && !(mode === 'set' && bundleBase)) {
1690
+ return { ok: false, error: '没找到本插件的 loader 行,也无法确认 bundle 层的挂载条目,无法自动改写配置:请在 profile 的 cordis.patch.yml 里手动加一条覆盖条目(- id: dsh-plugin-tool-management → config: → token: "…"),或改用环境变量 DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN。' };
1500
1691
  }
1501
1692
  if (files.length > 1) {
1502
1693
  return { ok: false, error: '本插件的 loader 行同时出现在两份补丁文件里(会导致 DSH 无法启动):请先清掉重复那条,再回来设置令牌。' };
1503
1694
  }
1695
+ if (!files.length) {
1696
+ // 0.1.7 bundle 挂载:两份补丁里都没有本插件的 loader 行(条目由 bundle 层提供,那份
1697
+ // 文件是仓库镜像、不能落密钥)—— 令牌写进 **profile 补丁的覆盖条目**:宿主在所有
1698
+ // bundle 层之后应用它,整体替换那条 loader 的 config(bundle 条目没有 config,替换
1699
+ // 零丢失);同 id 的 insert 条目才会重复挂载导致启动失败,覆盖条目是官方支持的改法。
1700
+ // `off` / `clear` / `on` 走不到这里:有 site 时就地改写,没 site 且有令牌 = 令牌来自
1701
+ // 环境变量,那两条路在前面已按各自口径返回。
1702
+ return withWriteLock(async () => {
1703
+ let content = '';
1704
+ try {
1705
+ content = await readPatch(p.projectPatch);
1706
+ }
1707
+ catch (e) {
1708
+ return { ok: false, error: '读取补丁失败: ' + message(e) };
1709
+ }
1710
+ const next = content === '' ? buildLoaderOverrideEntry(value) + '\n' : (content.endsWith('\n') ? content : content + '\n') + '\n' + buildLoaderOverrideEntry(value);
1711
+ try {
1712
+ await writePatch(p.projectPatch, next);
1713
+ }
1714
+ catch (e) {
1715
+ return { ok: false, error: '写入补丁失败: ' + message(e) };
1716
+ }
1717
+ return { ok: true, changed: true, mode, restartRequired: true, path: p.projectPatch };
1718
+ });
1719
+ }
1504
1720
  const abs = files[0];
1505
1721
  return withWriteLock(async () => {
1506
1722
  let content = '';
@@ -1682,8 +1898,31 @@ export default {
1682
1898
  memoriesService,
1683
1899
  toolKeyParts,
1684
1900
  });
1901
+ // 提示词预设域的 op 表。单独提出来是为了让**模型工具**能复用同一份判定:
1902
+ // `prompt_manager_list` 以前直调 `promptsService.list()`,于是它报的「生效中」是
1903
+ // **文件比对**口径(预设正文 == ~/.dsh/AGENTS.md),而界面报的是 `agentsmd-list` 的口径
1904
+ // (启用的场景绑了预设时,**只有那份**算生效中)—— 同一件事两个答案,场景驱动时工具就在
1905
+ // 说假话。工具改走 op 之后两边只有一份判定。
1906
+ const promptOps = buildPromptOps({
1907
+ promptsService,
1908
+ scenePromptSync,
1909
+ // 显式转发而不是整表传入:这两个 op 名字写死在这里,rules 域哪天改名会立刻红,
1910
+ // 不会静默变成 undefined 调用。
1911
+ rulesOps: {
1912
+ 'rules-list': (args) => memoriesService.ops['rules-list'](args),
1913
+ 'rules-rebind-prompt': (args) => memoriesService.ops['rules-rebind-prompt'](args),
1914
+ },
1915
+ applyPresetGuarded,
1916
+ withAgentsMdSync,
1917
+ promptRefReason,
1918
+ warn: (m) => { ctx.logger?.warn?.(m); },
1919
+ });
1685
1920
  const handlers = {
1686
1921
  'plugin-version': pluginVersion,
1922
+ // 「最近改动」流水的读侧(0.15.0 B2):只读 hub 里的 `audit.jsonl`,倒序、可按域过滤。
1923
+ 'audit-list': async (args) => ({ ok: true, entries: await readAudit(args || {}) }),
1924
+ // 清空流水。清完这一条 op 自己会被下面的包装层记进新流水("谁清过"也留痕)。
1925
+ 'audit-clear': async () => await clearAudit(),
1687
1926
  'skill-open': skillOpen,
1688
1927
  // MCP 域 16 个 op(实现在 ./mcp/manager.ts)
1689
1928
  ...mcp.ops,
@@ -1722,30 +1961,16 @@ export default {
1722
1961
  cleanPatchBackups,
1723
1962
  getContextInjectorLive: () => contextInjectorLive,
1724
1963
  // 功能总览(B6)的三层合成:令牌实况与兼容页「访问令牌」同源;场景锁定与写门禁同源;
1725
- // 停用工具数读 mcp 的 TTL 缓存(无 I/O)。
1964
+ // 停用工具的两个口径读 mcp 的 TTL 缓存(无 I/O)。
1726
1965
  readTokenState: async () => ({ active: TOKEN !== '', accepted: TOKEN !== '' && acceptedThisBoot() }),
1727
1966
  lockedSceneNames: () => lockedSceneNames(),
1728
- disabledToolCount: () => mcp.disabledToolCount(),
1967
+ disabledToolSummary: () => mcp.disabledToolSummary(),
1968
+ injectServiceCount: INJECT_SERVICES.length,
1729
1969
  message,
1730
1970
  compatLog,
1731
1971
  }),
1732
- // AGENTS.md / 提示词预设域(ops/prompts.ts 提供):agentsmd-list / read / create /
1733
- // update / apply / get-current / remove / import / trash-list / trash-restore /
1734
- // trash-delete。
1735
- ...buildPromptOps({
1736
- promptsService,
1737
- scenePromptSync,
1738
- // 显式转发而不是整表传入:这两个 op 名字写死在这里,rules 域哪天改名会立刻红,
1739
- // 不会静默变成 undefined 调用。
1740
- rulesOps: {
1741
- 'rules-list': (args) => memoriesService.ops['rules-list'](args),
1742
- 'rules-rebind-prompt': (args) => memoriesService.ops['rules-rebind-prompt'](args),
1743
- },
1744
- applyPresetGuarded,
1745
- withAgentsMdSync,
1746
- promptRefReason,
1747
- warn: (m) => { ctx.logger?.warn?.(m); },
1748
- }),
1972
+ // AGENTS.md / 提示词预设域(ops/prompts.ts 提供,见上方 promptOps)。
1973
+ ...promptOps,
1749
1974
  // 归档会话域(ops/history.ts 提供):history-list / history-sessions /
1750
1975
  // history-export-defaults / dir-list / history-archive / history-archive-batch /
1751
1976
  // history-unarchive / history-delete / history-unarchive-batch / history-delete-batch /
@@ -1774,6 +1999,20 @@ export default {
1774
1999
  // 必须排在 ...memoriesService.ops 之后 —— 这里是显式覆盖同名 op,不是新增。
1775
2000
  ...buildSceneSyncOps({
1776
2001
  withAgentsMdSync,
2002
+ onToolTablePresetChanged: async (scene, value) => {
2003
+ // 「修改场景」里改绑工具表方案且改的是**当前启用场景** → 立即把关停名单整体换成
2004
+ // 那一份(与「改档案 = 立刻生效」同口径)。解绑('')按下拉里的承诺 =「保持现状」,
2005
+ // 运行时不动;悬空绑定(方案已删)也不在这里硬失败,交给引擎进出场景时的 stale 处理。
2006
+ const slice = await memoriesService.readArchiveSlice();
2007
+ if (!slice.mode || slice.mode.scene !== scene || !value)
2008
+ return;
2009
+ const s = await readToolTableSettings();
2010
+ const hit = s.presets.filter((p) => p.name === value)[0];
2011
+ const hidden = hit ? [...hit.hidden] : (value === FACTORY_TOOL_TABLE_PRESET ? [...DEFAULT_HIDDEN_TOOLS] : null);
2012
+ if (!hidden)
2013
+ return;
2014
+ await applyToolTableHiddenNow(hidden);
2015
+ },
1777
2016
  rulesOps: {
1778
2017
  'rules-set-active': (args) => memoriesService.ops['rules-set-active'](args),
1779
2018
  'rules-update-scene': (args) => memoriesService.ops['rules-update-scene'](args),
@@ -1785,6 +2024,7 @@ export default {
1785
2024
  ...buildCandidateOps({
1786
2025
  presetToolCandidates: candidates.presetToolCandidates,
1787
2026
  modelCandidates: candidates.modelCandidates,
2027
+ modelReasoning: candidates.modelReasoning,
1788
2028
  getToolFailures: () => subagentToolFailures,
1789
2029
  mcpmListView: mcp.mcpmListView,
1790
2030
  toolStates: candidates.toolStates,
@@ -2176,6 +2416,22 @@ export default {
2176
2416
  // handlers 后处理(2026-09-19 抽到 ./request-gate.ts):场景锁定守卫、开关同步档案、
2177
2417
  // 读 op 的 anyLocked/activeScene 注解、子智能体失败清单 attach。
2178
2418
  // 顺序即语义(先装的在内层),整段搬运未改。
2419
+ //
2420
+ // 整机迁移快照(0.15.0 C2)在门禁装好**之前**并入 handlers:它自己不写任何域的数据,
2421
+ // 而是逐域调 handlers 表里那些 op —— 装好后取到的就是带门禁的那一份,场景锁定期间各域
2422
+ // 自己拒绝,迁移这边不必再判一套锁。
2423
+ const snapshotOps = createSnapshotOps({
2424
+ invokeOp: (name, args) => {
2425
+ const fn = handlers[name];
2426
+ return fn ? fn(args || {}) : Promise.resolve({ ok: false, error: '未知操作: ' + name });
2427
+ },
2428
+ hubPath,
2429
+ ensurePaths,
2430
+ pluginVersion: () => PKG_VERSION,
2431
+ promptsDir: () => promptsDir,
2432
+ message,
2433
+ });
2434
+ Object.assign(handlers, snapshotOps);
2179
2435
  installHandlerGuards({
2180
2436
  handlers,
2181
2437
  lockedSceneNames,
@@ -2253,8 +2509,28 @@ export default {
2253
2509
  */
2254
2510
  const hostDefineToolAny = hostDefineTool;
2255
2511
  const defineTool = ((options) => trackAdoption(hostDefineToolAny(options)));
2256
- // 五个域共用的那一份依赖(形状见 tools/deps.ts)。defineTool 带着遥测一起传下去 ——
2512
+ // 各域共用的那一份依赖(形状见 tools/deps.ts)。defineTool 带着遥测一起传下去 ——
2257
2513
  // 各域必须用它,直接 import 宿主的那个会丢掉采纳统计。
2514
+ /**
2515
+ * 「最近改动」流水的模型侧挂点(0.15.0 B2)。
2516
+ *
2517
+ * 为什么包在**交给工具的 op 表**上而不是散进每个 op:op 执行有多条入口路径(HTTP 路由、
2518
+ * 模型工具的 execute、场景引擎),而判断「谁干的」这件事只有调用点知道 —— 在 op 里自己猜
2519
+ * 调用方就是猜。这里给工具看到的表包一层,界面上 handlers 表那份原样不动,两条路各记各的。
2520
+ * 只多记一条流水:不改行为、不改返回值、也不吞异常(写类与成败的判据在 ./audit-log.ts)。
2521
+ */
2522
+ const auditFn = (opName, fn) => async (args) => {
2523
+ const result = await fn(args || {});
2524
+ recordOpAudit(opName, args || {}, 'model', result);
2525
+ return result;
2526
+ };
2527
+ const auditOps = (ops) => {
2528
+ const out = {};
2529
+ for (const [name, fn] of Object.entries(ops)) {
2530
+ out[name] = typeof fn === 'function' ? auditFn(name, fn) : fn;
2531
+ }
2532
+ return out;
2533
+ };
2258
2534
  const toolDeps = {
2259
2535
  defineTool,
2260
2536
  // 量体积只认**注册成功**的那些(注册失败的域工具本来就不在模型工具表里)——
@@ -2262,24 +2538,53 @@ export default {
2262
2538
  register: (def) => { recordToolSize(def); tools.register(def); },
2263
2539
  lockedSceneGuard,
2264
2540
  syncSwitchToScene,
2541
+ // 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
2542
+ toolVisible: (name) => !toolTableHidden().has(name),
2265
2543
  reachNoticeForAgent,
2266
2544
  presetRoster: candidates.presetRoster,
2267
2545
  injectNoticeOptions,
2268
2546
  message,
2269
2547
  };
2548
+ const mcpToolOps = auditOps(mcp.ops);
2270
2549
  buildMcpTools({
2271
2550
  ...toolDeps,
2272
2551
  mcpmListView: mcp.mcpmListView,
2273
- mcpmSetEnabled: mcp.ops['mcpm-set-enabled'],
2274
- mcpmRestart: mcp.ops['mcpm-restart'],
2275
- mcpmAdd: mcp.ops['mcpm-add'],
2552
+ mcpmTools: mcp.ops['mcpm-tools'],
2553
+ mcpmSetEnabled: mcpToolOps['mcpm-set-enabled'],
2554
+ mcpmRestart: mcpToolOps['mcpm-restart'],
2555
+ mcpmToolEnabled: mcpToolOps['mcpm-tool-enabled'],
2556
+ mcpmAdd: mcpToolOps['mcpm-add'],
2557
+ mcpmEdit: mcpToolOps['mcpm-edit'],
2558
+ mcpmNote: mcpToolOps['mcpm-note'],
2559
+ // **故意不接 `mcpm-reveal`**:`mcp_manager_save` 的改分支用 `mcpmListView()` 的
2560
+ // 打码视图填回省略字段,由 `mcpm-edit` 的 `resolveMaskedKv` / `resolveMaskedUrl`
2561
+ // 还原真值 —— 那条路本来就是给"表单里出现打码值"设计的(界面编辑框预填的就是它)。
2562
+ // 让模型驱动的工具在进程内读明文凭据,是另一条没人设计过、也没有测试覆盖的路径。
2563
+ });
2564
+ buildSkillTools({ ...toolDeps, skillsOps: auditOps(skillsService.ops) });
2565
+ // 传 op 表而不是 promptsService:「生效中」的判定只有 `agentsmd-list` 里有(场景绑定
2566
+ // 的那份才算),直调服务会得到文件比对口径 —— 场景驱动时工具会报一个与界面不同的答案。
2567
+ buildPromptTools({ ...toolDeps, promptOps: auditOps(promptOps), applyPresetGuarded, promptsDir });
2568
+ buildMemoryTools({ ...toolDeps, rulesOps: auditOps(memoriesService.ops) });
2569
+ // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2570
+ // + 进入与退出(`_switch`)。
2571
+ // 传**包装后**的 `archiveService.ops`:`scene-archive-save` 与 `scene-mode-set` 都在本文件
2572
+ // 被包了一层(前者联动人设开关、后者带锁定守卫与目录重算),工具走同一条路才有同一套行为。
2573
+ //
2574
+ // `sceneActivate` 传的是 **handlers 表里那份**(`buildSceneSyncOps` 覆盖过的):AGENTS.md
2575
+ // 同步在包装里,直调 `memoriesService.ops['rules-set-active']` 会静默漏掉"把场景绑定的
2576
+ // 预设正文写进 `~/.dsh/AGENTS.md`、退出时恢复基线"这一步。传 handlers 里那份而不是在这里
2577
+ // 再包一次,是为了让工具与 HTTP API 共用同一个实现(不会各自漂移)。
2578
+ buildSceneTools({
2579
+ ...toolDeps,
2580
+ rulesOps: auditOps(memoriesService.ops),
2581
+ archiveOps: auditOps(archiveService.ops),
2582
+ sceneActivate: auditFn('rules-set-active', (args) => handlers['rules-set-active'](args)),
2276
2583
  });
2277
- buildSkillTools({ ...toolDeps, skillsOps: skillsService.ops });
2278
- buildPromptTools({ ...toolDeps, promptsService, applyPresetGuarded, promptsDir });
2279
- buildMemoryTools({ ...toolDeps, rulesOps: memoriesService.ops });
2280
2584
  buildSubagentTools({
2281
2585
  ...toolDeps,
2282
- subagentService,
2586
+ // 只换掉工具看到的 `ops` 那一层(原型链回真实服务,其余读法一字不动)。
2587
+ subagentService: Object.assign(Object.create(subagentService), { ops: auditOps(subagentService.ops) }),
2283
2588
  sceneLists: subagentSceneLists,
2284
2589
  toolFilterFor: subagentToolFilterFor,
2285
2590
  failures: subagentFailures,
@@ -2292,15 +2597,28 @@ export default {
2292
2597
  // 在完全权限下的行为一致;探测实现与回归测试见 approval-policy.ts(必须 ctx.get('approval'),
2293
2598
  // 不能用 ctx.approval——inject 未声明该服务时 cordis 代理会抛 "cannot get property without inject")。
2294
2599
  const CONFIRM_LABELS = {
2295
- skill_manager_create: '「新建技能」',
2296
- memory_manager_write: '「写入记忆」',
2297
- memory_manager_update: '「修改记忆」',
2600
+ // 0.14.0 起 create 与 update 并成 save(标签取中性的「保存」:改一份已有技能时
2601
+ // "新建"是句假话)。危险度不变 —— 两者都往 hub 落文件。
2602
+ skill_manager_save: '「保存技能」',
2603
+ // 0.14.0 起 write/update 并成一条 upsert,标签取中性的「保存记忆」:「写入」在改一条
2604
+ // 已有记忆时是句假话(与同一轮修 `prompt_manager_list` 的「生效中」同一个口径)。
2605
+ memory_manager_save: '「保存记忆」',
2606
+ // 场景族的唯一写工具。无条件问(不设开关)—— 它改的是"进入这个场景时会切换哪些
2607
+ // mcp / 技能 / 人设",改错了影响的是用户切场景之后的**整个运行时环境**,而不是一次
2608
+ // 输出。与 skill / subagent 的 save 同一档(都是"以后每次都按它来"的长期资产)。
2609
+ scene_manager_save: '「保存场景」',
2610
+ // 0.14.0 的边界是"启用与进入留在界面",0.14.x 补上这条工具后它成为**改运行时**的那一条:
2611
+ // 六处开关一起动 + 收窄注入 + 改写 AGENTS.md。危险度高于 `_save`(那个只写剧本),
2612
+ // 所以门禁同档、不降。
2613
+ scene_manager_switch: '「切换场景」',
2298
2614
  subagent_manager_run: '「运行子代理」',
2299
- subagent_manager_create: '「新建人设」',
2300
- subagent_manager_update: '「修改人设」',
2301
- // 模型侧只有这一个工具能塞进任意 command/args(改命令的 mcpm-edit 不对外注册成工具),
2302
- // 所以门禁加在它这里就覆盖了整个模型可达面。
2303
- mcp_manager_add: '「新增 MCP 服务器」',
2615
+ // 0.14.0 起 create 与 update 并成 save(标签取中性的「保存人设」:改一份已有文件时
2616
+ // "新建"是句假话)。危险度不变 —— 两者都往 hub 里落/整份重写一份文件。
2617
+ subagent_manager_save: '「保存人设」',
2618
+ // 0.14.0 起 add 与 edit 并成 save,模型侧能塞进任意 command/args 的仍只有这一个工具
2619
+ // —— 门禁挂在它上面即覆盖整个模型可达面。标签取中性的「保存」:改一台已有服务器
2620
+ // (含改 URL / 命令)与新增一台是同一档危险度,但"新增"在改分支上是句假话。
2621
+ mcp_manager_save: '「保存 MCP 服务器」',
2304
2622
  };
2305
2623
  const bypassedByFullAccess = (exec) => {
2306
2624
  if (!isApprovalNever(ctx, exec))
@@ -2325,80 +2643,123 @@ export default {
2325
2643
  return true;
2326
2644
  }
2327
2645
  };
2328
- /** `subagent_manager_update` 要改名已存在、`_create` 要目标未被占用。 */
2329
- const subagentWriteWouldApply = async (exec) => {
2330
- try {
2331
- const a = (exec && exec.arguments) || {};
2332
- const name = String(a.name || '').trim();
2333
- if (!name)
2334
- return false;
2335
- const list = await subagentService.list();
2336
- const has = (n) => list.some((p) => p.name === n);
2337
- if (exec.name === 'subagent_manager_update') {
2338
- const next = String(a.nextName || '').trim();
2339
- return has(name) && (next === '' || next === name || !has(next));
2340
- }
2341
- return !has(name);
2342
- }
2343
- catch (e) {
2344
- return true;
2345
- }
2346
- };
2347
2646
  ctx.on('tools/pre-execute', async (exec, next) => {
2348
2647
  if (!exec || !CONFIRM_LABELS[String(exec.name)])
2349
2648
  return next();
2350
2649
  if (exec.name === 'subagent_manager_run' && !(await subagentManagerRunTargetExists(exec)))
2351
2650
  return next();
2352
- if ((exec.name === 'subagent_manager_create' || exec.name === 'subagent_manager_update')
2353
- && !(await subagentWriteWouldApply(exec)))
2354
- return next();
2355
2651
  if (bypassedByFullAccess(exec))
2356
2652
  return next();
2357
- if (exec.name === 'skill_manager_create') {
2358
- return Promise.resolve({ kind: 'ask', reason: 'Create a new skill under ~/.dsh/tool-management/skills' });
2653
+ if (exec.name === 'skill_manager_save') {
2654
+ // 卡是**执行前**弹的:此时工具还没读 `skill-state`,走建还是改还没判出来,
2655
+ // 所以 reason 只能取并集。0.14.0 起 create 与 update 并成一条 upsert。
2656
+ return Promise.resolve({ kind: 'ask', reason: 'Create or overwrite a skill under ~/.dsh/tool-management/skills' });
2359
2657
  }
2360
- if (exec.name === 'mcp_manager_add') {
2658
+ if (exec.name === 'mcp_manager_save') {
2361
2659
  // 为什么必须问:stdio 服务器是宿主按你给的 command/args **spawn** 出来的进程
2362
2660
  // (见 http-fence.ts 对 mcpm-add 的说明),且这条目会写进配置长期生效。
2363
2661
  // 写一个技能文件都要问,注册一条能起进程的配置却直接放行,是门禁倒挂 ——
2364
2662
  // 危险度与门禁强度必须同向。
2365
2663
  // 卡里回显将执行的命令行:用户批准的是「跑这条命令」,不是「加一个服务器」。
2664
+ // 0.14.0 起 save 也覆盖"改一台已存在的服务器",此时把**旧命令**一并摆出来:
2665
+ // 「把 `npx A` 改成 `npx B`」与「新增一台跑 `npx B` 的」是两件不同的事,
2666
+ // 只报新那句会让一次改命令看起来像新建。
2366
2667
  const a = (exec && exec.arguments) || {};
2668
+ const server = String(a.server || '');
2367
2669
  const transport = String(a.transport || '');
2368
2670
  const cmd = [a.command, a.args]
2369
2671
  .map((x) => String(x ?? '').trim())
2370
2672
  .filter((x) => x !== '')
2371
2673
  .join(' ');
2372
- const detail = transport === 'stdio'
2674
+ // 新 URL 走打码形态:查询串里的凭据不该明文进确认卡(与列表视图同一份实现)。
2675
+ // 旧 URL 已经是打码的 —— 它来自列表视图。
2676
+ const nextLine = transport === 'stdio'
2373
2677
  ? (cmd ? `stdio command: ${cmd}` : 'stdio command: (empty)')
2374
- : `${transport || 'streamable-http'} url: ${String(a.url || '(empty)')}`;
2678
+ : `${transport || 'streamable-http'} url: ${String(maskUrlQuery(String(a.url || '')) || '(empty)')}`;
2679
+ let cur = null;
2680
+ try {
2681
+ const r = await mcp.mcpmListView();
2682
+ cur = ((r && r.rows) || []).find((x) => String(x.serverName) === server) || null;
2683
+ }
2684
+ catch { /* 拿不到现状就按"新增"的口径报:宁可少说一句,也不因此拦住操作 */ }
2685
+ let detail = nextLine;
2686
+ if (cur) {
2687
+ const curCmd = [cur.command, Array.isArray(cur.args) ? cur.args.join(' ') : cur.args]
2688
+ .map((x) => String(x ?? '').trim())
2689
+ .filter((x) => x !== '')
2690
+ .join(' ');
2691
+ const curLine = String(cur.transport) === 'stdio'
2692
+ ? `stdio command: ${curCmd || '(empty)'}`
2693
+ : `${String(cur.transport || 'streamable-http')} url: ${String(cur.url || '(empty)')}`;
2694
+ detail = `current — ${curLine}; new — ${nextLine}`;
2695
+ }
2375
2696
  return Promise.resolve({
2376
2697
  kind: 'ask',
2377
- reason: `Add MCP server「${String(a.serverName || '')}」— ${detail}. A stdio server is spawned by the host and the entry persists in the config.`,
2698
+ reason: `${cur ? 'Update' : 'Add'} MCP server「${server}」— ${detail}. A stdio server is spawned by the host and the entry persists in the config.`,
2378
2699
  });
2379
2700
  }
2380
- if (exec.name === 'memory_manager_write' || exec.name === 'memory_manager_update') {
2701
+ if (exec.name === 'memory_manager_save') {
2381
2702
  // D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
2382
2703
  // 不在此处做任何兜底放行。
2383
- // `memory_manager_update` 走**同一个开关**:改一条记忆和建一条记忆动的是同一批文件,
2384
- // 而改的破坏性更大(覆盖已有内容)—— 给它另设一档只会造出一个"改不用问、建要问"的倒挂。
2385
- const what = exec.name === 'memory_manager_write'
2386
- ? 'Write a memory under ~/.dsh/tool-management/memories'
2387
- : 'Update an existing memory under ~/.dsh/tool-management/memories';
2704
+ // 0.14.0 起 write/update 并成一条 upsert,reason 必须同时覆盖两种可能:卡是**执行前**
2705
+ // 弹的,此时工具还没跑,走"建"还是"改"要读一次 `rules-list` 才知道。旧实现按工具名
2706
+ // 分叉出两句,合并后只能取并集 —— 不能只留 "write",那会让一次覆盖已有内容的操作
2707
+ // 在卡上看起来像新建(危险度与门禁强度必须同向)。
2708
+ const what = 'Create or overwrite a memory under ~/.dsh/tool-management/memories';
2388
2709
  return mcp.readPluginSettings()
2389
2710
  .then((s) => (s.requireConfirmForModelRuleWrite ? { kind: 'ask', reason: what } : next()))
2390
2711
  .catch(() => ({ kind: 'ask', reason: what }));
2391
2712
  }
2392
- if (exec.name === 'subagent_manager_create' || exec.name === 'subagent_manager_update') {
2393
- // 与 `skill_manager_create` 完全对称:往 hub 里落一份新文件 / 整份重写一份现有文件。
2713
+ if (exec.name === 'scene_manager_save') {
2714
+ // 与记忆 save 的区别:**不设开关**。记忆写入有 `requireConfirmForModelRuleWrite`
2715
+ // 可以放行,场景写入没有 —— 它改的是"用户切进这个场景之后,哪些 mcp / 技能 / 人设
2716
+ // 会开、其余全部关掉",那是一次环境切换的剧本,不是一份可以事后改的文档。
2717
+ // 卡里回显场景名与这次要写的档案段(用户批准的是"这个场景进入时开这些")。
2718
+ const a = (exec && exec.arguments) || {};
2719
+ const scene = String(a.scene || '');
2720
+ const secs = ['mcp', 'skills', 'subagents'].filter((k) => a[k] !== undefined);
2721
+ const detail = secs.length
2722
+ ? `archive sections: ${secs.join(', ')}`
2723
+ : 'record only (label / description / prompt)';
2724
+ return Promise.resolve({
2725
+ kind: 'ask',
2726
+ reason: `Save scene「${scene}」— ${detail}. Entering it switches on exactly what this scene lists and turns everything else off.`,
2727
+ });
2728
+ }
2729
+ if (exec.name === 'scene_manager_switch') {
2730
+ // 与 `scene_manager_save` 同一档(都无条件问),但**问的是另一件事**:
2731
+ // save 改的是"以后进这个场景会怎样"(一份剧本),switch 改的是**现在** ——
2732
+ // 六处开关一起动(MCP 服务器级/工具级、技能来源级/技能级、人设、备注覆盖),
2733
+ // 注入范围随之收窄,并且**改写 `~/.dsh/AGENTS.md`**(本插件唯一会动那个文件的
2734
+ // 动作,覆盖前 5 代备份)。危险度只高不低,所以门禁强度不降。
2735
+ // 卡里回显动作与目标场景:用户批准的是"现在就切到 X",不是"改一下 X 的配置"。
2736
+ const a = (exec && exec.arguments) || {};
2737
+ const action = String(a.action || '');
2738
+ const scene = String(a.scene || '');
2739
+ const what = action === 'exit'
2740
+ ? 'Leave the current scene — restore the runtime switches and the AGENTS.md baseline saved on entry'
2741
+ : `Enter scene「${scene}」— apply its archive (everything it does not list goes off), narrow injection to its memories, and apply its bound prompt preset to AGENTS.md`;
2742
+ return Promise.resolve({ kind: 'ask', reason: what });
2743
+ }
2744
+ if (exec.name === 'subagent_manager_save') {
2745
+ // 与 `skill_manager_save` 完全对称:往 hub 里落一份新文件 / 整份重写一份现有文件。
2394
2746
  // 无条件问(不设开关)—— 人设是"以后每次委派都按它来"的长期资产,改错了影响的是
2395
2747
  // 后续所有子代理的行为,而不是一次输出。
2396
- // update 尤其:`subagent-update` 走 serializePersona 整份重写,改名还会连带改
2748
+ // 改分支尤其:`subagent-update` 走 serializePersona 整份重写,改名还会连带改
2397
2749
  // 场景档案里的绑定 —— 那是一次会影响环境配置的操作,不是一句文案修改。
2750
+ // 建还是改要读一次现状才知道(工具侧也是执行时才读),所以这里查一下名单:
2751
+ // 只报"新建"会让一次整份重写看起来像加了个文件,反过来也一样。
2398
2752
  const a = (exec && exec.arguments) || {};
2399
- const what = exec.name === 'subagent_manager_create'
2400
- ? `Create persona「${String(a.name || '')}」under ~/.dsh/tool-management/subagents`
2401
- : `Rewrite persona「${String(a.name || '')}」${a.nextName ? ` and rename it to「${String(a.nextName)}」(scene-profile bindings follow the rename)` : ''}`;
2753
+ const name = String(a.name || '');
2754
+ let exists = false;
2755
+ try {
2756
+ const docs = await subagentService.list();
2757
+ exists = docs.some((p) => String(p && p.name) === name);
2758
+ }
2759
+ catch { /* 读不到现状就按"新建"的口径报:宁可少说一句,也不因此拦住操作 */ }
2760
+ const what = exists
2761
+ ? `Rewrite persona「${name}」${a.nextName ? ` and rename it to「${String(a.nextName)}」(scene-profile bindings follow the rename)` : ''}`
2762
+ : `Create persona「${name}」under ~/.dsh/tool-management/subagents`;
2402
2763
  return Promise.resolve({ kind: 'ask', reason: what });
2403
2764
  }
2404
2765
  // subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
@@ -2670,6 +3031,10 @@ export default {
2670
3031
  return;
2671
3032
  }
2672
3033
  const result = await fn(payload.args || {});
3034
+ // 「最近改动」流水:来源 = 面板(这条 HTTP 路由是界面唯一的入口)。
3035
+ // `readOnlyCall` 是上面那四个「读改写两态 op」的纯读分支 —— 它们没改任何状态,不记。
3036
+ if (!readOnlyCall)
3037
+ recordOpAudit(op, payload.args || {}, 'panel', result);
2673
3038
  res.end(JSON.stringify(withPatchWarnings(result === undefined ? { ok: true } : result)));
2674
3039
  }
2675
3040
  catch (e) {