dsh-plugin-tool-management 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +11 -3
- package/README_EN.md +9 -3
- package/docs/update.md +46 -0
- package/lib/client.js +602 -51
- package/lib/context-inject.js +98 -28
- package/lib/index.js +305 -12
- package/lib/mcp/manager.js +115 -57
- package/lib/mcp/state-section.js +5 -2
- package/lib/memories/archive-engine.js +199 -0
- package/lib/memories/constants.js +1 -1
- package/lib/memories/snapshot.js +10 -0
- package/lib/op-registry.js +273 -0
- package/lib/ops/compat.js +15 -2
- package/lib/ops/state-doctor.js +115 -0
- package/lib/request-gate.js +21 -78
- package/lib/scene-settings.js +17 -0
- package/lib/skills/catalog.js +9 -4
- package/lib/skills/core.js +9 -0
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/tools.js +9 -2
- package/lib/tools/mcp.js +3 -3
- package/lib/tools/memory.js +105 -5
- package/lib/tools/prompt.js +2 -2
- package/lib/tools/skills.js +81 -3
- package/lib/tools/subagent.js +117 -2
- package/lib/tools/table.js +101 -0
- package/package.json +108 -108
package/lib/skills/catalog.js
CHANGED
|
@@ -41,7 +41,7 @@ function isEnabled(row) {
|
|
|
41
41
|
* 返回 `''` 表示没有可注入的技能 —— 没装技能的用户零 token 成本。
|
|
42
42
|
* 名字按字典序排序:底层目录枚举顺序变化时文本仍逐字节稳定(前缀缓存契约)。
|
|
43
43
|
*/
|
|
44
|
-
export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES, maxDescription = SKILL_CATALOG_DESCRIPTION_MAX_LENGTH) {
|
|
44
|
+
export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES, maxDescription = SKILL_CATALOG_DESCRIPTION_MAX_LENGTH, listToolVisible = true) {
|
|
45
45
|
const roots = (data && typeof data === 'object' ? data.roots : undefined) ?? [];
|
|
46
46
|
const byName = new Map();
|
|
47
47
|
if (Array.isArray(roots)) {
|
|
@@ -77,8 +77,13 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
|
|
|
77
77
|
// (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME 的 `how` 行)—— 一处内容一个出处。
|
|
78
78
|
const out = [...lines];
|
|
79
79
|
const hidden = sorted.length - shown.length;
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// 查询工具被关掉(兼容页的模型工具表)时只说"还有几个":点名一个模型手里没有的工具,
|
|
81
|
+
// 它只会去猜名字。数量照报 —— 那是实话,与有没有出口无关。
|
|
82
|
+
if (hidden > 0) {
|
|
83
|
+
out.push('', listToolVisible
|
|
84
|
+
? `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`
|
|
85
|
+
: `(另有 ${hidden} 个未列出。)`);
|
|
86
|
+
}
|
|
82
87
|
return out.join('\n');
|
|
83
88
|
}
|
|
84
89
|
/**
|
|
@@ -95,7 +100,7 @@ export function createSkillCatalog(deps, opts = {}) {
|
|
|
95
100
|
try {
|
|
96
101
|
const result = await deps.state();
|
|
97
102
|
if (result && result.ok !== false) {
|
|
98
|
-
value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription);
|
|
103
|
+
value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription, deps.listToolVisible ? deps.listToolVisible() : true);
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
catch { /* 保留上一次的值;首次失败则维持 '' */ }
|
package/lib/skills/core.js
CHANGED
|
@@ -2840,6 +2840,15 @@ export async function getProviderSkill(candidate, options = {}) {
|
|
|
2840
2840
|
typeof options.cwd === "string") {
|
|
2841
2841
|
root = (await projectRoots([options.cwd])).find((item) => item.key === locator.rootKey);
|
|
2842
2842
|
}
|
|
2843
|
+
// 自定义技能目录不住在 `userRoots()` 里,它住在状态文件的 `customRoots` 里,所以
|
|
2844
|
+
// `rootByKey()` 恒取不到。少了这一支就是「列得出、调不动」:`state()` 扫的是
|
|
2845
|
+
// `userRoots() + customRootsFromState()`,官方 catalog 因此把目录里的技能列给模型,
|
|
2846
|
+
// 而模型真去 `skill` 工具取正文时这里返回 undefined —— 症状是
|
|
2847
|
+
// `skill "brainstorming" is unknown or no longer available`(用户 2026-09-22 报的)。
|
|
2848
|
+
if (!root &&
|
|
2849
|
+
CUSTOM_ROOT_KEY_RE.test(locator.rootKey)) {
|
|
2850
|
+
root = customRootsFromState((await readManagerState()).state).find((item) => item.key === locator.rootKey);
|
|
2851
|
+
}
|
|
2843
2852
|
if (!root)
|
|
2844
2853
|
return undefined;
|
|
2845
2854
|
try {
|
package/lib/subagents/catalog.js
CHANGED
|
@@ -11,7 +11,7 @@ import { filterBySceneBinding } from './tools.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500;
|
|
13
13
|
/** 段里最多列几个人设;超出部分只报数量,让模型自己去调 subagent_manager_list。 */
|
|
14
|
-
export const DEFAULT_CATALOG_MAX_ENTRIES =
|
|
14
|
+
export const DEFAULT_CATALOG_MAX_ENTRIES = 50;
|
|
15
15
|
/** 无描述时的占位,与 `subagent_manager_list` 工具的输出保持同一口径。 */
|
|
16
16
|
const NO_DESCRIPTION = '(无描述)';
|
|
17
17
|
/**
|
|
@@ -43,7 +43,7 @@ export function catalogDescription(value, maxLength) {
|
|
|
43
43
|
* (`- **名字** — 描述`),"这是什么 + 该拿它做什么"整句交给注入通道的引导语。于是这里
|
|
44
44
|
* 既没有标题也没有"可委派给下列子智能体"那句:一处内容只有一个出处。
|
|
45
45
|
*/
|
|
46
|
-
export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_ENTRIES, maxDescription = DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH) {
|
|
46
|
+
export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_ENTRIES, maxDescription = DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH, listToolVisible = true) {
|
|
47
47
|
if (!allowed.length)
|
|
48
48
|
return '';
|
|
49
49
|
const sorted = [...allowed].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
@@ -58,8 +58,12 @@ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_
|
|
|
58
58
|
// 查询工具**只在真被 40 条上限截掉时**才出现:常态下不提,省常驻字符,也免得模型为了
|
|
59
59
|
// 「确认一遍」去调它(用户裁定:没列出来的就是当前不想要的)。与 MCP 状态段的
|
|
60
60
|
// `(另有 N 台未列出。)` 同一句式,但这里多给一个出口——不给人设就真的找不回来了。
|
|
61
|
-
if (hidden > 0)
|
|
62
|
-
|
|
61
|
+
if (hidden > 0) {
|
|
62
|
+
// 与技能目录同一条纪律:点名一个被关掉的工具只会让模型去猜名字。数量照报。
|
|
63
|
+
out.push('', listToolVisible
|
|
64
|
+
? `(另有 ${hidden} 个未列出,用 \`subagent_manager_list\` 查。)`
|
|
65
|
+
: `(另有 ${hidden} 个未列出。)`);
|
|
66
|
+
}
|
|
63
67
|
return out.join('\n');
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
@@ -101,7 +105,7 @@ export function createSubagentCatalog(deps, opts = {}) {
|
|
|
101
105
|
const cached = rendered.get(depth);
|
|
102
106
|
if (cached !== undefined)
|
|
103
107
|
return cached;
|
|
104
|
-
const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription);
|
|
108
|
+
const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription, deps.listToolVisible ? deps.listToolVisible() : true);
|
|
105
109
|
rendered.set(depth, out);
|
|
106
110
|
return out;
|
|
107
111
|
},
|
package/lib/subagents/tools.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
|
|
|
22
22
|
export function defineSubagentManagerListTool(subagents) {
|
|
23
23
|
return {
|
|
24
24
|
name: 'subagent_manager_list',
|
|
25
|
-
description: 'List
|
|
25
|
+
description: 'List personas (pre-configured subagent profiles) with their descriptions. The「可委派的子智能体」reminder carries the same catalog; call this for the always-current full list before subagent_manager_run.',
|
|
26
26
|
parameters: {},
|
|
27
27
|
output: {
|
|
28
28
|
schema: { type: 'string' },
|
|
@@ -59,7 +59,14 @@ export function defineSubagentManagerRunTool(subagents) {
|
|
|
59
59
|
// 「何时不用」那一段保留(2026-09-17,用户采纳的四条里的第 6 条):参照 Claude Code 的
|
|
60
60
|
// Agent 工具(`AgentTool/prompt.ts:232-240`),把"不该用"写成**带替代工具**的具体清单
|
|
61
61
|
// (具体路径→Read;找定义→Grep/Glob),比笼统说"这个很贵"有用得多。
|
|
62
|
-
|
|
62
|
+
//
|
|
63
|
+
// 2026-09-23(用户裁定):与官方两个委派工具的**分界规则**从描述里删掉 —— 注入通道的
|
|
64
|
+
// `how` 行(context-inject.ts 的 `DOMAIN_FRAME.subagents`)已经逐字说过一遍,而两份都在
|
|
65
|
+
// 每轮上下文里 = 同一件事付两次 token。留注入那份(它出现在"正在选工具"的那一刻)。
|
|
66
|
+
// 代价如实记下:子智能体域被关掉、或走压制型预设时上下文里没有那条 how 行,模型只剩本
|
|
67
|
+
// 描述与 `agent` 参数说明("Persona name from subagent_manager_list")—— 够它认出这条是
|
|
68
|
+
// 带人设的委派通道,但"没有人设贴合时才用官方那两个"这层分界就没人说了。
|
|
69
|
+
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\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「可委派的子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
|
|
63
70
|
parameters: {
|
|
64
71
|
agent: { type: 'string', required: true, description: 'Persona name from subagent_manager_list.' },
|
|
65
72
|
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.' },
|
package/lib/tools/mcp.js
CHANGED
|
@@ -48,7 +48,7 @@ export function buildMcpTools(deps) {
|
|
|
48
48
|
}));
|
|
49
49
|
register(defineTool({
|
|
50
50
|
name: 'mcp_manager_set_enabled',
|
|
51
|
-
description: 'Enable or disable one configured MCP server (writes the patch file;
|
|
51
|
+
description: 'Enable or disable one configured MCP server (writes the patch file; hot-reloaded). Only when the user asks or approves.',
|
|
52
52
|
parameters: {
|
|
53
53
|
id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
|
|
54
54
|
level: { type: 'string', required: true, description: 'project or global.' },
|
|
@@ -70,7 +70,7 @@ export function buildMcpTools(deps) {
|
|
|
70
70
|
}));
|
|
71
71
|
register(defineTool({
|
|
72
72
|
name: 'mcp_manager_restart',
|
|
73
|
-
description: 'Restart one configured MCP server (disable + re-enable;
|
|
73
|
+
description: 'Restart one configured MCP server (disable + re-enable; reconnects and re-syncs tools). Only when the user asks or approves.',
|
|
74
74
|
parameters: {
|
|
75
75
|
id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
|
|
76
76
|
level: { type: 'string', required: true, description: 'project or global.' },
|
|
@@ -85,7 +85,7 @@ export function buildMcpTools(deps) {
|
|
|
85
85
|
}));
|
|
86
86
|
register(defineTool({
|
|
87
87
|
name: 'mcp_manager_add',
|
|
88
|
-
description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
|
|
88
|
+
description: 'Add a new MCP server (streamable-http or stdio) at project or global level. Only on the user\'s explicit request.',
|
|
89
89
|
parameters: {
|
|
90
90
|
serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
|
|
91
91
|
transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
|
package/lib/tools/memory.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 记忆(rules)域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
|
|
2
|
-
// memory_manager_list /
|
|
2
|
+
// memory_manager_list / _read / _write / _set_enabled / _update。
|
|
3
3
|
//
|
|
4
4
|
// 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于查询/编辑规则
|
|
5
5
|
// 本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
|
|
@@ -9,7 +9,7 @@ export function buildMemoryTools(deps) {
|
|
|
9
9
|
const { defineTool, register } = deps;
|
|
10
10
|
register(defineTool({
|
|
11
11
|
name: 'memory_manager_list',
|
|
12
|
-
description: 'List memories
|
|
12
|
+
description: 'List memories (id, scene, enabled, description). What is injected each turn is in the「本机当前的场景和记忆」reminder; use this for ids/paths and for entries that are off. Defaults to the ones that would be injected; all=true for every entry.',
|
|
13
13
|
parameters: {
|
|
14
14
|
group: { type: 'string', description: 'Optional scene filter.' },
|
|
15
15
|
all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
|
|
@@ -52,7 +52,7 @@ export function buildMemoryTools(deps) {
|
|
|
52
52
|
}));
|
|
53
53
|
register(defineTool({
|
|
54
54
|
name: 'memory_manager_read',
|
|
55
|
-
description: 'Read the full body of one memory
|
|
55
|
+
description: 'Read the full body of one memory. Only for ones not already in your context (disabled, scene not active, or dropped by the byte budget).',
|
|
56
56
|
parameters: {
|
|
57
57
|
id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
|
|
58
58
|
},
|
|
@@ -64,9 +64,79 @@ export function buildMemoryTools(deps) {
|
|
|
64
64
|
return '# ' + r.rule.id + '\n\n' + (r.rule.body || '');
|
|
65
65
|
},
|
|
66
66
|
}));
|
|
67
|
+
register(defineTool({
|
|
68
|
+
name: 'memory_manager_set_enabled',
|
|
69
|
+
// 为什么单独一个工具:注入实况里「这个域注入了几次 / 模型调了几次」都看得到,但模型此前
|
|
70
|
+
// 看得到一份记忆却开关不了它 —— 只能回一句"请你去界面上点"。启停是它替用户调整环境时
|
|
71
|
+
// 最常碰的一格,而 `rules-toggle` 这个 op 早就带齐了门禁(写令牌 + 场景冻结)。
|
|
72
|
+
// 三个写侧工具共用一句 consent(`Only when the user asks or approves.` / `Only on the
|
|
73
|
+
// user's instruction.`):这些动的都是"以后每一轮都会带上"的东西,模型不该顺手做。
|
|
74
|
+
// 描述同时按"省 token"重写了一遍 —— 这 20 段文字每次请求都要发出去,废话是常驻成本。
|
|
75
|
+
description: 'Enable or disable one memory (index switch; the .md file is untouched). Disabled memories stop being injected, freeing the byte budget. Prefer this over deleting — it is reversible. Only when the user asks or approves.',
|
|
76
|
+
parameters: {
|
|
77
|
+
id: { type: 'string', required: true, description: 'Memory id like <scene>/<name> (from memory_manager_list).' },
|
|
78
|
+
enabled: { type: 'boolean', required: true, description: 'true = enable, false = disable; omission is refused.' },
|
|
79
|
+
},
|
|
80
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
81
|
+
async execute(args) {
|
|
82
|
+
const blocked = await deps.lockedSceneGuard();
|
|
83
|
+
if (blocked)
|
|
84
|
+
throw new Error(blocked);
|
|
85
|
+
const r = await deps.rulesOps['rules-toggle'](args);
|
|
86
|
+
if (!r || r.ok === false)
|
|
87
|
+
throw new Error((r && r.error) || '切换记忆启停失败');
|
|
88
|
+
const rule = r.rule || {};
|
|
89
|
+
// 「启用了一条不在启用场景里的记忆」是一次静默无效:单条开关拨上去了,注入集里却没有它。
|
|
90
|
+
// 这一句不说,模型会以为已经生效 —— 与 skill_manager_set_enabled 说清影子副本同一条理由。
|
|
91
|
+
let sceneNote = '';
|
|
92
|
+
try {
|
|
93
|
+
const all = await deps.rulesOps['rules-list']({});
|
|
94
|
+
if (all && all.ok !== false) {
|
|
95
|
+
const group = String(rule.group || '');
|
|
96
|
+
const on = group === 'global' || (group !== '' && (all.activeMode === 'all' || String(all.activeScene || '') === group));
|
|
97
|
+
if (!on)
|
|
98
|
+
sceneNote = ' — BUT its scene「' + group + '」is not the active one, so it still will not be injected; enable that scene (or move it to global) first';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
sceneNote = '';
|
|
103
|
+
}
|
|
104
|
+
return 'OK: memory ' + String(rule.id || args.id) + ' ' + (rule.enabled === false ? 'disabled' : 'enabled') + sceneNote;
|
|
105
|
+
},
|
|
106
|
+
}));
|
|
107
|
+
register(defineTool({
|
|
108
|
+
name: 'memory_manager_update',
|
|
109
|
+
description: 'Update a memory: body, description, or name (renaming keeps the old file in the trash). Omit a field to keep it — this merges. Read it with memory_manager_read first and change one part rather than restating the file; if a memory already covers this, update that one instead of adding a second. Only on the user\'s instruction.',
|
|
110
|
+
parameters: {
|
|
111
|
+
id: { type: 'string', required: true, description: 'Memory id like <scene>/<name> (from memory_manager_list).' },
|
|
112
|
+
body: { type: 'string', description: 'New Markdown body (<=256 KiB, non-empty). Omit to keep the current body.' },
|
|
113
|
+
description: { type: 'string', description: 'New one-sentence description (<=500 chars). Empty string = drop it and re-derive from the body; omit = keep.' },
|
|
114
|
+
name: { type: 'string', description: 'New memory name (= file name). Omit to keep it.' },
|
|
115
|
+
},
|
|
116
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
117
|
+
async execute(args) {
|
|
118
|
+
const blocked = await deps.lockedSceneGuard();
|
|
119
|
+
if (blocked)
|
|
120
|
+
throw new Error(blocked);
|
|
121
|
+
// 三个可改字段一个都没给 → 直接拒。`rules-update` 会按"保留现有"落一次盘并刷新
|
|
122
|
+
// updatedAt,模型以为改了点什么,其实只是把文件重写了一遍。
|
|
123
|
+
const touches = ['body', 'description', 'name'].filter((k) => args[k] !== undefined);
|
|
124
|
+
if (!touches.length)
|
|
125
|
+
throw new Error('没有要改的东西:body / description / name 至少给一个');
|
|
126
|
+
const r = await deps.rulesOps['rules-update'](args);
|
|
127
|
+
if (!r || r.ok === false)
|
|
128
|
+
throw new Error((r && r.error) || '更新记忆失败');
|
|
129
|
+
const rule = r.rule || {};
|
|
130
|
+
return 'OK: updated ' + String(rule.id || args.id) + '(改了 ' + touches.join('、') + ')' +
|
|
131
|
+
(rule.id && String(rule.id) !== String(args.id) ? ' → 现在是 ' + rule.id : '');
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
67
134
|
register(defineTool({
|
|
68
135
|
name: 'memory_manager_write',
|
|
69
|
-
|
|
136
|
+
// 「能推导的别记」「先查重」这两句是 0.13.0 加的:记忆是唯一直接吃注入预算的域
|
|
137
|
+
// (场景段 128 KiB),而模型很乐意把「这个项目用 pnpm」记一条 —— 那看一眼 `package.json`
|
|
138
|
+
// 就知道,记下来却永久占着每一轮请求。
|
|
139
|
+
description: 'Create a memory (the scene must already exist; global is the always-on one). Memories cost context on EVERY later request, so record only what cannot be re-derived from code, config or git, and check the list for one that already says this — update that instead of adding a near-duplicate. Only on the user\'s instruction.',
|
|
70
140
|
parameters: {
|
|
71
141
|
group: { type: 'string', required: true, description: 'Scene name (no path separators or < > : " | ? *); `global` = the always-on scene.' },
|
|
72
142
|
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.' },
|
|
@@ -78,10 +148,40 @@ export function buildMemoryTools(deps) {
|
|
|
78
148
|
const blocked = await deps.lockedSceneGuard();
|
|
79
149
|
if (blocked)
|
|
80
150
|
throw new Error(blocked);
|
|
151
|
+
// 先扫一遍已有的:疑似重复只**说出来**,不拒绝 —— 猜错一次就把该记的东西记不下去,
|
|
152
|
+
// 比多记一条代价大得多;而且"像不像同一条"本来就是人判断的事。
|
|
153
|
+
const norm = (value) => String(value ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
154
|
+
const wantName = norm(args && args.name);
|
|
155
|
+
const wantDesc = norm(args && args.description);
|
|
156
|
+
let similar = [];
|
|
157
|
+
try {
|
|
158
|
+
const before = await deps.rulesOps['rules-list']({});
|
|
159
|
+
if (before && before.ok !== false) {
|
|
160
|
+
similar = (before.rules || [])
|
|
161
|
+
.filter((x) => {
|
|
162
|
+
const have = norm(x.name);
|
|
163
|
+
const desc = norm(x.description);
|
|
164
|
+
if (have === wantName)
|
|
165
|
+
return true;
|
|
166
|
+
if (wantDesc !== '' && desc !== '' && desc === wantDesc)
|
|
167
|
+
return true;
|
|
168
|
+
// 名字互相包含:只在两边都不短于 4 个字符时算,否则 "git" 会把所有条目都匹配上。
|
|
169
|
+
return have.length >= 4 && wantName.length >= 4 && (have.includes(wantName) || wantName.includes(have));
|
|
170
|
+
})
|
|
171
|
+
.map((x) => String(x.id))
|
|
172
|
+
.slice(0, 3);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch { /* 查重读不到就当没查到:它不该拦住写入 */ }
|
|
81
176
|
const r = await deps.rulesOps['rules-create'](args);
|
|
82
177
|
if (!r || r.ok === false)
|
|
83
178
|
throw new Error((r && r.error) || '创建规则失败');
|
|
84
|
-
|
|
179
|
+
// 刚建好的这条自己会在列表里,从名单里去掉。
|
|
180
|
+
similar = similar.filter((id) => id !== String(r.rule.id));
|
|
181
|
+
const dup = similar.length
|
|
182
|
+
? `\n注意:已有 ${similar.length} 条名字或描述与它很像(${similar.join('、')})—— 若是同一件事,改那一条而不是留着两条。`
|
|
183
|
+
: '';
|
|
184
|
+
return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)' + dup;
|
|
85
185
|
},
|
|
86
186
|
}));
|
|
87
187
|
}
|
package/lib/tools/prompt.js
CHANGED
|
@@ -8,7 +8,7 @@ export function buildPromptTools(deps) {
|
|
|
8
8
|
const { defineTool, register } = deps;
|
|
9
9
|
register(defineTool({
|
|
10
10
|
name: 'prompt_manager_list',
|
|
11
|
-
description: 'List AGENTS.md presets (id, active state, file path)
|
|
11
|
+
description: 'List AGENTS.md presets (id, active state, file path); read that file for a preset body. The one in effect is in the「本机提示词」reminder, whose「来源:」line names the file. Defaults to the one in effect; all=true for the whole library.',
|
|
12
12
|
parameters: {
|
|
13
13
|
all: { type: 'boolean', description: 'Include inactive presets (default false).' },
|
|
14
14
|
},
|
|
@@ -47,7 +47,7 @@ export function buildPromptTools(deps) {
|
|
|
47
47
|
}));
|
|
48
48
|
register(defineTool({
|
|
49
49
|
name: 'prompt_manager_apply',
|
|
50
|
-
description: 'Apply one AGENTS.md preset by id; effective
|
|
50
|
+
description: 'Apply one AGENTS.md preset by id; effective next turn. If a scene drives the baseline this does NOT refuse — it rebinds that scene to the preset and re-syncs it, so tell the user which scene was rebound. A locked scene blocks the call. Only on the user\'s instruction.',
|
|
51
51
|
parameters: {
|
|
52
52
|
id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
|
|
53
53
|
},
|
package/lib/tools/skills.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// 技能域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
|
|
2
|
-
// skill_manager_list / skill_manager_set_enabled / skill_manager_create。
|
|
2
|
+
// skill_manager_list / skill_manager_read / skill_manager_set_enabled / skill_manager_create。
|
|
3
3
|
import { text } from './deps.js';
|
|
4
4
|
export function buildSkillTools(deps) {
|
|
5
5
|
const { defineTool, register } = deps;
|
|
@@ -7,7 +7,7 @@ export function buildSkillTools(deps) {
|
|
|
7
7
|
// tools/pre-execute hook below (the model must ask before writing files).
|
|
8
8
|
register(defineTool({
|
|
9
9
|
name: 'skill_manager_list',
|
|
10
|
-
description: 'List
|
|
10
|
+
description: 'List skills with enabled state, effective/shadowed status and source file path. The「本机技能目录」reminder carries callable skills and summaries only; use this for entries that are off and to see which source wins a name collision, and skill_manager_read for a body. Defaults to enabled only; all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
|
|
11
11
|
parameters: {
|
|
12
12
|
all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
|
|
13
13
|
},
|
|
@@ -67,9 +67,87 @@ export function buildSkillTools(deps) {
|
|
|
67
67
|
return header + (shown.map((row) => row.text).join('\n') || '(none)') + notice;
|
|
68
68
|
},
|
|
69
69
|
}));
|
|
70
|
+
// 一次拿到技能正文。此前读正文要两步:`skill_manager_list` 拿路径 → 自己再去读那个文件。
|
|
71
|
+
// 少的那一步不只是省一次调用 —— 目录里只有名字没有路径,而**同名技能常几份并存**(dsh /
|
|
72
|
+
// agents / claude / 自定义目录各一份),模型自己挑路径时很容易读到被覆盖的那份影子副本:
|
|
73
|
+
// 读完以为在用这个技能,其实生效的是另一份。这里先走 `skill-state`(core 的 markWinners
|
|
74
|
+
// 已在上面标出胜出者),再走 `skill-detail` 读那一份 —— 读到的永远是目录列出来的那一份。
|
|
75
|
+
register(defineTool({
|
|
76
|
+
name: 'skill_manager_read',
|
|
77
|
+
description: 'Read one skill\'s full SKILL.md body by name — what the「本机技能目录」reminder deliberately leaves out (it has names and summaries only). Resolves name collisions the same way, so you get the copy actually in effect; pass root only to inspect a specific (e.g. shadowed) source. Read-only.',
|
|
78
|
+
parameters: {
|
|
79
|
+
name: { type: 'string', required: true, description: 'Skill name, exactly as it appears in the catalog (kebab-case).' },
|
|
80
|
+
root: { type: 'string', description: 'Source root key (dsh/hub/agents/codex/claude, a project key, or a custom one). Default: the winning copy of this name.' },
|
|
81
|
+
},
|
|
82
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
83
|
+
async execute(args, exec) {
|
|
84
|
+
const wanted = String((args && args.name) || '').trim();
|
|
85
|
+
if (!wanted)
|
|
86
|
+
throw new Error('name is required');
|
|
87
|
+
const rootWanted = String((args && args.root) || '').trim();
|
|
88
|
+
const state = await deps.skillsOps['skill-state']({});
|
|
89
|
+
if (!state || state.ok === false)
|
|
90
|
+
throw new Error((state && state.error) || 'skill-state failed');
|
|
91
|
+
const data = state.data || {};
|
|
92
|
+
const hits = [];
|
|
93
|
+
for (const root of data.roots || []) {
|
|
94
|
+
for (const skill of root.skills || []) {
|
|
95
|
+
if (String(skill.declaredName || skill.name || '') !== wanted && String(skill.name || '') !== wanted)
|
|
96
|
+
continue;
|
|
97
|
+
if (rootWanted && String(root.key || '') !== rootWanted)
|
|
98
|
+
continue;
|
|
99
|
+
hits.push({
|
|
100
|
+
rootKey: String(root.key || ''),
|
|
101
|
+
// `skill-detail` 按**目录名**认条目(core 的 visibleEntryForRoot 比的是 entry.name),
|
|
102
|
+
// 而目录名与 frontmatter 的 name 可以不同 —— 传错一个就读不到。
|
|
103
|
+
entryName: String(skill.name || wanted),
|
|
104
|
+
path: String(skill.path || ''),
|
|
105
|
+
shadowedBy: skill.shadowedBy && skill.shadowedBy.root ? String(skill.shadowedBy.root) : '',
|
|
106
|
+
preferred: skill.preferred === true,
|
|
107
|
+
// 与 skill_manager_list 同一套启停推导(影子副本没有 enabled 字段)。
|
|
108
|
+
enabled: skill.enabled !== undefined
|
|
109
|
+
? skill.enabled === true
|
|
110
|
+
: (skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!hits.length) {
|
|
115
|
+
throw new Error(rootWanted
|
|
116
|
+
? `no skill named "${wanted}" in source "${rootWanted}" — call skill_manager_list (pass all=true) to see what is installed`
|
|
117
|
+
: `no skill named "${wanted}" — call skill_manager_list (pass all=true) to see what is installed`);
|
|
118
|
+
}
|
|
119
|
+
// 胜出者优先:同名的影子副本读得到,但那不是生效的一份,所以默认不选它。
|
|
120
|
+
const picked = hits.find((hit) => !hit.shadowedBy && hit.enabled)
|
|
121
|
+
|| hits.find((hit) => !hit.shadowedBy)
|
|
122
|
+
|| hits[0];
|
|
123
|
+
const res = await deps.skillsOps['skill-detail']({ root: picked.rootKey, name: picked.entryName });
|
|
124
|
+
if (!res || res.ok === false)
|
|
125
|
+
throw new Error((res && res.error) || 'skill-detail failed');
|
|
126
|
+
const detail = res.data || res;
|
|
127
|
+
const marks = [];
|
|
128
|
+
if (picked.shadowedBy)
|
|
129
|
+
marks.push('shadowed by "' + picked.shadowedBy + '" — the copy actually in effect is a different one');
|
|
130
|
+
if (picked.preferred)
|
|
131
|
+
marks.push('preferred copy');
|
|
132
|
+
if (!picked.enabled)
|
|
133
|
+
marks.push('disabled');
|
|
134
|
+
const dup = hits.filter((hit) => !hit.shadowedBy).length > 1
|
|
135
|
+
? hits.filter((hit) => !hit.shadowedBy).map((hit) => hit.rootKey).filter((k) => k !== picked.rootKey)
|
|
136
|
+
: [];
|
|
137
|
+
const header = 'Skill ' + wanted + ' · source ' + picked.rootKey + (marks.length ? ' (' + marks.join('; ') + ')' : '') + '\n' +
|
|
138
|
+
'Path: ' + (detail.path || picked.path || '(unknown)') + '\n' +
|
|
139
|
+
'Description: ' + String(detail.description || '(none)').replace(/\s+/g, ' ').trim() + '\n' +
|
|
140
|
+
(dup.length ? 'Other callable copies with the same name: ' + dup.join(', ') + ' (this one wins; read them only to compare)\n' : '') +
|
|
141
|
+
'--- SKILL.md body ---\n';
|
|
142
|
+
// 注入边界:正文读进来就永久留在这一轮的 transcript 里,压制型预设下目录也可能不在 ——
|
|
143
|
+
// 与 skill_manager_list 同一句说明,别让模型以为"读过就等于技能开着"。
|
|
144
|
+
const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
|
|
145
|
+
return header + String(detail.body || '').trim() + notice;
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
70
148
|
register(defineTool({
|
|
71
149
|
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.',
|
|
150
|
+
description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect. Only when the user asks or approves.',
|
|
73
151
|
parameters: {
|
|
74
152
|
name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
|
|
75
153
|
enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
|
package/lib/tools/subagent.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// 子智能体域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
|
|
2
|
-
// subagent_manager_list / subagent_manager_run。
|
|
2
|
+
// subagent_manager_list / subagent_manager_run / _set_enabled / _create / _update。
|
|
3
3
|
//
|
|
4
4
|
// exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
|
|
5
5
|
//
|
|
6
6
|
// 两个工具**各自** try/catch:一个注册失败不该把另一个也带走,而且失败必须说得出
|
|
7
7
|
// 「是哪一个没注册上」——只打一行日志时,模型侧只会「查无此工具」、界面毫无痕迹。
|
|
8
8
|
import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from '../subagents/tools.js';
|
|
9
|
+
import { text } from './deps.js';
|
|
9
10
|
export function buildSubagentTools(deps) {
|
|
10
11
|
const { defineTool, register } = deps;
|
|
11
12
|
const recordFailure = (name, e) => {
|
|
@@ -16,7 +17,7 @@ export function buildSubagentTools(deps) {
|
|
|
16
17
|
console.error('[dsh-plugin-tool-management] subagent tool registration failed:', name, deps.message(e));
|
|
17
18
|
};
|
|
18
19
|
try {
|
|
19
|
-
// 与其余
|
|
20
|
+
// 与其余 19 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
|
|
20
21
|
// 裸 register 会把未编译的参数声明直接发给模型 API。
|
|
21
22
|
register(defineTool(defineSubagentManagerListTool({
|
|
22
23
|
list: () => deps.subagentService.list(),
|
|
@@ -37,4 +38,118 @@ export function buildSubagentTools(deps) {
|
|
|
37
38
|
catch (e) {
|
|
38
39
|
recordFailure('subagent_manager_run', e);
|
|
39
40
|
}
|
|
41
|
+
// ── 管理侧的三个(启停 / 建 / 改)────────────────────────────────────────────
|
|
42
|
+
// 与上面两个不同,这三个**不依赖** provider:动的只是 hub 里的人设文件与启停名单,
|
|
43
|
+
// 宿主没挂 `dsh-subagent-*` 也照样该能用(恰恰那时更需要:能把人设建好、开关拨对,
|
|
44
|
+
// 等 provider 回来就可用)。所以各自单独 try/catch,不与 `_run` 绑在一起失败。
|
|
45
|
+
//
|
|
46
|
+
// 参数表刻意只给 name / description / body / output 四个:工具限制(tools / toolsDeny /
|
|
47
|
+
// toolsByPreset)与 provider/model 留给面板。理由不是"怕模型不会填",而是填错的后果
|
|
48
|
+
// 不对称 —— 一个错误的 toolsDeny 会静默改变子代理能做什么,而模型看不到自己改对了没有。
|
|
49
|
+
const ops = () => deps.subagentService.ops;
|
|
50
|
+
try {
|
|
51
|
+
register(deps.defineTool({
|
|
52
|
+
name: 'subagent_manager_set_enabled',
|
|
53
|
+
description: 'Enable or disable one persona. Only enabled personas appear in the「可委派的子智能体」catalog and can be given work by subagent_manager_run. Reversible, and the file is untouched. Only when the user asks or approves.',
|
|
54
|
+
parameters: {
|
|
55
|
+
name: { type: 'string', required: true, description: 'Persona name.' },
|
|
56
|
+
enabled: { type: 'boolean', required: true, description: 'true = enable, false = disable; omission is refused.' },
|
|
57
|
+
},
|
|
58
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
59
|
+
async execute(args) {
|
|
60
|
+
const blocked = await deps.lockedSceneGuard();
|
|
61
|
+
if (blocked)
|
|
62
|
+
throw new Error(blocked);
|
|
63
|
+
const name = String((args && args.name) || '').trim();
|
|
64
|
+
const r = await ops()['subagent-toggle']({ name, enabled: args.enabled === true });
|
|
65
|
+
if (!r || r.ok === false)
|
|
66
|
+
throw new Error((r && r.error) || '切换人设启停失败');
|
|
67
|
+
// 场景内改开关要同步进当前场景档案(与 HTTP 层 syncsArchive 同一口径 —— 模型工具
|
|
68
|
+
// 直接调 service op,绕过了那一层包装,所以这里自己叫一声)。
|
|
69
|
+
const syncErr = await deps.syncSwitchToScene('subagent-toggle', { name, enabled: args.enabled === true });
|
|
70
|
+
return 'OK: persona ' + name + ' ' + (args.enabled === true ? 'enabled' : 'disabled') +
|
|
71
|
+
(syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
recordFailure('subagent_manager_set_enabled', e);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
register(deps.defineTool({
|
|
80
|
+
name: 'subagent_manager_create',
|
|
81
|
+
description: 'Create a persona at ~/.dsh/tool-management/subagents/<name>.md. Only on the user\'s explicit request. Write `body` as who this is and how it judges its own work — not the steps or paths, those belong to the task; put hard output requirements in `output` (one per line), not buried in prose. New personas start disabled: enable with subagent_manager_set_enabled before delegating.',
|
|
82
|
+
parameters: {
|
|
83
|
+
name: { type: 'string', required: true, description: 'Persona name (= file name); ≤64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
|
|
84
|
+
description: { type: 'string', required: true, description: 'One-line routing description: what it is for and when to pick it. This is what shows up in the delegation catalog.' },
|
|
85
|
+
body: { type: 'string', required: true, description: 'Markdown role definition.' },
|
|
86
|
+
output: { type: 'string', description: 'Output contract, one requirement per line (rendered to the persona as its own 「输出要求」 section). Omit for no hard requirements.' },
|
|
87
|
+
},
|
|
88
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
89
|
+
async execute(args) {
|
|
90
|
+
const blocked = await deps.lockedSceneGuard();
|
|
91
|
+
if (blocked)
|
|
92
|
+
throw new Error(blocked);
|
|
93
|
+
const r = await ops()['subagent-create'](args);
|
|
94
|
+
if (!r || r.ok === false)
|
|
95
|
+
throw new Error((r && r.error) || '创建人设失败');
|
|
96
|
+
return 'OK: persona ' + String(r.name || args.name) + ' created(默认未启用,要委派它先 subagent_manager_set_enabled)';
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
recordFailure('subagent_manager_create', e);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
register(deps.defineTool({
|
|
105
|
+
name: 'subagent_manager_update',
|
|
106
|
+
// 为什么不是直通 `subagent-update`:那个 op 走 serializePersona **整份重写**文件,
|
|
107
|
+
// 缺席的字段一律按空处理。界面每次都带全量表单所以没事,模型只改一句 description
|
|
108
|
+
// 却直通过去,就会把人设正文与工具限制一起冲掉。这里先读现状再合并 —— 合并发生在
|
|
109
|
+
// 工具这一侧,op 的语义不动。
|
|
110
|
+
description: 'Update a persona: body, description, output contract, or name (renaming re-points scene-profile bindings). Omit a field to keep it — this merges, so you can change one part without restating the role. Only on the user\'s instruction.',
|
|
111
|
+
parameters: {
|
|
112
|
+
name: { type: 'string', required: true, description: 'Current persona name.' },
|
|
113
|
+
nextName: { type: 'string', description: 'New name (rename). Omit to keep it. Refused if a persona already has that name — nothing is overwritten.' },
|
|
114
|
+
description: { type: 'string', description: 'New one-line routing description. Omit to keep it.' },
|
|
115
|
+
body: { type: 'string', description: 'New Markdown role definition. Omit to keep it.' },
|
|
116
|
+
output: { type: 'string', description: 'New output contract, one requirement per line. Empty string clears it. Omit to keep it.' },
|
|
117
|
+
},
|
|
118
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
119
|
+
async execute(args) {
|
|
120
|
+
const blocked = await deps.lockedSceneGuard();
|
|
121
|
+
if (blocked)
|
|
122
|
+
throw new Error(blocked);
|
|
123
|
+
const name = String((args && args.name) || '').trim();
|
|
124
|
+
const current = await ops()['subagent-get']({ name });
|
|
125
|
+
if (!current || current.ok === false)
|
|
126
|
+
throw new Error((current && current.error) || '人设不存在: ' + name);
|
|
127
|
+
const keep = current.persona || {};
|
|
128
|
+
const touched = ['nextName', 'description', 'body', 'output'].filter((k) => args && args[k] !== undefined);
|
|
129
|
+
if (!touched.length)
|
|
130
|
+
throw new Error('没有要改的东西:nextName / description / body / output 至少给一个');
|
|
131
|
+
const merged = {
|
|
132
|
+
name,
|
|
133
|
+
nextName: args.nextName,
|
|
134
|
+
description: args.description !== undefined ? String(args.description) : String(keep.description ?? ''),
|
|
135
|
+
body: args.body !== undefined ? String(args.body) : String(keep.body ?? ''),
|
|
136
|
+
output: args.output !== undefined ? String(args.output) : (Array.isArray(keep.output) ? keep.output.join('\n') : String(keep.output ?? '')),
|
|
137
|
+
provider: keep.provider ?? '',
|
|
138
|
+
model: keep.model ?? '',
|
|
139
|
+
tools: keep.tools ?? [],
|
|
140
|
+
toolsDeny: keep.toolsDeny ?? [],
|
|
141
|
+
toolsByPreset: keep.toolsByPreset ?? {},
|
|
142
|
+
catalogDepth: keep.catalogDepth,
|
|
143
|
+
};
|
|
144
|
+
const r = await ops()['subagent-update'](merged);
|
|
145
|
+
if (!r || r.ok === false)
|
|
146
|
+
throw new Error((r && r.error) || '更新人设失败');
|
|
147
|
+
return 'OK: persona ' + String(r.name || name) + ' updated(改了 ' + touched.join('、') + ')' +
|
|
148
|
+
(r.renamedFrom ? ' — 原名「' + r.renamedFrom + '」,场景档案里的绑定已一起改名' : '');
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
recordFailure('subagent_manager_update', e);
|
|
154
|
+
}
|
|
40
155
|
}
|