dsh-plugin-tool-management 0.13.0 → 0.14.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 (51) hide show
  1. package/CHANGELOG.md +45 -0
  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 +28 -0
  21. package/lib/client.js +578 -124
  22. package/lib/compat/preset-reach.js +6 -3
  23. package/lib/context-inject.js +86 -32
  24. package/lib/index.js +251 -92
  25. package/lib/mcp/manager.js +3 -14
  26. package/lib/mcp/secret-guard.js +21 -0
  27. package/lib/mcp/state-section.js +13 -11
  28. package/lib/memories/constants.js +72 -8
  29. package/lib/memories/index-io.js +1 -1
  30. package/lib/memories/projection.js +67 -31
  31. package/lib/memories/service.js +17 -2
  32. package/lib/memories/snapshot.js +113 -22
  33. package/lib/op-registry.js +8 -2
  34. package/lib/ops/candidates.js +11 -0
  35. package/lib/ops/compat.js +10 -2
  36. package/lib/ops/sessions.js +1 -1
  37. package/lib/prompts/service.js +25 -2
  38. package/lib/scenes/candidates.js +56 -0
  39. package/lib/skills/core.js +93 -0
  40. package/lib/skills/service.js +34 -2
  41. package/lib/subagents/service.js +37 -4
  42. package/lib/subagents/tools.js +11 -4
  43. package/lib/tools/deps.js +15 -0
  44. package/lib/tools/mcp.js +291 -50
  45. package/lib/tools/memory.js +131 -80
  46. package/lib/tools/prompt.js +27 -13
  47. package/lib/tools/scene.js +351 -0
  48. package/lib/tools/skills.js +93 -17
  49. package/lib/tools/subagent.js +56 -45
  50. package/lib/tools/table.js +204 -15
  51. package/package.json +108 -108
package/lib/index.js CHANGED
@@ -29,13 +29,15 @@ import { createSubagentCatalog } from './subagents/catalog.js';
29
29
  import { createSkillCatalog } from './skills/catalog.js';
30
30
  import { DEFAULT_INJECT_SETTINGS, createContextInjector, normalizeInjectSettings, subagentDepthOf, } from './context-inject.js';
31
31
  import { isApprovalNever } from './approval-policy.js';
32
- import { buildToolTableReport, normalizeToolTableSettings, } from './tools/table.js';
32
+ import { addPreset, buildToolTableReport, DEFAULT_HIDDEN_TOOLS, dropPreset, migrateLegacyToolNames, normalizeToolTableSettings, PRESET_MAX_COUNT, PRESET_NAME_MAX_LENGTH, toolTableSettingsFrom, } from './tools/table.js';
33
33
  import { normalizeSceneSettings } from './scene-settings.js';
34
34
  import { checkPatchWrite, takePatchGuardWarnings } from './compat/patch-dialect.js';
35
35
  import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
36
36
  import { reachNoticeForAgent } from './compat/preset-reach.js';
37
37
  import { TOKEN_CODE_BAD, TOKEN_MSG, fenceRejection, secretOpRejection } from './http-fence.js';
38
38
  import { createMcpManager } from './mcp/manager.js';
39
+ // 确认卡回显"新 URL"时要打码(查询串里的凭据不该明文进卡片),口径与列表视图同一份实现。
40
+ import { maskUrlQuery } from './mcp/secret-guard.js';
39
41
  import { createCandidates } from './scenes/candidates.js';
40
42
  // 「已知工具」归一化与「当前可用工具数」原本定义在本文件(导出仅为测试接缝),
41
43
  // 2026-09-19 随 MCP 域搬进 ./mcp/manager.ts。这里保持原路径可导出,契约不变。
@@ -56,6 +58,7 @@ import { buildMcpTools } from './tools/mcp.js';
56
58
  import { buildSkillTools } from './tools/skills.js';
57
59
  import { buildPromptTools } from './tools/prompt.js';
58
60
  import { buildMemoryTools } from './tools/memory.js';
61
+ import { buildSceneTools } from './tools/scene.js';
59
62
  import { buildSubagentTools } from './tools/subagent.js';
60
63
  import { createScenePromptSync } from './scene-prompt-sync.js';
61
64
  import { extractText } from './imports/parsers.js';
@@ -210,7 +213,7 @@ export default {
210
213
  memoriesRoot: String(config?.memoriesRoot || ''),
211
214
  stateDir: String(config?.memoriesStateDir || ''),
212
215
  scenesDir: String(config?.memoriesScenesDir || ''),
213
- // 场景记忆段预算(字节),默认 65536;仅用于测试与特殊部署调优。
216
+ // 场景记忆段预算(字节),默认 131072(= DEFAULT_MAX_BYTES,128 KiB);仅用于测试与特殊部署调优。
214
217
  ...(Number.isFinite(Number(config?.rulesMaxBytes))
215
218
  ? { maxBytes: Number(config.rulesMaxBytes) }
216
219
  : {}),
@@ -510,8 +513,10 @@ export default {
510
513
  .map((s) => slice.archives[s.name]?.subagents ?? []);
511
514
  };
512
515
  // ---------- 模型工具表开关(侧车 `tool-table.json`,界面在「兼容」页)----------
513
- // 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,500 tok 每轮都在。关掉某几个,
514
- // 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。
516
+ // 工具表按**每个请求**付钱:20 个工具的整份定义合计 ≈3,769 tok 每轮都在。关掉某几个,
517
+ // 它们整份不进请求(实测口径与取舍见 src/tools/table.ts 的文件头)。**出厂就已经关了十五条**
518
+ // (20 条里实发只剩 5 条)—— 名单、判据与代价在 `DEFAULT_HIDDEN_TOOLS`,只在这个人从没记过
519
+ // 选择时铺(见下面的 `toolTableSettingsFrom`)。
515
520
  //
516
521
  // 为什么放在目录之前:两个目录的「用 `X` 查」提示要跟着这份设置变(工具关掉后那句话
517
522
  // 就是假的),所以它们的构造依赖在这块之后 —— 不做前向引用,顺序就是依赖顺序。
@@ -536,7 +541,20 @@ export default {
536
541
  return toolTableCache.value;
537
542
  await ensurePaths();
538
543
  const raw = await readJsonFile(hubPath(TOOL_TABLE_FILE));
539
- const value = normalizeToolTableSettings(raw);
544
+ // 0.14.0 旧工具名迁移:读侧翻译一次并**回写盘**(幂等)。不迁移的话,用户"关掉了某条"
545
+ // 的意图会在新名字上静默失效 —— 那条工具照旧每轮发出去,而界面上看不出来。
546
+ // 写失败不回滚本次读取:内存里已经是迁移后的值,下次读会再试一次。
547
+ // 侧车不存在(`readJsonFile` 给 null)= 用户从没记过选择 ⇒ 用**出厂默认**(十五条不发,
548
+ // 实发 5 条;判据与代价见 tools/table.ts 的 DEFAULT_HIDDEN_TOOLS)。存过盘的
549
+ // 一律照盘上那份,包括显式的 `hidden: []` —— 那是"我全都要",不能被默认值盖掉。
550
+ const migrated = migrateLegacyToolNames(toolTableSettingsFrom(raw));
551
+ const value = migrated.settings;
552
+ if (migrated.changed) {
553
+ try {
554
+ await writeJsonFile(hubPath(TOOL_TABLE_FILE), value);
555
+ }
556
+ catch { /* 回写失败:本次仍按迁移后的值生效 */ }
557
+ }
540
558
  const changed = toolTableCache === null || toolTableCache.value.hidden.join('\u0000') !== value.hidden.join('\u0000');
541
559
  toolTableCache = { at: Date.now(), value, off: new Set(value.hidden) };
542
560
  // 首读 / 文件被外部改过 ⇒ 可见性要跟着重排。同步快照的调用方(门禁、注入通道)不会
@@ -552,8 +570,9 @@ export default {
552
570
  }
553
571
  /**
554
572
  * 同步快照(热路径用:工具门禁每次调用、注入通道每个 step 都要问一句)。还没加载时先给
555
- * 默认值并异步预热 —— 默认是"什么都没关",预热前的这一瞬与用户的选择可能不一致,但
556
- * 方向是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让工具凭空消失)。
573
+ * **空集**并异步预热 —— 空集不是出厂默认(出厂关着五条),是刻意的:预热前的这一瞬与
574
+ * 用户的选择可能不一致,但方向必须是安全的那一侧(不隐藏任何东西,绝不因为读盘慢而让
575
+ * 工具凭空消失)。
557
576
  */
558
577
  function toolTableHidden() {
559
578
  if (toolTableCache === null) {
@@ -564,27 +583,74 @@ export default {
564
583
  }
565
584
  const toolTableRows = () => [...toolTableSizes].map(([name, bytes]) => ({ name, bytes }));
566
585
  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));
586
+ /** 落盘 + 同步内存缓存(三条写路径共用:改勾选 / 存方案 / 删方案)。 */
587
+ async function writeToolTableSettings(next) {
575
588
  await ensurePaths();
576
589
  // 落盘在写锁里,落完**出了锁**再刷新(`withWriteLock` 是一条不可重入的链:锁里再调
577
590
  // 一次会等自己,直接卡死)。刷新是两处目录读,本来也不必占着写锁。
578
- const saved = await withWriteLock(async () => {
591
+ return withWriteLock(async () => {
579
592
  try {
580
- await writeJsonFile(hubPath(TOOL_TABLE_FILE), { hidden });
593
+ await writeJsonFile(hubPath(TOOL_TABLE_FILE), next);
581
594
  }
582
595
  catch (e) {
583
596
  return { ok: false, error: '设置保存失败: ' + message(e) };
584
597
  }
585
- toolTableCache = { at: Date.now(), value: { hidden }, off: new Set(hidden) };
598
+ toolTableCache = { at: Date.now(), value: next, off: new Set(next.hidden) };
586
599
  return { ok: true };
587
600
  });
601
+ }
602
+ /**
603
+ * 回给界面的同一份形状:当前名单 + 已存方案 + 出厂默认名单 + 体积报告。
604
+ *
605
+ * `defaultHidden` 单独回一份而不是塞进 `presets`:那条**不存在文件里**(它是代码里的
606
+ * `DEFAULT_HIDDEN_TOOLS`),写进文件就会出现"用户删不掉的一条数据"。界面把它排在最前、
607
+ * 不给删除键,词典出它的名字。
608
+ */
609
+ const toolTablePayload = (value) => ({
610
+ ok: true, hidden: value.hidden, presets: value.presets,
611
+ defaultHidden: [...DEFAULT_HIDDEN_TOOLS], report: toolTableReport(value.hidden),
612
+ });
613
+ /** 方案名:折叠空白 + 截到上限。空名由调用方报错(这里不猜"用户想叫什么")。 */
614
+ const presetNameOf = (raw) => String(raw ?? '').replaceAll(/\s+/g, ' ').trim().slice(0, PRESET_NAME_MAX_LENGTH);
615
+ async function toolTableOp(args) {
616
+ const current = await readToolTableSettings();
617
+ if (!args || args.set !== true) {
618
+ // 存 / 删方案都不动 `hidden`,所以这两条路**不**重排可见性、也不刷目录(那是 `set`
619
+ // 那一条才有的三处联动)。失败原因回 `code` 不回中文串 —— 英文界面会原样露出中文。
620
+ if (args && args.presetSave !== undefined) {
621
+ const name = presetNameOf(args.presetSave);
622
+ if (name === '')
623
+ return { ok: false, code: 'nameEmpty' };
624
+ const added = addPreset(current, name, current.hidden);
625
+ // 同名不覆盖(用户裁定):撞名要用户换个名字,而不是悄悄改掉已有那份。
626
+ if (added.exists)
627
+ return { ok: false, code: 'nameTaken', name };
628
+ if (current.presets.length >= PRESET_MAX_COUNT) {
629
+ return { ok: false, code: 'limit', limit: PRESET_MAX_COUNT };
630
+ }
631
+ const saved = await writeToolTableSettings(added.settings);
632
+ if (!saved.ok)
633
+ return saved;
634
+ return toolTablePayload(added.settings);
635
+ }
636
+ if (args && args.presetDelete !== undefined) {
637
+ const name = presetNameOf(args.presetDelete);
638
+ const dropped = dropPreset(current, name);
639
+ if (!dropped.changed)
640
+ return { ok: false, code: 'notFound', name };
641
+ const saved = await writeToolTableSettings(dropped.settings);
642
+ if (!saved.ok)
643
+ return saved;
644
+ return toolTablePayload(dropped.settings);
645
+ }
646
+ return toolTablePayload(current);
647
+ }
648
+ // 只收**已注册**的名字:写进来的陌生名字在下一次 restrict 时会让官方抛错
649
+ // ("names unknown global tool")。注册失败的工具本来也不在表里。
650
+ const hidden = normalizeToolTableSettings({ hidden: args.hidden }).hidden
651
+ .filter((name) => toolTableSizes.has(name));
652
+ const next = { hidden, presets: current.presets };
653
+ const saved = await writeToolTableSettings(next);
588
654
  if (!saved.ok)
589
655
  return saved;
590
656
  // 三处跟着变:可见性(restrict 名单重排)、两个目录(截断提示里点名的工具可能没了)。
@@ -594,7 +660,7 @@ export default {
594
660
  skillCatalog.refresh().catch(() => { }),
595
661
  subagentCatalog.refresh().catch(() => { }),
596
662
  ]);
597
- return { ok: true, hidden, report: toolTableReport(hidden) };
663
+ return toolTablePayload(next);
598
664
  }
599
665
  // ---------- 「有官方等价物 ⇒ 我们让位」----------
600
666
  // 官方 `skill` 工具(`@deepseek-ai/dsh-tool-skill`)与我们的 `skill_manager_read` 是同一
@@ -628,7 +694,7 @@ export default {
628
694
  }
629
695
  return out;
630
696
  };
631
- // ---------- 注入通道(场景和记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
697
+ // ---------- 注入通道(场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词,各一条消息)----------
632
698
  // 这些文本以前是 systemPrompt 段(persona complete 会整段压掉)。现在改走官方的
633
699
  // 「每步注入一条合成消息」通道(skill-catalog / AGENTS.md / 时间上下文同款):
634
700
  // 任何预设都到得了,内容没变不重发。**每个域一条自己的消息**(来源 kind 各不同,
@@ -759,16 +825,21 @@ export default {
759
825
  // `catalogDepth` 字段的注释。目录正文也按同一个判据过滤(`text(agent)`),两处同源
760
826
  // 所以不会分叉。
761
827
  //
762
- // 记忆域(2026-09-17 用户裁定):**只在顶层注入**。记忆是"父会话的现场",不是子代理
763
- // 完成任务所需的事实 —— 而且它带着「一律照办,覆盖你的默认做法」这种强主张,塞进
764
- // 一次性子会话只会与角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与
765
- // 整份场景记忆)。子代理手里有 `memory_manager_list/read`,需要什么自己取;父代理
766
- // 上下文里也有记忆,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
828
+ // 场景与记忆(2026-09-17 用户裁定;2026-09-23 拆成两个域):**都只在顶层注入**。
829
+ // 记忆是"父会话的现场",不是子代理完成任务所需的事实 —— 塞进一次性子会话只会与
830
+ // 角色定义争注意力(实测:子代理跑审查时,上下文里同时躺着人设与整份记忆)。
831
+ // 场景段同理:它说的是"父会话现在处在哪个模式",而子代理根本没有"模式"可言。
832
+ // 子代理手里有 `memory_manager_list/read` 与 `scene_manager_save`,需要什么自己取;
833
+ // 父代理上下文里也有,相关事实应当由它写进 `task`(子代理的上下文 = 角色 + 任务)。
767
834
  // 其余三域对任何深度都成立:提示词是用户规则(本插件的立身之本就是"覆盖到子代理")、
768
835
  // 技能目录与 MCP 状态是"操作这台机器所需的事实"(子代理手里就有 `skill` / `mcp__*`
769
836
  // 工具,不知道清单就只能瞎调)。
770
837
  domains: () => [
771
- { key: 'memory', name: 'tool-management:scene-memory', label: '场景和记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
838
+ // 场景(**框架**):启用的场景 + 场景说明(用户的约定)。排第一 —— 先让模型知道
839
+ // "现在在哪个场景、这个场景的约定是什么",再读下面的记忆条目。
840
+ { key: 'scene', name: 'tool-management:scene', label: '场景', form: 'snapshot', text: () => memoriesService.sceneCatalogText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
841
+ // 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
842
+ { key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
772
843
  { key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
773
844
  { key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: () => skillCatalog.text() },
774
845
  {
@@ -1682,6 +1753,25 @@ export default {
1682
1753
  memoriesService,
1683
1754
  toolKeyParts,
1684
1755
  });
1756
+ // 提示词预设域的 op 表。单独提出来是为了让**模型工具**能复用同一份判定:
1757
+ // `prompt_manager_list` 以前直调 `promptsService.list()`,于是它报的「生效中」是
1758
+ // **文件比对**口径(预设正文 == ~/.dsh/AGENTS.md),而界面报的是 `agentsmd-list` 的口径
1759
+ // (启用的场景绑了预设时,**只有那份**算生效中)—— 同一件事两个答案,场景驱动时工具就在
1760
+ // 说假话。工具改走 op 之后两边只有一份判定。
1761
+ const promptOps = buildPromptOps({
1762
+ promptsService,
1763
+ scenePromptSync,
1764
+ // 显式转发而不是整表传入:这两个 op 名字写死在这里,rules 域哪天改名会立刻红,
1765
+ // 不会静默变成 undefined 调用。
1766
+ rulesOps: {
1767
+ 'rules-list': (args) => memoriesService.ops['rules-list'](args),
1768
+ 'rules-rebind-prompt': (args) => memoriesService.ops['rules-rebind-prompt'](args),
1769
+ },
1770
+ applyPresetGuarded,
1771
+ withAgentsMdSync,
1772
+ promptRefReason,
1773
+ warn: (m) => { ctx.logger?.warn?.(m); },
1774
+ });
1685
1775
  const handlers = {
1686
1776
  'plugin-version': pluginVersion,
1687
1777
  'skill-open': skillOpen,
@@ -1729,23 +1819,8 @@ export default {
1729
1819
  message,
1730
1820
  compatLog,
1731
1821
  }),
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
- }),
1822
+ // AGENTS.md / 提示词预设域(ops/prompts.ts 提供,见上方 promptOps)。
1823
+ ...promptOps,
1749
1824
  // 归档会话域(ops/history.ts 提供):history-list / history-sessions /
1750
1825
  // history-export-defaults / dir-list / history-archive / history-archive-batch /
1751
1826
  // history-unarchive / history-delete / history-unarchive-batch / history-delete-batch /
@@ -1785,6 +1860,7 @@ export default {
1785
1860
  ...buildCandidateOps({
1786
1861
  presetToolCandidates: candidates.presetToolCandidates,
1787
1862
  modelCandidates: candidates.modelCandidates,
1863
+ modelReasoning: candidates.modelReasoning,
1788
1864
  getToolFailures: () => subagentToolFailures,
1789
1865
  mcpmListView: mcp.mcpmListView,
1790
1866
  toolStates: candidates.toolStates,
@@ -2262,6 +2338,8 @@ export default {
2262
2338
  register: (def) => { recordToolSize(def); tools.register(def); },
2263
2339
  lockedSceneGuard,
2264
2340
  syncSwitchToScene,
2341
+ // 错误句里点名工具前要先问一句(判据与两个目录的 `listToolVisible` 同一份缓存)。
2342
+ toolVisible: (name) => !toolTableHidden().has(name),
2265
2343
  reachNoticeForAgent,
2266
2344
  presetRoster: candidates.presetRoster,
2267
2345
  injectNoticeOptions,
@@ -2270,13 +2348,38 @@ export default {
2270
2348
  buildMcpTools({
2271
2349
  ...toolDeps,
2272
2350
  mcpmListView: mcp.mcpmListView,
2351
+ mcpmTools: mcp.ops['mcpm-tools'],
2273
2352
  mcpmSetEnabled: mcp.ops['mcpm-set-enabled'],
2274
2353
  mcpmRestart: mcp.ops['mcpm-restart'],
2354
+ mcpmToolEnabled: mcp.ops['mcpm-tool-enabled'],
2275
2355
  mcpmAdd: mcp.ops['mcpm-add'],
2356
+ mcpmEdit: mcp.ops['mcpm-edit'],
2357
+ mcpmNote: mcp.ops['mcpm-note'],
2358
+ // **故意不接 `mcpm-reveal`**:`mcp_manager_save` 的改分支用 `mcpmListView()` 的
2359
+ // 打码视图填回省略字段,由 `mcpm-edit` 的 `resolveMaskedKv` / `resolveMaskedUrl`
2360
+ // 还原真值 —— 那条路本来就是给"表单里出现打码值"设计的(界面编辑框预填的就是它)。
2361
+ // 让模型驱动的工具在进程内读明文凭据,是另一条没人设计过、也没有测试覆盖的路径。
2276
2362
  });
2277
2363
  buildSkillTools({ ...toolDeps, skillsOps: skillsService.ops });
2278
- buildPromptTools({ ...toolDeps, promptsService, applyPresetGuarded, promptsDir });
2364
+ // 传 op 表而不是 promptsService:「生效中」的判定只有 `agentsmd-list` 里有(场景绑定
2365
+ // 的那份才算),直调服务会得到文件比对口径 —— 场景驱动时工具会报一个与界面不同的答案。
2366
+ buildPromptTools({ ...toolDeps, promptOps, applyPresetGuarded, promptsDir });
2279
2367
  buildMemoryTools({ ...toolDeps, rulesOps: memoriesService.ops });
2368
+ // 场景族(tools/scene.ts):列场景与档案(`_list`)+ 建场景 / 写档案 / 绑提示词(`_save`)
2369
+ // + 进入与退出(`_switch`)。
2370
+ // 传**包装后**的 `archiveService.ops`:`scene-archive-save` 与 `scene-mode-set` 都在本文件
2371
+ // 被包了一层(前者联动人设开关、后者带锁定守卫与目录重算),工具走同一条路才有同一套行为。
2372
+ //
2373
+ // `sceneActivate` 传的是 **handlers 表里那份**(`buildSceneSyncOps` 覆盖过的):AGENTS.md
2374
+ // 同步在包装里,直调 `memoriesService.ops['rules-set-active']` 会静默漏掉"把场景绑定的
2375
+ // 预设正文写进 `~/.dsh/AGENTS.md`、退出时恢复基线"这一步。传 handlers 里那份而不是在这里
2376
+ // 再包一次,是为了让工具与 HTTP API 共用同一个实现(不会各自漂移)。
2377
+ buildSceneTools({
2378
+ ...toolDeps,
2379
+ rulesOps: memoriesService.ops,
2380
+ archiveOps: archiveService.ops,
2381
+ sceneActivate: (args) => handlers['rules-set-active'](args),
2382
+ });
2280
2383
  buildSubagentTools({
2281
2384
  ...toolDeps,
2282
2385
  subagentService,
@@ -2292,15 +2395,28 @@ export default {
2292
2395
  // 在完全权限下的行为一致;探测实现与回归测试见 approval-policy.ts(必须 ctx.get('approval'),
2293
2396
  // 不能用 ctx.approval——inject 未声明该服务时 cordis 代理会抛 "cannot get property without inject")。
2294
2397
  const CONFIRM_LABELS = {
2295
- skill_manager_create: '「新建技能」',
2296
- memory_manager_write: '「写入记忆」',
2297
- memory_manager_update: '「修改记忆」',
2398
+ // 0.14.0 起 create 与 update 并成 save(标签取中性的「保存」:改一份已有技能时
2399
+ // "新建"是句假话)。危险度不变 —— 两者都往 hub 落文件。
2400
+ skill_manager_save: '「保存技能」',
2401
+ // 0.14.0 起 write/update 并成一条 upsert,标签取中性的「保存记忆」:「写入」在改一条
2402
+ // 已有记忆时是句假话(与同一轮修 `prompt_manager_list` 的「生效中」同一个口径)。
2403
+ memory_manager_save: '「保存记忆」',
2404
+ // 场景族的唯一写工具。无条件问(不设开关)—— 它改的是"进入这个场景时会切换哪些
2405
+ // mcp / 技能 / 人设",改错了影响的是用户切场景之后的**整个运行时环境**,而不是一次
2406
+ // 输出。与 skill / subagent 的 save 同一档(都是"以后每次都按它来"的长期资产)。
2407
+ scene_manager_save: '「保存场景」',
2408
+ // 0.14.0 的边界是"启用与进入留在界面",0.14.x 补上这条工具后它成为**改运行时**的那一条:
2409
+ // 六处开关一起动 + 收窄注入 + 改写 AGENTS.md。危险度高于 `_save`(那个只写剧本),
2410
+ // 所以门禁同档、不降。
2411
+ scene_manager_switch: '「切换场景」',
2298
2412
  subagent_manager_run: '「运行子代理」',
2299
- subagent_manager_create: '「新建人设」',
2300
- subagent_manager_update: '「修改人设」',
2301
- // 模型侧只有这一个工具能塞进任意 command/args(改命令的 mcpm-edit 不对外注册成工具),
2302
- // 所以门禁加在它这里就覆盖了整个模型可达面。
2303
- mcp_manager_add: '「新增 MCP 服务器」',
2413
+ // 0.14.0 起 create 与 update 并成 save(标签取中性的「保存人设」:改一份已有文件时
2414
+ // "新建"是句假话)。危险度不变 —— 两者都往 hub 里落/整份重写一份文件。
2415
+ subagent_manager_save: '「保存人设」',
2416
+ // 0.14.0 起 add 与 edit 并成 save,模型侧能塞进任意 command/args 的仍只有这一个工具
2417
+ // —— 门禁挂在它上面即覆盖整个模型可达面。标签取中性的「保存」:改一台已有服务器
2418
+ // (含改 URL / 命令)与新增一台是同一档危险度,但"新增"在改分支上是句假话。
2419
+ mcp_manager_save: '「保存 MCP 服务器」',
2304
2420
  };
2305
2421
  const bypassedByFullAccess = (exec) => {
2306
2422
  if (!isApprovalNever(ctx, exec))
@@ -2325,80 +2441,123 @@ export default {
2325
2441
  return true;
2326
2442
  }
2327
2443
  };
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
2444
  ctx.on('tools/pre-execute', async (exec, next) => {
2348
2445
  if (!exec || !CONFIRM_LABELS[String(exec.name)])
2349
2446
  return next();
2350
2447
  if (exec.name === 'subagent_manager_run' && !(await subagentManagerRunTargetExists(exec)))
2351
2448
  return next();
2352
- if ((exec.name === 'subagent_manager_create' || exec.name === 'subagent_manager_update')
2353
- && !(await subagentWriteWouldApply(exec)))
2354
- return next();
2355
2449
  if (bypassedByFullAccess(exec))
2356
2450
  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' });
2451
+ if (exec.name === 'skill_manager_save') {
2452
+ // 卡是**执行前**弹的:此时工具还没读 `skill-state`,走建还是改还没判出来,
2453
+ // 所以 reason 只能取并集。0.14.0 起 create 与 update 并成一条 upsert。
2454
+ return Promise.resolve({ kind: 'ask', reason: 'Create or overwrite a skill under ~/.dsh/tool-management/skills' });
2359
2455
  }
2360
- if (exec.name === 'mcp_manager_add') {
2456
+ if (exec.name === 'mcp_manager_save') {
2361
2457
  // 为什么必须问:stdio 服务器是宿主按你给的 command/args **spawn** 出来的进程
2362
2458
  // (见 http-fence.ts 对 mcpm-add 的说明),且这条目会写进配置长期生效。
2363
2459
  // 写一个技能文件都要问,注册一条能起进程的配置却直接放行,是门禁倒挂 ——
2364
2460
  // 危险度与门禁强度必须同向。
2365
2461
  // 卡里回显将执行的命令行:用户批准的是「跑这条命令」,不是「加一个服务器」。
2462
+ // 0.14.0 起 save 也覆盖"改一台已存在的服务器",此时把**旧命令**一并摆出来:
2463
+ // 「把 `npx A` 改成 `npx B`」与「新增一台跑 `npx B` 的」是两件不同的事,
2464
+ // 只报新那句会让一次改命令看起来像新建。
2366
2465
  const a = (exec && exec.arguments) || {};
2466
+ const server = String(a.server || '');
2367
2467
  const transport = String(a.transport || '');
2368
2468
  const cmd = [a.command, a.args]
2369
2469
  .map((x) => String(x ?? '').trim())
2370
2470
  .filter((x) => x !== '')
2371
2471
  .join(' ');
2372
- const detail = transport === 'stdio'
2472
+ // 新 URL 走打码形态:查询串里的凭据不该明文进确认卡(与列表视图同一份实现)。
2473
+ // 旧 URL 已经是打码的 —— 它来自列表视图。
2474
+ const nextLine = transport === 'stdio'
2373
2475
  ? (cmd ? `stdio command: ${cmd}` : 'stdio command: (empty)')
2374
- : `${transport || 'streamable-http'} url: ${String(a.url || '(empty)')}`;
2476
+ : `${transport || 'streamable-http'} url: ${String(maskUrlQuery(String(a.url || '')) || '(empty)')}`;
2477
+ let cur = null;
2478
+ try {
2479
+ const r = await mcp.mcpmListView();
2480
+ cur = ((r && r.rows) || []).find((x) => String(x.serverName) === server) || null;
2481
+ }
2482
+ catch { /* 拿不到现状就按"新增"的口径报:宁可少说一句,也不因此拦住操作 */ }
2483
+ let detail = nextLine;
2484
+ if (cur) {
2485
+ const curCmd = [cur.command, Array.isArray(cur.args) ? cur.args.join(' ') : cur.args]
2486
+ .map((x) => String(x ?? '').trim())
2487
+ .filter((x) => x !== '')
2488
+ .join(' ');
2489
+ const curLine = String(cur.transport) === 'stdio'
2490
+ ? `stdio command: ${curCmd || '(empty)'}`
2491
+ : `${String(cur.transport || 'streamable-http')} url: ${String(cur.url || '(empty)')}`;
2492
+ detail = `current — ${curLine}; new — ${nextLine}`;
2493
+ }
2375
2494
  return Promise.resolve({
2376
2495
  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.`,
2496
+ reason: `${cur ? 'Update' : 'Add'} MCP server「${server}」— ${detail}. A stdio server is spawned by the host and the entry persists in the config.`,
2378
2497
  });
2379
2498
  }
2380
- if (exec.name === 'memory_manager_write' || exec.name === 'memory_manager_update') {
2499
+ if (exec.name === 'memory_manager_save') {
2381
2500
  // D2:模型写规则默认需确认;设置关闭后直接放行。ask 无应答者时降级为拒绝(fail-closed),
2382
2501
  // 不在此处做任何兜底放行。
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';
2502
+ // 0.14.0 起 write/update 并成一条 upsert,reason 必须同时覆盖两种可能:卡是**执行前**
2503
+ // 弹的,此时工具还没跑,走"建"还是"改"要读一次 `rules-list` 才知道。旧实现按工具名
2504
+ // 分叉出两句,合并后只能取并集 —— 不能只留 "write",那会让一次覆盖已有内容的操作
2505
+ // 在卡上看起来像新建(危险度与门禁强度必须同向)。
2506
+ const what = 'Create or overwrite a memory under ~/.dsh/tool-management/memories';
2388
2507
  return mcp.readPluginSettings()
2389
2508
  .then((s) => (s.requireConfirmForModelRuleWrite ? { kind: 'ask', reason: what } : next()))
2390
2509
  .catch(() => ({ kind: 'ask', reason: what }));
2391
2510
  }
2392
- if (exec.name === 'subagent_manager_create' || exec.name === 'subagent_manager_update') {
2393
- // 与 `skill_manager_create` 完全对称:往 hub 里落一份新文件 / 整份重写一份现有文件。
2511
+ if (exec.name === 'scene_manager_save') {
2512
+ // 与记忆 save 的区别:**不设开关**。记忆写入有 `requireConfirmForModelRuleWrite`
2513
+ // 可以放行,场景写入没有 —— 它改的是"用户切进这个场景之后,哪些 mcp / 技能 / 人设
2514
+ // 会开、其余全部关掉",那是一次环境切换的剧本,不是一份可以事后改的文档。
2515
+ // 卡里回显场景名与这次要写的档案段(用户批准的是"这个场景进入时开这些")。
2516
+ const a = (exec && exec.arguments) || {};
2517
+ const scene = String(a.scene || '');
2518
+ const secs = ['mcp', 'skills', 'subagents'].filter((k) => a[k] !== undefined);
2519
+ const detail = secs.length
2520
+ ? `archive sections: ${secs.join(', ')}`
2521
+ : 'record only (label / description / prompt)';
2522
+ return Promise.resolve({
2523
+ kind: 'ask',
2524
+ reason: `Save scene「${scene}」— ${detail}. Entering it switches on exactly what this scene lists and turns everything else off.`,
2525
+ });
2526
+ }
2527
+ if (exec.name === 'scene_manager_switch') {
2528
+ // 与 `scene_manager_save` 同一档(都无条件问),但**问的是另一件事**:
2529
+ // save 改的是"以后进这个场景会怎样"(一份剧本),switch 改的是**现在** ——
2530
+ // 六处开关一起动(MCP 服务器级/工具级、技能来源级/技能级、人设、备注覆盖),
2531
+ // 注入范围随之收窄,并且**改写 `~/.dsh/AGENTS.md`**(本插件唯一会动那个文件的
2532
+ // 动作,覆盖前 5 代备份)。危险度只高不低,所以门禁强度不降。
2533
+ // 卡里回显动作与目标场景:用户批准的是"现在就切到 X",不是"改一下 X 的配置"。
2534
+ const a = (exec && exec.arguments) || {};
2535
+ const action = String(a.action || '');
2536
+ const scene = String(a.scene || '');
2537
+ const what = action === 'exit'
2538
+ ? 'Leave the current scene — restore the runtime switches and the AGENTS.md baseline saved on entry'
2539
+ : `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`;
2540
+ return Promise.resolve({ kind: 'ask', reason: what });
2541
+ }
2542
+ if (exec.name === 'subagent_manager_save') {
2543
+ // 与 `skill_manager_save` 完全对称:往 hub 里落一份新文件 / 整份重写一份现有文件。
2394
2544
  // 无条件问(不设开关)—— 人设是"以后每次委派都按它来"的长期资产,改错了影响的是
2395
2545
  // 后续所有子代理的行为,而不是一次输出。
2396
- // update 尤其:`subagent-update` 走 serializePersona 整份重写,改名还会连带改
2546
+ // 改分支尤其:`subagent-update` 走 serializePersona 整份重写,改名还会连带改
2397
2547
  // 场景档案里的绑定 —— 那是一次会影响环境配置的操作,不是一句文案修改。
2548
+ // 建还是改要读一次现状才知道(工具侧也是执行时才读),所以这里查一下名单:
2549
+ // 只报"新建"会让一次整份重写看起来像加了个文件,反过来也一样。
2398
2550
  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)` : ''}`;
2551
+ const name = String(a.name || '');
2552
+ let exists = false;
2553
+ try {
2554
+ const docs = await subagentService.list();
2555
+ exists = docs.some((p) => String(p && p.name) === name);
2556
+ }
2557
+ catch { /* 读不到现状就按"新建"的口径报:宁可少说一句,也不因此拦住操作 */ }
2558
+ const what = exists
2559
+ ? `Rewrite persona「${name}」${a.nextName ? ` and rename it to「${String(a.nextName)}」(scene-profile bindings follow the rename)` : ''}`
2560
+ : `Create persona「${name}」under ~/.dsh/tool-management/subagents`;
2402
2561
  return Promise.resolve({ kind: 'ask', reason: what });
2403
2562
  }
2404
2563
  // subagent_manager_run:子代理运行花真 token:默认确认(requireConfirmForModelSubagentRun !== false),可关。
@@ -23,7 +23,7 @@ import { hubPath } from '../hub.js';
23
23
  import { planOverrideCompaction } from './override-blocks.js';
24
24
  import { createMcpStateCatalog } from './state-section.js';
25
25
  import { appendBlock, buildDisableBlock, buildInsertBlock, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
26
- import { describeMaskedOutcome, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
26
+ import { describeMaskedOutcome, maskUrlQuery, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
27
27
  /**
28
28
  * 归一化「已知工具」侧车的一行值。
29
29
  *
@@ -653,19 +653,8 @@ export function createMcpManager(deps) {
653
653
  }
654
654
  return out;
655
655
  }
656
- function maskUrlQuery(url) {
657
- if (!url)
658
- return url;
659
- try {
660
- const parsed = new URL(url);
661
- if (parsed.search)
662
- parsed.search = '?<redacted>';
663
- return parsed.toString();
664
- }
665
- catch (e) {
666
- return url;
667
- }
668
- }
656
+ // `maskUrlQuery` 已挪到 ./secret-guard.js:那里是两条打码形态的唯一口径,而使用方
657
+ // 现在有两个(本文件的列表视图 + index.ts 的确认卡)。
669
658
  /** Shared UI rows: mcpmList plus notes, secrets masked unless `reveal`. */
670
659
  async function mcpmRowsWithNotes(reveal) {
671
660
  const result = await mcpmList();
@@ -69,6 +69,27 @@ export function isMaskedUrl(value) {
69
69
  return false;
70
70
  }
71
71
  }
72
+ /**
73
+ * 把 URL 的查询串换成 `<redacted>` 哨兵 —— 打码的**产出**侧,与上面的 `isMaskedUrl` 配对。
74
+ *
75
+ * 放在这里而不是 manager.ts 里:本模块是这两条打码形态的唯一口径,而使用方已经有两个
76
+ * ——MCP 列表视图(`mcpmRowsWithNotes`),以及模型调 `mcp_manager_save` 时的确认卡
77
+ * (卡里要回显"新 URL",但用户批准的是「跑这个地址」,查询串里的凭据不该明文进卡片)。
78
+ * 两处各写一份的话,改了一处就会漂成两种打码形态,而 `isMaskedUrl` 只认其中一种。
79
+ */
80
+ export function maskUrlQuery(url) {
81
+ if (!url)
82
+ return url;
83
+ try {
84
+ const parsed = new URL(url);
85
+ if (parsed.search)
86
+ parsed.search = '?' + URL_REDACTED_QUERY;
87
+ return parsed.toString();
88
+ }
89
+ catch {
90
+ return url;
91
+ }
92
+ }
72
93
  /**
73
94
  * 把表单来的 URL 收敛成"可以安全写盘"的一个(与 `resolveMaskedKv` 同方向:往旧值收)。
74
95
  *