dsh-plugin-tool-management 0.9.1 → 0.11.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 (79) hide show
  1. package/CHANGELOG.md +119 -1
  2. package/README.md +227 -201
  3. package/README_EN.md +227 -199
  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 +144 -12
  21. package/lib/client.js +8437 -5929
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -25
  24. package/lib/context-inject.js +396 -53
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +31 -3
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +940 -3765
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +20 -9
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/mcp/state-section.js +64 -21
  37. package/lib/{rules → memories}/archive-engine.js +65 -10
  38. package/lib/{rules → memories}/archive.js +6 -7
  39. package/lib/memories/constants.js +128 -0
  40. package/lib/memories/index-io.js +330 -0
  41. package/lib/memories/projection.js +280 -0
  42. package/lib/memories/service.js +686 -0
  43. package/lib/memories/snapshot.js +672 -0
  44. package/lib/ops/candidates.js +64 -0
  45. package/lib/ops/compat.js +136 -0
  46. package/lib/ops/ctx.js +9 -0
  47. package/lib/ops/memory.js +678 -0
  48. package/lib/ops/prompts.js +107 -0
  49. package/lib/ops/scene-records.js +460 -0
  50. package/lib/ops/scene-sync.js +17 -0
  51. package/lib/ops/sessions.js +603 -0
  52. package/lib/ops/trash.js +140 -0
  53. package/lib/paths.js +103 -0
  54. package/lib/prompts/preset-id.js +49 -0
  55. package/lib/{agents-md → prompts}/service.js +1 -1
  56. package/lib/request-gate.js +320 -0
  57. package/lib/scene-prompt-sync.js +4 -4
  58. package/lib/scenes/candidates.js +344 -0
  59. package/lib/{history → sessions}/bridge.js +22 -5
  60. package/lib/sessions/history.js +323 -0
  61. package/lib/{history → sessions}/tombstone.js +1 -2
  62. package/lib/{history → sessions}/workspace.js +92 -24
  63. package/lib/skills/catalog.js +6 -9
  64. package/lib/skills/core.js +79 -43
  65. package/lib/skills/readonly-discovery.js +4 -1
  66. package/lib/skills/service.js +98 -13
  67. package/lib/subagents/catalog.js +31 -15
  68. package/lib/subagents/service.js +506 -89
  69. package/lib/subagents/tools.js +29 -4
  70. package/lib/tools/deps.js +8 -0
  71. package/lib/tools/mcp.js +110 -0
  72. package/lib/tools/memory.js +87 -0
  73. package/lib/tools/prompt.js +70 -0
  74. package/lib/tools/skills.js +139 -0
  75. package/lib/tools/subagent.js +40 -0
  76. package/package.json +105 -102
  77. package/lib/agents-md/preset-id.js +0 -49
  78. package/lib/history/projcache.js +0 -335
  79. package/lib/rules/service.js +0 -2971
@@ -1,3 +1,6 @@
1
+ // 深度探针(`subagentDepthOf`)与目录判据(`catalogInjectedAt`)都不再需要:
2
+ // 2026-09-17 方案 A 之后,这两个工具都不再看会话深度 —— 委派由官方决定(默认能嵌套到 3 层),
3
+ // `catalogDepth` 只管常驻目录注入到哪些会话。
1
4
  export function text(v) {
2
5
  return [{ type: 'text', text: v }];
3
6
  }
@@ -19,7 +22,7 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
19
22
  export function defineSubagentManagerListTool(subagents) {
20
23
  return {
21
24
  name: 'subagent_manager_list',
22
- description: 'List available personas (pre-configured subagent profiles) with their descriptions. Call before subagent_manager_run.',
25
+ description: 'List available personas (pre-configured subagent profiles) with their descriptions. The same catalog is injected into your context each turn (the「可委派的子智能体」system-reminder); call this tool before subagent_manager_run for the full, always-current list.',
23
26
  parameters: {},
24
27
  output: {
25
28
  schema: { type: 'string' },
@@ -27,6 +30,10 @@ export function defineSubagentManagerListTool(subagents) {
27
30
  },
28
31
  async execute(_args, exec) {
29
32
  const { allowed, reason } = await filterBySceneBinding(await subagents.list(), await subagents.sceneLists());
33
+ // 这里**不再按深度过滤**(2026-09-17 用户裁定,方案 A):`catalogDepth` 只决定常驻目录
34
+ // 注入到哪些会话,与"能不能委派"无关 —— 委派由官方决定(`dsh-tool-subagent` 默认能嵌套
35
+ // 到 3 层)。此前按深度过滤等于把可用人设藏起来:目录不注入时模型只能靠这个工具查,
36
+ // 而工具又不列全,结果是"明明能委派却查不到人设"。
30
37
  const lines = allowed.map((p) => '- ' + p.name + ' — ' + (p.description || '(无描述)'));
31
38
  let notice = '';
32
39
  try {
@@ -42,10 +49,21 @@ export function defineSubagentManagerListTool(subagents) {
42
49
  export function defineSubagentManagerRunTool(subagents) {
43
50
  return {
44
51
  name: 'subagent_manager_run',
45
- description: 'Run a named persona as a one-shot subagent: it gets the persona as its own system prompt, works on `task` in a fresh context, and returns only its final output. Stateless — it does not see this conversation. Expensive: starts a fresh model session, so use it only for self-contained work.',
52
+ // 描述按「是什么 → 两种模式 → 何时用(含与官方两个委派工具的分界)→ 何时改用别的」组织。
53
+ // 2026-09-17 补了**分界规则**与 `inherit`:此前上下文里虽然注入了人设目录与「用
54
+ // subagent_manager_run 执行」,但没有任何一句话说明它和官方 `subagent` / `subagent_fork`
55
+ // 何时该用谁 —— 本机实测(session-ee722e23)模型读完 README 后选了官方 `subagent_fork`
56
+ // (fork 能继承已读内容、不必复述)。现在人设通道也有 fork(`inherit`),分界只剩
57
+ // 「要不要后台跑」一件事,所以规则能写成"贴合人设的一律走这里"。
58
+ //
59
+ // 「何时不用」那一段保留(2026-09-17,用户采纳的四条里的第 6 条):参照 Claude Code 的
60
+ // Agent 工具(`AgentTool/prompt.ts:232-240`),把"不该用"写成**带替代工具**的具体清单
61
+ // (具体路径→Read;找定义→Grep/Glob),比笼统说"这个很贵"有用得多。
62
+ description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output.\n\nTwo modes: by default the child starts fresh — it cannot see this conversation, so `task` must be self-contained. With `inherit: true` the child is seeded with this conversation\'s finished turns (the same mechanism as the host\'s `subagent_fork`), so `task` only states what is new — use it for follow-ups on work already completed. Only **finished** turns are inherited: a delegation made during the current turn inherits nothing from that turn, so a mid-turn hand-off still needs a self-contained `task`.\n\nWrite `task` as the goal plus the context it needs — do not prescribe method or output format: those belong to the persona.\n\nWhen to use: work that matches one of the personas in the「可委派的子智能体」system-reminder injected into your context (or from subagent_manager_list) — a review, a focused investigation, a piece of writing — where the detail does not belong in your own context. Work that matches a persona belongs here, not in the host\'s `subagent` / `subagent_fork`: those take no persona. Use them only when no persona fits, or when you need a background run (this tool waits for the result).\n\nWhen NOT to use: reading a specific file (use Read), finding a definition (use Grep/Glob), or touching two or three files (use Read directly).',
46
63
  parameters: {
47
64
  agent: { type: 'string', required: true, description: 'Persona name from subagent_manager_list.' },
48
- task: { type: 'string', required: true, description: 'Self-contained task, with all context the subagent needs.' },
65
+ task: { type: 'string', required: true, description: 'The task for the subagent: the goal plus the context it needs. Self-contained by default; with inherit: true it only needs to state what is new. Leave method and output format to the persona.' },
66
+ inherit: { type: 'boolean', description: 'Let the subagent inherit this conversation\'s finished turns, like the host\'s subagent_fork (default false = a fresh child that cannot see this conversation). Only finished turns are inherited — a delegation made mid-turn cannot pass the current turn\'s content, so write `task` as if it were self-contained.' },
49
67
  },
50
68
  output: {
51
69
  schema: { type: 'string' },
@@ -54,6 +72,7 @@ export function defineSubagentManagerRunTool(subagents) {
54
72
  async execute(args, exec) {
55
73
  const name = String((args && args.agent) || '').trim();
56
74
  const task = String((args && args.task) || '').trim();
75
+ const inherit = args && args.inherit === true;
57
76
  if (!task)
58
77
  throw new Error('task 不能为空');
59
78
  // 官方 SubagentStartRequest.parent 必填:调用方 agent 缺失时给结构化错误,不把 undefined 透传下去。
@@ -66,12 +85,18 @@ export function defineSubagentManagerRunTool(subagents) {
66
85
  const available = allowed.map((p) => p.name).join('、') || '(无)';
67
86
  throw new Error('人设不可用: ' + name + (reason ? '(' + reason + ')' : '(可用: ' + available + ')'));
68
87
  }
88
+ // 这里**刻意没有**"深度不够就拒绝"的检查(2026-09-17 用户裁定,方案 A)。
89
+ // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
90
+ // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验,所以子代理本来
91
+ // 就能继续嵌套。那个检查的唯一净效果是让**我们的**工具比官方严 —— 同一个子会话里官方
92
+ // 工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了(官方工具照样能)。
93
+ // `catalogDepth` 现在只管"目录注入到哪些会话",不参与委派可行性判断。
69
94
  // 按当前会话的 Agent 预设算工具限制:判断不了当前预设、或名单里的工具已经不存在时,
70
95
  // 结论里会带一句实话,跟着结果一起返回——不静默改变限制的强度。
71
96
  const decision = typeof subagents.toolFilterFor === 'function'
72
97
  ? await subagents.toolFilterFor(persona, exec.agent && exec.agent.ctx)
73
98
  : undefined;
74
- const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter);
99
+ const r = await subagents.runSerial(exec.agent, persona, task, exec.signal, decision === undefined ? undefined : decision.filter, inherit);
75
100
  const prefix = r.stopReason && r.stopReason !== 'completed' ? `[stopReason: ${r.stopReason}]\n` : '';
76
101
  const note = decision && decision.note ? `⚠ ${decision.note}\n\n` : '';
77
102
  return note + prefix + r.text;
@@ -0,0 +1,8 @@
1
+ // model 工具(ctx.tools.register + defineTool)各域共用的依赖与 helper。
2
+ //
3
+ // 为什么单独一个文件:五个域的工具都要同一批依赖(defineTool 包装、注册出口、场景锁定
4
+ // 守卫、注入边界提示),各写一份会漂移。
5
+ /** 每个工具 output.render 的统一出口:把字符串包成宿主要的 content 数组。 */
6
+ export function text(value) {
7
+ return [{ type: 'text', text: value }];
8
+ }
@@ -0,0 +1,110 @@
1
+ // MCP 域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // mcp_manager_list / mcp_manager_set_enabled / mcp_manager_restart / mcp_manager_add。
3
+ import { DEFAULT_MCP_NOTE_MAX_LENGTH, normalizeMcpNote } from '../mcp/state-section.js';
4
+ import { text } from './deps.js';
5
+ export function buildMcpTools(deps) {
6
+ const { defineTool, register } = deps;
7
+ register(defineTool({
8
+ name: 'mcp_manager_list',
9
+ description: 'List configured MCP servers (level, enabled state, live loader status, tool count excluding switched-off tools, note). Read a server\'s note before choosing it. The same list is injected into your context each turn (the「本机 MCP 服务器的当前状态」system-reminder); this tool is the raw view — all=true includes disabled servers. Defaults to enabled servers; pass all=true for every configured server.',
10
+ parameters: {
11
+ all: { type: 'boolean', description: 'Include disabled servers (default false).' },
12
+ },
13
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
14
+ async execute(args, exec) {
15
+ const showAll = Boolean(args && args.all === true);
16
+ // 必须走 mcpmListView()(= mcpmRowsWithNotes(false)):备注在这里合入,且未打码的
17
+ // url/headers/env 已被打码。备注是本插件 MCP 这一项的真正价值(用户写给未来模型的
18
+ // 决策提示,如「A 挂了改用 B」),而它只走注入通道 —— 压制型预设下不注入时,
19
+ // 这里就是唯一的读取路径。
20
+ const r = await deps.mcpmListView();
21
+ if (!r.ok)
22
+ throw new Error(r.error);
23
+ const all = r.rows || [];
24
+ // 默认只列「已启用」的(用户裁定 2026-09-16,与技能 / 记忆列表同口径):停用的服务器
25
+ // 模型调不到,列出来只是噪音。表头给出全量口径,所以不会变成盲区。
26
+ const shown = showAll ? all : all.filter((x) => !x.disabled);
27
+ const summary = shown.map((x) => {
28
+ const note = normalizeMcpNote(x.notes, DEFAULT_MCP_NOTE_MAX_LENGTH);
29
+ // 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
30
+ // 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
31
+ const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
32
+ return x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
33
+ (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
34
+ (typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
35
+ (note ? ' | user-hint:' + note : '');
36
+ });
37
+ const header = 'MCP servers: ' + (showAll
38
+ ? all.length + ' configured'
39
+ : shown.length + ' enabled of ' + all.length + ' configured' +
40
+ (shown.length === all.length ? '' : ' (pass all=true for every configured server)')) + '\n';
41
+ // 注入边界:压制型预设(persona complete / 关闭运行时上下文,如极简)下本插件
42
+ // 默认不注入 —— 模型只有 mcp__* 的工具名与参数,没有服务级信息。不说明的话,
43
+ // 模型会把「能调用这些工具」当成「已经知道有哪些 server、用户给它们写了什么备注」。
44
+ // 提示本身不点名任何工具,所以挂在哪个发现型工具上都不会出现循环指引。
45
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
46
+ return header + (summary.join('\n') || '(none)') + notice;
47
+ },
48
+ }));
49
+ register(defineTool({
50
+ name: 'mcp_manager_set_enabled',
51
+ description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
52
+ parameters: {
53
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
54
+ level: { type: 'string', required: true, description: 'project or global.' },
55
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
56
+ },
57
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
58
+ async execute(args) {
59
+ const blocked = await deps.lockedSceneGuard();
60
+ if (blocked)
61
+ throw new Error(blocked);
62
+ const r = await deps.mcpmSetEnabled({ id: args.id, level: args.level, enabled: args.enabled });
63
+ if (!r.ok)
64
+ throw new Error(r.error);
65
+ // 场景未锁定时,页面 / 模型改的开关都要落进当前场景的档案(与 handlers 层同一套同步)。
66
+ const syncErr = await deps.syncSwitchToScene('mcpm-set-enabled', args);
67
+ return 'OK: ' + args.id + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
68
+ (syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
69
+ },
70
+ }));
71
+ register(defineTool({
72
+ name: 'mcp_manager_restart',
73
+ description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
74
+ parameters: {
75
+ id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
76
+ level: { type: 'string', required: true, description: 'project or global.' },
77
+ },
78
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
79
+ async execute(args) {
80
+ const r = await deps.mcpmRestart({ id: args.id, level: args.level });
81
+ if (!r.ok)
82
+ throw new Error(r.error);
83
+ return 'OK: ' + args.id + ' restarted';
84
+ },
85
+ }));
86
+ register(defineTool({
87
+ name: 'mcp_manager_add',
88
+ description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
89
+ parameters: {
90
+ serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
91
+ transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
92
+ url: { type: 'string', description: 'Server URL (required for streamable-http).' },
93
+ command: { type: 'string', description: 'Executable (required for stdio).' },
94
+ args: { type: 'string', description: 'Arguments, space separated (stdio).' },
95
+ headers: { type: 'string', description: 'Extra headers as key=value lines (streamable-http).' },
96
+ env: { type: 'string', description: 'Extra env vars as key=value lines (stdio).' },
97
+ level: { type: 'string', description: 'project or global (default project).' },
98
+ },
99
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
100
+ async execute(args) {
101
+ const blocked = await deps.lockedSceneGuard();
102
+ if (blocked)
103
+ throw new Error(blocked);
104
+ const r = await deps.mcpmAdd(args);
105
+ if (!r.ok)
106
+ throw new Error(r.error);
107
+ return 'OK: added ' + r.row.id + ' at ' + r.row.level;
108
+ },
109
+ }));
110
+ }
@@ -0,0 +1,87 @@
1
+ // 记忆(rules)域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // memory_manager_list / memory_manager_read / memory_manager_write。
3
+ //
4
+ // 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于查询/编辑规则
5
+ // 本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
6
+ // 路径锚点:$DSH_HOME/tool-management/memories/<场景>/…(场景 `global` = 界面「全局」)。
7
+ import { text } from './deps.js';
8
+ export function buildMemoryTools(deps) {
9
+ const { defineTool, register } = deps;
10
+ register(defineTool({
11
+ name: 'memory_manager_list',
12
+ description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). The ones actually injected are carried in your context each turn (the「本机当前的场景和记忆」system-reminder); use this tool to find ids/paths or to see entries that are off. Defaults to the memories that will actually be injected; pass all=true for every entry.',
13
+ parameters: {
14
+ group: { type: 'string', description: 'Optional scene filter.' },
15
+ all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
16
+ },
17
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
18
+ async execute(args, exec) {
19
+ const showAll = Boolean(args && args.all === true);
20
+ const r = await deps.rulesOps['rules-list'](args);
21
+ if (!r || r.ok === false)
22
+ throw new Error((r && r.error) || '读取规则失败');
23
+ // 「会注入」的判定与记忆段同源(renderSceneMemory):global 场景恒常注入,其余场景要
24
+ // 在 index.active 里,再叠加单条 enabled 与同名 bundle 的 shadowed。默认只列这些,
25
+ // 全列一次会把没启用的条目也算进上下文(用户裁定 2026-09-16,与技能列表同口径)。
26
+ const sceneOn = (group) => group === 'global' ||
27
+ (group !== '' && (r.activeMode === 'all' || String(r.activeScene || '') === group));
28
+ const stateOf = (x) => {
29
+ if (x.shadowed === true)
30
+ return { injects: false, label: '被同名覆盖' };
31
+ if (x.enabled === false)
32
+ return { injects: false, label: '已停用' };
33
+ if (!sceneOn(String(x.group || '')))
34
+ return { injects: false, label: '场景未启用' };
35
+ return { injects: true, label: '已启用' };
36
+ };
37
+ const rows = (r.rules || []).map((x) => ({ x, ...stateOf(x) }));
38
+ const shown = showAll ? rows : rows.filter((row) => row.injects);
39
+ const lines = shown.map(({ x, label }) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + label +
40
+ (x.description ? ' — ' + x.description : '')));
41
+ const scenes = (r.scenes || []).map((s) => (s.label || s.name) + (s.active ? '(启用)' : '(未启用)')).join('、');
42
+ const header = '记忆:' + (showAll
43
+ ? rows.length + ' 条'
44
+ : shown.length + ' 条会注入 / 共 ' + rows.length + ' 条' +
45
+ (shown.length === rows.length ? '' : '(传 all=true 看全部)')) + '\n';
46
+ // 注入边界:压制型预设(persona complete / 关闭运行时上下文)下本插件默认不注入,
47
+ // 此时列出的记忆**不在**模型上下文里。必须说出来,否则模型会假设自己已经看到正文。
48
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
49
+ return header + (lines.join('\n') || '(无记忆)') +
50
+ '\n场景:' + (scenes || '(无)') + (r.activeMode === 'all' ? '(默认全部启用)' : '(已收窄)') + notice;
51
+ },
52
+ }));
53
+ register(defineTool({
54
+ name: 'memory_manager_read',
55
+ description: 'Read the full body of one memory under ~/.dsh/tool-management/memories. Call it only for memories that are not already in your context (disabled, unassigned to a scene, or dropped by the injection budget).',
56
+ parameters: {
57
+ id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
58
+ },
59
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
60
+ async execute(args) {
61
+ const r = await deps.rulesOps['rules-read'](args);
62
+ if (!r || r.ok === false)
63
+ throw new Error((r && r.error) || '读取规则失败');
64
+ return '# ' + r.rule.id + '\n\n' + (r.rule.body || '');
65
+ },
66
+ }));
67
+ register(defineTool({
68
+ name: 'memory_manager_write',
69
+ description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene).',
70
+ parameters: {
71
+ group: { type: 'string', required: true, description: 'Scene name (no path separators or < > : " | ? *); `global` = the always-on scene.' },
72
+ name: { type: 'string', required: true, description: 'Memory name = .md file name without extension; <=64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
73
+ description: { type: 'string', required: true, description: 'One-sentence description (<=500 chars).' },
74
+ body: { type: 'string', required: true, description: 'Markdown body (<=256 KiB).' },
75
+ },
76
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
77
+ async execute(args) {
78
+ const blocked = await deps.lockedSceneGuard();
79
+ if (blocked)
80
+ throw new Error(blocked);
81
+ const r = await deps.rulesOps['rules-create'](args);
82
+ if (!r || r.ok === false)
83
+ throw new Error((r && r.error) || '创建规则失败');
84
+ return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)';
85
+ },
86
+ }));
87
+ }
@@ -0,0 +1,70 @@
1
+ // AGENTS.md 预设域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // prompt_manager_list / prompt_manager_apply。
3
+ //
4
+ // 模型可查、可切,**不能造 / 不能删** —— 避免模型乱删用户预设。
5
+ import { join } from 'node:path';
6
+ import { text } from './deps.js';
7
+ export function buildPromptTools(deps) {
8
+ const { defineTool, register } = deps;
9
+ register(defineTool({
10
+ name: 'prompt_manager_list',
11
+ description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. The preset in effect right now is injected into your context each turn (the「本机提示词」system-reminder, whose「来源:」line names the file). Defaults to the preset currently in effect; pass all=true for the whole library.',
12
+ parameters: {
13
+ all: { type: 'boolean', description: 'Include inactive presets (default false).' },
14
+ },
15
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
16
+ async execute(args, exec) {
17
+ const showAll = Boolean(args && args.all === true);
18
+ const r = await deps.promptsService.list();
19
+ if (!r.ok)
20
+ throw new Error(r.error);
21
+ const all = r.presets || [];
22
+ // 默认只列「当前这份 ~/.dsh/AGENTS.md 是从哪个预设来的」(用户裁定 2026-09-16)。
23
+ // 判定分两级:内容逐字节相同 = 生效;否则看服务层记下的「最近一次应用」——
24
+ // 用户手改过全局文件时,第二级仍能给出答案,不必退化成全列(那会白烧上下文)。
25
+ const active = all.filter((p) => p.active === true);
26
+ const fallback = active.length ? active : all.filter((p) => p.lastApplied === true);
27
+ const shown = showAll ? all : fallback;
28
+ // 路径这一列是「名单 → 全文」的闭环:预设正文既不进提示词、也没有读取工具,
29
+ // 给出文件路径让模型用宿主的文件工具读,比再造一个 read 工具省一条常驻 schema。
30
+ const summary = shown.map((p) => {
31
+ const mark = p.active ? ' [active]'
32
+ : p.lastApplied ? ' [last applied — ~/.dsh/AGENTS.md has changed since]'
33
+ : '';
34
+ return p.id + mark + ' | ' + join(deps.promptsDir, p.id, 'AGENTS.md');
35
+ });
36
+ const header = 'AGENTS.md presets: ' + (showAll
37
+ ? all.length + ' in the library'
38
+ : fallback.length
39
+ ? fallback.length + (active.length ? ' active' : ' last applied') + ' of ' + all.length +
40
+ (fallback.length === all.length ? '' : ' (pass all=true for the whole library)')
41
+ : '0 active of ' + all.length + ' (pass all=true for the whole library)') + '\n';
42
+ // 同上:AGENTS.md 由官方 dsh-agent-instructions 行承载(极简没挂这一行),
43
+ // 这一行不在时文件内容不会进上下文。
44
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
45
+ return header + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
46
+ },
47
+ }));
48
+ register(defineTool({
49
+ name: 'prompt_manager_apply',
50
+ description: 'Apply one AGENTS.md preset by id; effective on the next turn. If a scene currently drives the baseline, this does NOT refuse: it rebinds that scene\'s prompt binding to the preset and re-syncs the scene, so ~/.dsh/AGENTS.md ends up holding the scene\'s (new) binding — tell the user which scene was rebound. Otherwise the preset is written to ~/.dsh/AGENTS.md directly. A locked scene blocks the call.',
51
+ parameters: {
52
+ id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
53
+ },
54
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
55
+ async execute(args) {
56
+ const blocked = await deps.lockedSceneGuard();
57
+ if (blocked)
58
+ throw new Error(blocked);
59
+ const r = await deps.applyPresetGuarded(args.id);
60
+ if (!r.ok)
61
+ throw new Error(r.error);
62
+ // 场景驱动时写的是「场景绑定」,回执必须说清改的是哪个场景 —— 只说「已应用到
63
+ // AGENTS.md」会让模型对用户谎报(用户不知道自己的场景绑定被换掉了)。
64
+ if (r.viaScene) {
65
+ return 'OK: preset ' + args.id + ' is now the prompt binding of scene 「' + (r.scene || '') + '」 and the scene was re-synced (effective next turn; ~/.dsh/AGENTS.md now carries that scene\'s binding).';
66
+ }
67
+ return 'OK: preset ' + args.id + ' applied to ~/.dsh/AGENTS.md (next session; current session unchanged' + (r.backedUp ? '; previous backed up to __last-applied__' : '') + ')';
68
+ },
69
+ }));
70
+ }
@@ -0,0 +1,139 @@
1
+ // 技能域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // skill_manager_list / skill_manager_set_enabled / skill_manager_create。
3
+ import { text } from './deps.js';
4
+ export function buildSkillTools(deps) {
5
+ const { defineTool, register } = deps;
6
+ // Skill-facing tools: list, toggle, create. create is gated by the
7
+ // tools/pre-execute hook below (the model must ask before writing files).
8
+ register(defineTool({
9
+ name: 'skill_manager_list',
10
+ description: 'List DSH skills with enabled state, effective/shadowed status and source file path. The injected「本机技能目录」system-reminder carries callable skills and summaries only; use this tool to get a source file path (read the file for the full body) and to see entries that are off. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
11
+ parameters: {
12
+ all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
13
+ },
14
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
15
+ async execute(args, exec) {
16
+ const showAll = Boolean(args && args.all === true);
17
+ const r = await deps.skillsOps['skill-state']({});
18
+ if (!r || r.ok === false)
19
+ throw new Error((r && r.error) || 'skill-state failed');
20
+ const data = r.data || {};
21
+ // 同名(canonical = declaredName || name)跨根重复时只有**一份**生效,排序规则是
22
+ // 「同名首选 > 来源优先级 rank」,**与启用状态无关**(core 的 groupLoadableSkillsByName)。
23
+ // 于是「启用被覆盖的那份」是静默无效的:工具会回 OK,技能却依然不可用 —— 用户实测
24
+ // 踩到的正是这个坑(同一技能在 dsh/agents/claude/custom 各有一份,启错根等于没启)。
25
+ // 这里把生效/被覆盖如实标出来,让模型(和人)不必猜。
26
+ //
27
+ // 默认只列启用的(2026-09-16 用户裁定):本机 36 条里只有 1 条启用,全列一次约 4.3k
28
+ // 字符且永久留在 transcript。表头始终给出全量口径(多少条 / 多少同名组),所以
29
+ // 「还有没列出来的」不会变成盲区 —— 要看就传 all=true。
30
+ const rows = [];
31
+ const counts = new Map();
32
+ for (const root of data.roots || []) {
33
+ for (const skill of root.skills || []) {
34
+ const key = String(skill.declaredName || skill.name || '');
35
+ counts.set(key, (counts.get(key) || 0) + 1);
36
+ // `enabled` 只写在「胜出者」上(core 的 markWinners);影子副本与不可加载的技能
37
+ // 没有这个字段。原先直接读 skill.enabled,这些技能一律被报成 disabled ——
38
+ // 这里改用与界面同口径的兜底推导(client 的 isSkillEnabled)。
39
+ const enabled = skill.enabled !== undefined
40
+ ? skill.enabled === true
41
+ : (skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false);
42
+ const marks = [];
43
+ if (skill.shadowedBy && skill.shadowedBy.root)
44
+ marks.push('shadowed by ' + skill.shadowedBy.root);
45
+ if (skill.preferred === true)
46
+ marks.push('preferred');
47
+ rows.push({
48
+ root: String(root.key || ''),
49
+ enabled,
50
+ text: (skill.name || skill.declaredName || '') + ' | ' + (root.key || '') + ' | ' + (enabled ? 'enabled' : 'disabled') +
51
+ (marks.length ? ' | ' + marks.join(' ') : '') +
52
+ (skill.path ? ' | ' + skill.path : ''),
53
+ });
54
+ }
55
+ }
56
+ const dupGroups = [...counts.values()].filter((n) => n > 1).length;
57
+ const shown = showAll ? rows : rows.filter((row) => row.enabled);
58
+ const header = 'Skills: ' + (showAll
59
+ ? rows.length + ' entries'
60
+ : shown.length + ' enabled of ' + rows.length + ' entries') +
61
+ (dupGroups ? ' · ' + dupGroups + ' duplicated names' : '') +
62
+ (showAll || shown.length === rows.length ? '' : ' (pass all=true to include disabled and shadowed entries)') + '\n';
63
+ // 注入边界:预设没挂 dsh-tool-skill(如极简)时官方技能目录不在,本插件的
64
+ // 「技能目录」注入域会在开关打开时兜底(见上面的注入通道);两者都没有时模型看到的
65
+ // 只是这份名单 —— 说清边界,别让它以为上下文里已经有技能正文(正文用路径读)。
66
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
67
+ return header + (shown.map((row) => row.text).join('\n') || '(none)') + notice;
68
+ },
69
+ }));
70
+ register(defineTool({
71
+ name: 'skill_manager_set_enabled',
72
+ description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect.',
73
+ parameters: {
74
+ name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
75
+ enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
76
+ root: { type: 'string', description: 'Source root key (dsh/agents/codex/claude or a project key); default dsh.' },
77
+ },
78
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
79
+ async execute(args) {
80
+ const blocked = await deps.lockedSceneGuard();
81
+ if (blocked)
82
+ throw new Error(blocked);
83
+ const root = String(args.root || 'dsh');
84
+ const op = args.enabled ? 'skill-enable' : 'skill-disable';
85
+ const r = await deps.skillsOps[op]({ name: args.name, root });
86
+ if (!r || r.ok === false)
87
+ throw new Error((r && r.error) || 'skill toggle failed');
88
+ // 场景未锁定时同步进当前场景档案(与 handlers 层同一套同步)。
89
+ const syncErr = await deps.syncSwitchToScene(op, { name: args.name, root, enabled: args.enabled === true });
90
+ // 静默无效是这个工具最容易骗人的地方:同名胜负只由「首选 > 来源优先级」决定,
91
+ // 与启用状态无关。对影子副本操作会返回 OK,但技能依然不可用。如实说一句。
92
+ let shadowedBy = '';
93
+ try {
94
+ const state = await deps.skillsOps['skill-state']({});
95
+ for (const rt of (state && state.data && state.data.roots) || []) {
96
+ for (const skill of rt.skills || []) {
97
+ if (String(skill.declaredName || skill.name || '') !== String(args.name))
98
+ continue;
99
+ if (String(rt.key || '') !== root)
100
+ continue;
101
+ if (skill.shadowedBy && skill.shadowedBy.root)
102
+ shadowedBy = String(skill.shadowedBy.root);
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ shadowedBy = '';
108
+ }
109
+ const warn = shadowedBy
110
+ ? ' — BUT this copy is shadowed by the same-name skill in "' + shadowedBy + '", so it stays inactive: enable that copy instead, or make this root the preferred copy in the panel'
111
+ : '';
112
+ const sceneWarn = syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '';
113
+ return 'OK: ' + args.name + ' now ' + (args.enabled ? 'enabled' : 'disabled') + warn + sceneWarn;
114
+ },
115
+ }));
116
+ register(defineTool({
117
+ name: 'skill_manager_create',
118
+ // 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
119
+ // tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
120
+ // root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
121
+ description: 'Create a new DSH skill under DSH_HOME/tool-management/skills. Use only when the user explicitly asks to create or save a reusable skill.',
122
+ parameters: {
123
+ name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
124
+ description: { type: 'string', required: true, description: 'A concise routing description for when to use the skill.' },
125
+ body: { type: 'string', required: true, description: 'Markdown instructions that form the skill body.' },
126
+ },
127
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
128
+ async execute(args) {
129
+ const blocked = await deps.lockedSceneGuard();
130
+ if (blocked)
131
+ throw new Error(blocked);
132
+ const r = await deps.skillsOps['skill-create']({ name: args.name, description: args.description, body: args.body });
133
+ if (!r || r.ok === false)
134
+ throw new Error((r && r.error) || 'skill create failed');
135
+ const data = r.data || {};
136
+ return 'Created DSH skill ' + (data.name || args.name) + ' at ' + (data.path || '(unknown)');
137
+ },
138
+ }));
139
+ }
@@ -0,0 +1,40 @@
1
+ // 子智能体域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
+ // subagent_manager_list / subagent_manager_run。
3
+ //
4
+ // exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
5
+ //
6
+ // 两个工具**各自** try/catch:一个注册失败不该把另一个也带走,而且失败必须说得出
7
+ // 「是哪一个没注册上」——只打一行日志时,模型侧只会「查无此工具」、界面毫无痕迹。
8
+ import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from '../subagents/tools.js';
9
+ export function buildSubagentTools(deps) {
10
+ const { defineTool, register } = deps;
11
+ const recordFailure = (name, e) => {
12
+ deps.failures.list.push({ name, reason: deps.message(e) });
13
+ if (deps.failures.logged)
14
+ return;
15
+ deps.failures.logged = true;
16
+ console.error('[dsh-plugin-tool-management] subagent tool registration failed:', name, deps.message(e));
17
+ };
18
+ try {
19
+ // 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
20
+ // 裸 register 会把未编译的参数声明直接发给模型 API。
21
+ register(defineTool(defineSubagentManagerListTool({
22
+ list: () => deps.subagentService.list(),
23
+ sceneLists: deps.sceneLists,
24
+ // 与其余发现型工具同口径:压制型预设下不注入时,人设目录不在模型上下文里,
25
+ // 只有工具可用。挂上边界提示,模型才不会把「看不到人设」当成「没有人设」。
26
+ noticeFor: (exec) => deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions()),
27
+ })));
28
+ }
29
+ catch (e) {
30
+ recordFailure('subagent_manager_list', e);
31
+ }
32
+ try {
33
+ // 工具限制按**当前会话的 Agent 预设**下发:父会话跑在哪个预设,就用那个预设那一行的
34
+ // 白/黑名单(`decideToolFilter`),名单里已消失的工具名会被丢掉并在结果里如实说明。
35
+ register(defineTool(defineSubagentManagerRunTool({ ...deps.subagentService, sceneLists: deps.sceneLists, toolFilterFor: deps.toolFilterFor })));
36
+ }
37
+ catch (e) {
38
+ recordFailure('subagent_manager_run', e);
39
+ }
40
+ }