dsh-plugin-tool-management 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +560 -474
- package/README.md +91 -123
- package/README_EN.md +99 -126
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +391 -337
- package/lib/audit-log.js +157 -0
- package/lib/client.js +4204 -2454
- package/lib/compat/patch-dialect.js +3 -1
- package/lib/compat/preset-reach.js +43 -9
- package/lib/compat/probe.js +149 -7
- package/lib/context-inject.js +91 -35
- package/lib/index.js +479 -114
- package/lib/mcp/loader-token.js +53 -28
- package/lib/mcp/manager.js +149 -21
- package/lib/mcp/secret-guard.js +21 -0
- package/lib/mcp/state-section.js +13 -11
- package/lib/memories/archive-engine.js +70 -2
- package/lib/memories/archive.js +8 -1
- package/lib/memories/constants.js +72 -8
- package/lib/memories/index-io.js +7 -1
- package/lib/memories/projection.js +67 -31
- package/lib/memories/service.js +17 -2
- package/lib/memories/snapshot.js +113 -22
- package/lib/op-registry.js +26 -6
- package/lib/ops/candidates.js +11 -0
- package/lib/ops/compat.js +113 -31
- package/lib/ops/scene-records.js +34 -0
- package/lib/ops/scene-sync.js +12 -1
- package/lib/ops/sessions.js +24 -9
- package/lib/ops/snapshot.js +596 -0
- package/lib/prompts/service.js +25 -2
- package/lib/scenes/candidates.js +66 -5
- package/lib/sessions/bridge.js +5 -0
- package/lib/sessions/workspace.js +17 -5
- package/lib/skills/core.js +105 -3
- package/lib/skills/service.js +34 -2
- package/lib/subagents/service.js +37 -4
- package/lib/subagents/tools.js +11 -4
- package/lib/tools/deps.js +16 -1
- package/lib/tools/mcp.js +291 -50
- package/lib/tools/memory.js +131 -80
- package/lib/tools/prompt.js +27 -13
- package/lib/tools/scene.js +351 -0
- package/lib/tools/skills.js +93 -17
- package/lib/tools/subagent.js +56 -45
- package/lib/tools/table.js +208 -15
- package/package.json +24 -20
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// 场景域的 model 工具(2026-09-23 从记忆族分出来;同日补上 `_switch` 与 `_list`):
|
|
2
|
+
// `scene_manager_list` → `_switch` → `_save`。**注册顺序就是兼容页「模型工具表」的显示顺序**
|
|
3
|
+
// (`tools/table.ts` 刻意不定义第二份排序),这一族按其余四族同形的排法排:先查、再拨开关、
|
|
4
|
+
// 最后写定义 —— 场景族补上这两条时是照"先建后切"的语义顺序注册的,成了五族里唯一的例外。
|
|
5
|
+
//
|
|
6
|
+
// 为什么单独一族而不是塞进 `memory_manager_save`:场景与记忆是两个对象 —— 场景是**容器**
|
|
7
|
+
// (进入时按档案切换 mcp / 技能 / 人设,其余全部关掉),记忆是**内容**(按场景组织、吃注入
|
|
8
|
+
// 预算)。合成一条工具会让 `description` 变成双义参数、门禁按参数分叉,而这两件事的失败
|
|
9
|
+
// 形状都是静默的。
|
|
10
|
+
//
|
|
11
|
+
// 为什么有 `scene_manager_list`(2026-09-23 用户裁定:「记忆就管记忆,场景就管场景」):
|
|
12
|
+
// 场景清单此前挂在 `memory_manager_list` 的输出末尾(它最初解释的是"这条记忆为什么没被注入",
|
|
13
|
+
// 0.14.x 那次把档案也并进去时明确写过"不加新工具、不加新 op")。省下那 106 tok/轮的代价是
|
|
14
|
+
// **引用方向反了** —— 场景族两条工具都要模型去调记忆族的工具看现状,而工具表可以单独关掉
|
|
15
|
+
// `memory_manager_list`(`tools/table.ts`),关掉后 `_switch` 描述里那句"照抄它打印的场景名"
|
|
16
|
+
// 就悬空了。六个注入域里也只有场景没有自己的 list。
|
|
17
|
+
//
|
|
18
|
+
// 数据源没变,仍然是 `rules-list` 的 `scenes` + `scene-mode-get` 的档案,**没有新 op**。
|
|
19
|
+
//
|
|
20
|
+
// 写侧为什么是两条、而不是一条带 `action` 的:
|
|
21
|
+
// · `_save` 只写**定义**(场景记录 + 档案),不改变现在;
|
|
22
|
+
// · `_switch` 改的是**现在**(进入 = 六处开关一起动 + 收窄注入 + 改写 AGENTS.md)。
|
|
23
|
+
// 危险度差一档,回执要说的事也完全不同。0.14.0 当初把「启用与进入」整个留在界面,
|
|
24
|
+
// 理由是"那是改运行时环境的动作";补上 `_switch` 的取舍记在 CHANGELOG 里 —— 要点是
|
|
25
|
+
// **场景段的动作句仍然不加回来**:「上下文注入只给现在是什么」是同一天定下的原则,
|
|
26
|
+
// 不因为多了一条工具就破例(工具描述本来就是写"什么时候该用它"的地方)。
|
|
27
|
+
import { text, toolNameList } from './deps.js';
|
|
28
|
+
/** 档案里由模型设置的三个段(`memories` 段在 P5 已废弃,不再是档案的一部分)。 */
|
|
29
|
+
const ARCHIVE_SECTIONS = ['mcp', 'skills', 'subagents'];
|
|
30
|
+
/** 段里的名单:数组段是名字列表,`mcp` 段是 serverName → `'*'` | 工具名数组。段缺席 = 空。 */
|
|
31
|
+
function segmentNames(value) {
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map((x) => String(x));
|
|
34
|
+
if (value && typeof value === 'object')
|
|
35
|
+
return Object.keys(value);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 一个档案的名单渲染(`_list` 用)。**点名而不是只给数量**:模型要判断"这个场景进入时会不会
|
|
40
|
+
* 关掉它正用的那台服务器",数量答不了这一句。`mcpNotes` 是档案里的备注覆盖,单独列一项。
|
|
41
|
+
*/
|
|
42
|
+
function archiveText(archive) {
|
|
43
|
+
if (!archive || typeof archive !== 'object')
|
|
44
|
+
return '未建(进入时 MCP / 技能 / 人设 三个域全部停用)';
|
|
45
|
+
const keys = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v) : []);
|
|
46
|
+
const list = (v) => (Array.isArray(v) ? v.map((x) => String(x)) : []);
|
|
47
|
+
const parts = [
|
|
48
|
+
'MCP ' + toolNameList(keys(archive.mcp)),
|
|
49
|
+
'技能 ' + toolNameList(list(archive.skills)),
|
|
50
|
+
'人设 ' + toolNameList(list(archive.subagents)),
|
|
51
|
+
];
|
|
52
|
+
const notes = keys(archive.mcpNotes);
|
|
53
|
+
if (notes.length)
|
|
54
|
+
parts.push('备注 ' + toolNameList(notes));
|
|
55
|
+
return parts.join(' | ');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 一个档案段的 before → after:`mcp 2 项 → 1 项(-B)`。
|
|
59
|
+
*
|
|
60
|
+
* 为什么按"项"而不是按"值"比:`mcp` 段的值可以是 `'*'`(整台)或工具名数组,值层面的收窄
|
|
61
|
+
* (`A: '*'` → `A: ['read']`)**名字没变但含义变了** —— 那种情况单独标一句"勾选范围有调整",
|
|
62
|
+
* 不硬塞进 +/- 名单里(否则一个名字完全没变的段会显得像动过名单)。
|
|
63
|
+
*/
|
|
64
|
+
function segmentDiff(key, beforeValue, afterValue) {
|
|
65
|
+
const b = segmentNames(beforeValue);
|
|
66
|
+
const a = segmentNames(afterValue);
|
|
67
|
+
const dropped = b.filter((n) => a.indexOf(n) < 0);
|
|
68
|
+
const added = a.filter((n) => b.indexOf(n) < 0);
|
|
69
|
+
const marks = [];
|
|
70
|
+
if (added.length)
|
|
71
|
+
marks.push('+' + toolNameList(added));
|
|
72
|
+
if (dropped.length)
|
|
73
|
+
marks.push('-' + toolNameList(dropped));
|
|
74
|
+
if (!marks.length && JSON.stringify(beforeValue ?? null) !== JSON.stringify(afterValue ?? null))
|
|
75
|
+
marks.push('勾选范围有调整');
|
|
76
|
+
return {
|
|
77
|
+
text: key + ' ' + b.length + ' 项 → ' + a.length + ' 项' + (marks.length ? '(' + marks.join(' ') + ')' : ''),
|
|
78
|
+
dropped,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `withSync` 产物(`agentsMd`)的一行回执:切换场景会**改写 `~/.dsh/AGENTS.md`** ——
|
|
83
|
+
* 这是本插件唯一会动那个文件的动作,不写出来的话"提示词没跟着换"是静默的
|
|
84
|
+
* (场景切过去了、全局基线还是上一份,模型只会以为自己已经在新场景里)。
|
|
85
|
+
*
|
|
86
|
+
* 探测不到 / 什么都没做(`unchanged`)就不占行:那两种情况下文件确实没变,
|
|
87
|
+
* 说一句"没变"只是噪音。
|
|
88
|
+
*/
|
|
89
|
+
function agentsMdNote(sync) {
|
|
90
|
+
if (!sync || typeof sync !== 'object')
|
|
91
|
+
return '';
|
|
92
|
+
if (sync.error)
|
|
93
|
+
return '\nWARN: 全局基线(AGENTS.md)未同步:' + String(sync.error);
|
|
94
|
+
if (sync.applied)
|
|
95
|
+
return '\nAGENTS.md ← 场景绑定的预设「' + String(sync.applied) + '」';
|
|
96
|
+
if (sync.restored === true)
|
|
97
|
+
return '\nAGENTS.md ← 已恢复进场景前的基线';
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
export function buildSceneTools(deps) {
|
|
101
|
+
const { defineTool, register } = deps;
|
|
102
|
+
register(defineTool({
|
|
103
|
+
name: 'scene_manager_list',
|
|
104
|
+
// 只读、无参数:场景是几十个名字而不是几千条条目,没有分页的必要,也没有"默认只看启用的"
|
|
105
|
+
// 这种筛选(未启用的场景照样要能被进入,藏着只会让模型以为它不存在)。
|
|
106
|
+
// 描述与参数说明里**不点名同族工具**(scene_manager_list / _switch / _save 三条都在出厂
|
|
107
|
+
// 默认的隐藏名单里):静态文本没法按 toolVisible 改写,点名一条模型没有的工具只会让它
|
|
108
|
+
// 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the「本机当前的场景」reminder
|
|
109
|
+
// —— 后者是常驻源,只要场景域开着就在)。点名只留在**运行时回执**里、按可见性分叉。
|
|
110
|
+
description: 'List every scene with its archive (what entering it switches on), bound prompt preset, memory count and enabled / entered / locked state. Read the archive here before saving a scene: a section you give later replaces it whole. Read-only; the scene in effect now is also the「本机当前的场景」reminder.',
|
|
111
|
+
parameters: {},
|
|
112
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
113
|
+
async execute() {
|
|
114
|
+
const r = await deps.rulesOps['rules-list']({});
|
|
115
|
+
if (!r || r.ok === false)
|
|
116
|
+
throw new Error((r && r.error) || '读取场景失败');
|
|
117
|
+
// 档案与「当前进入的是哪个」要额外读一次(纯读盘,`scene-mode-get`)。
|
|
118
|
+
// 读不到**不阻断**清单:场景行退化成只有名字与启用状态,而不是让整条工具失败 ——
|
|
119
|
+
// 一份少了档案的清单仍然有用,一次失败什么都没有。
|
|
120
|
+
// `archives = null` 是"没读到",与"读到了但某个场景没建档案"是两件事:混成一份会让
|
|
121
|
+
// 每个场景都印出「档案:未建(进入时三个域全部停用)」,那是一次读盘失败的**假话**。
|
|
122
|
+
let archives = {};
|
|
123
|
+
let entered = null;
|
|
124
|
+
try {
|
|
125
|
+
const a = await deps.archiveOps['scene-mode-get']({});
|
|
126
|
+
if (a && a.ok !== false) {
|
|
127
|
+
archives = a.archives && typeof a.archives === 'object' ? a.archives : {};
|
|
128
|
+
entered = a.mode && a.mode.scene ? String(a.mode.scene) : null;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
archives = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
archives = null; /* 见上:降级为"没有档案信息" */
|
|
136
|
+
}
|
|
137
|
+
const scenes = r.scenes || [];
|
|
138
|
+
const lines = scenes.map((s) => {
|
|
139
|
+
const name = String(s.name);
|
|
140
|
+
const state = [s.active === true ? '启用' : '未启用'];
|
|
141
|
+
// 「启用」与「进入」是两个独立状态轴(见 README「进/出模式」),只报前者的话,
|
|
142
|
+
// 模型会把"启用着"读成"现在就是它" —— 而记忆与提示词只按**进入**的那一个注入。
|
|
143
|
+
if (entered !== null && name === entered)
|
|
144
|
+
state.push('已进入');
|
|
145
|
+
if (s.locked === true)
|
|
146
|
+
state.push('已锁定');
|
|
147
|
+
// 打**场景名**而不是 label:`scene_manager_save` / `_switch` 要的都是名字(单个
|
|
148
|
+
// 路径段),label 只是界面显示名。两者不同时把 label 放进括号,别让模型照抄错那个。
|
|
149
|
+
const title = name + (s.label && String(s.label) !== name ? '(' + String(s.label) + ')' : '');
|
|
150
|
+
const bits = ['- ' + title + ' [' + state.join('·') + '] · 记忆 ' + Number(s.count || 0) + ' 条'];
|
|
151
|
+
if (s.description)
|
|
152
|
+
bits.push('说明:' + String(s.description));
|
|
153
|
+
if (s.prompt)
|
|
154
|
+
bits.push('预设:' + String(s.prompt));
|
|
155
|
+
bits.push('档案:' + (archives === null ? '读不到(不代表没建)' : archiveText(archives[name])));
|
|
156
|
+
return bits.join(' · ');
|
|
157
|
+
});
|
|
158
|
+
return '场景(' + scenes.length + ' 个 · ' +
|
|
159
|
+
(r.activeMode === 'all' ? '启用集为历史默认(全部启用)' : '启用集已收窄为单选') +
|
|
160
|
+
(entered === null ? ';当前没有进入任何场景' : ';当前已进入「' + entered + '」') + '):\n' +
|
|
161
|
+
(lines.join('\n') || '(无场景)');
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
register(defineTool({
|
|
165
|
+
name: 'scene_manager_switch',
|
|
166
|
+
// 为什么 `action` 是必填枚举而不是一个布尔:与 `mcp_manager_switch` 同一条理由 ——
|
|
167
|
+
// 让模型在 enter / exit 两个动词里选一个,比让它猜"scene 传 null 是什么意思"省一次往返。
|
|
168
|
+
// 参数里刻意不出现 `null`:那是 op(`scene-mode-set`)的形态,模型照着填只会填错。
|
|
169
|
+
description: 'Enter a scene, or leave the one currently entered. Entering switches on exactly what its archive lists and everything else off, narrows injection to its memories, and applies its bound prompt preset to AGENTS.md. Leaving restores the runtime snapshot taken on entry. Check the scene\'s archive in the scene listing before entering. Only when the user asks or approves.',
|
|
170
|
+
parameters: {
|
|
171
|
+
action: { type: 'string', required: true, enum: ['enter', 'exit'], description: 'enter = switch to the scene named in `scene`; exit = leave the current one (no scene needed).' },
|
|
172
|
+
scene: { type: 'string', description: 'Scene name (one path segment), required for enter. Use the exact name, not the display label — the scene listing and the「本机当前的场景」reminder both print names.' },
|
|
173
|
+
},
|
|
174
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
175
|
+
async execute(args) {
|
|
176
|
+
const action = String((args && args.action) || '');
|
|
177
|
+
if (action !== 'enter' && action !== 'exit') {
|
|
178
|
+
throw new Error('action 只能是 enter / exit(收到:' + (action || '(空)') + ')');
|
|
179
|
+
}
|
|
180
|
+
// 刻意**不过** `lockedSceneGuard()`:场景**自身**的启停不在冻结清单里(冻结的是五个
|
|
181
|
+
// 域的内容)。锁定中的场景正被进入时不能退出 —— 那条守卫在 `scene-mode-set` 的包装里
|
|
182
|
+
// (index.ts),它以 ok:false 返回,下面如实抛出去。
|
|
183
|
+
let scene = '';
|
|
184
|
+
if (action === 'enter') {
|
|
185
|
+
scene = String((args && args.scene) || '').trim();
|
|
186
|
+
if (scene === '')
|
|
187
|
+
throw new Error('action=enter 需要 scene:场景名(照抄 scene_manager_list 里那一行)');
|
|
188
|
+
// 场景名是**单个路径段**(与 scene_manager_save 同一条校验、同一句报错)。
|
|
189
|
+
if (scene.indexOf('/') >= 0 || scene.indexOf('\\') >= 0) {
|
|
190
|
+
throw new Error('场景名是单个路径段,不能含 / 或 \\:' + scene);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 先读一次"现在在哪个场景":用来在回执里说清是"从 A 换到 B"还是"本来就在 B"。
|
|
194
|
+
// 读不到不算失败 —— 下面 `scene-mode-set` 自己会校验场景是否存在。
|
|
195
|
+
let before = null;
|
|
196
|
+
try {
|
|
197
|
+
const g = await deps.archiveOps['scene-mode-get']({});
|
|
198
|
+
if (g && g.ok !== false)
|
|
199
|
+
before = g.mode && g.mode.scene ? String(g.mode.scene) : null;
|
|
200
|
+
}
|
|
201
|
+
catch { /* 见上 */ }
|
|
202
|
+
// 两轴齐动,与界面「进入场景」/「退出模式」逐字同一条路径(45-scenes.js 的 enterMode /
|
|
203
|
+
// exitMode):`scene-mode-set` 只动运行时快照与 mode 位,**启用集合**由
|
|
204
|
+
// `rules-set-active` 写。少调后者,记忆与提示词仍按旧场景注入 —— 而运行时看起来已经切了。
|
|
205
|
+
const mode = await deps.archiveOps['scene-mode-set']({ scene: action === 'enter' ? scene : null });
|
|
206
|
+
if (!mode || mode.ok === false)
|
|
207
|
+
throw new Error((mode && mode.error) || (action === 'enter' ? '进入场景失败:' + scene : '退出场景失败'));
|
|
208
|
+
const act = await deps.sceneActivate({ scenes: action === 'enter' ? [scene] : [] });
|
|
209
|
+
if (!act || act.ok === false) {
|
|
210
|
+
// 半成品状态:运行时已经切了,启用集合没跟上。**必须说出来** —— 不说的话模型以为
|
|
211
|
+
// 切完了,而记忆与提示词还是旧场景的。重试一次是安全的(两个 op 都幂等)。
|
|
212
|
+
throw new Error((action === 'enter' ? '场景「' + scene + '」的运行时已切换' : '运行时已退出场景') +
|
|
213
|
+
',但启用集合未写入(记忆与提示词仍按旧场景注入):' + ((act && act.error) || '未知错误') +
|
|
214
|
+
' —— 重试一次 scene_manager_switch 即可(两个 op 都幂等)');
|
|
215
|
+
}
|
|
216
|
+
// 回执:说清"从哪到哪 + 实际切了几台 / 几个来源 + 有没有键被丢弃 + AGENTS.md 那一步"。
|
|
217
|
+
// 都是界面会如实报的同一批事实(`memory.result.modeSet` / `modeSwitched` / `archive.stale`),
|
|
218
|
+
// 少报一项,模型就会以为那次切换"完全干净"。
|
|
219
|
+
const noop = mode.applied === null || mode.applied === undefined;
|
|
220
|
+
const head = noop
|
|
221
|
+
? 'OK: already in scene「' + scene + '」'
|
|
222
|
+
: action === 'enter'
|
|
223
|
+
? 'OK: ' + (before === null ? 'entered' : '「' + before + '」→') + ' scene「' + scene + '」'
|
|
224
|
+
: 'OK: left the scene' + (before === null ? '' : '「' + before + '」');
|
|
225
|
+
const sw = (mode && mode.switched) || {};
|
|
226
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
227
|
+
const parts = [];
|
|
228
|
+
if (count(sw.mcpServers) || count(sw.skillSources)) {
|
|
229
|
+
parts.push('switched ' + count(sw.mcpServers) + ' MCP server(s) / ' + count(sw.skillSources) + ' skill source(s)');
|
|
230
|
+
}
|
|
231
|
+
if (Array.isArray(mode.stale) && mode.stale.length) {
|
|
232
|
+
parts.push('丢弃了本机不存在的键:' + mode.stale.join('、'));
|
|
233
|
+
}
|
|
234
|
+
return head + (parts.length ? ' · ' + parts.join(' · ') : '') + agentsMdNote(act.agentsMd) +
|
|
235
|
+
(noop ? '\n(目标就是当前场景,运行时没有改动)' : '');
|
|
236
|
+
},
|
|
237
|
+
}));
|
|
238
|
+
register(defineTool({
|
|
239
|
+
name: 'scene_manager_save',
|
|
240
|
+
// 「写入指引」保留("写成指令而不是备注"—— 这是场景说明质量的杠杆),但不再声称
|
|
241
|
+
// "注入后模型会被要求照办":2026-09-23 定稿把场景段的授权语删了(注入只陈述现状),
|
|
242
|
+
// 参数说明与注入侧各说各话时,模型信的那份是错的。改为如实说明去向。
|
|
243
|
+
description: 'Save a scene — a named bundle the user enters: entering it switches on exactly what its archive lists and everything else off. Creates it if absent, else updates it. An archive section you give replaces that section whole — include what should stay; the receipt reports what got displaced. Saving does not enter it; entering is a separate action. Only on the user\'s instruction.',
|
|
244
|
+
parameters: {
|
|
245
|
+
scene: { type: 'string', required: true, description: 'Scene name (one path segment, no slashes). Created if absent.' },
|
|
246
|
+
label: { type: 'string', description: 'Display name shown in the UI. Omit = keep.' },
|
|
247
|
+
description: { type: 'string', description: 'What this scene is for; shown beside the scene name in the「本机当前的场景」reminder while the scene is on — write an instruction, not a note. Omit = keep.' },
|
|
248
|
+
prompt: { type: 'string', description: 'Prompt preset id to bind; while this scene is on it is the global baseline. Omit = keep.' },
|
|
249
|
+
mcp: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
// 官方要求对象类型**显式声明开放性**(`additionalProperties` 是必需字段,且只能是
|
|
252
|
+
// boolean)—— 嵌套对象不允许带 schema,所以值的形状(`'*'` 还是工具名数组)只能在
|
|
253
|
+
// 描述里说清。这不是偷懒:键是服务器名,本身是动态的。
|
|
254
|
+
additionalProperties: true,
|
|
255
|
+
description: "Archive: serverName -> '*' (whole server) or a list of tool names to leave on. Omit = keep; {} = switch none on.",
|
|
256
|
+
},
|
|
257
|
+
skills: { type: 'array', items: { type: 'string' }, description: 'Archive: skill keys `<root>/<name>` to switch on. Omit = keep; [] = switch none on.' },
|
|
258
|
+
subagents: { type: 'array', items: { type: 'string' }, description: 'Archive: persona names to switch on. Omit = keep; [] = switch none on.' },
|
|
259
|
+
},
|
|
260
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
261
|
+
async execute(args) {
|
|
262
|
+
const blocked = await deps.lockedSceneGuard();
|
|
263
|
+
if (blocked)
|
|
264
|
+
throw new Error(blocked);
|
|
265
|
+
const scene = String((args && args.scene) || '').trim();
|
|
266
|
+
if (scene === '')
|
|
267
|
+
throw new Error('缺少参数:scene 是场景名');
|
|
268
|
+
// 场景名是**单个路径段**(界面把场景当一级列表展示,场景名就是 `memories/<场景>/` 那级
|
|
269
|
+
// 目录名)。`rules-create-scene` 会做完整校验,但档案那条路不经过它 —— 这里先挡一次,
|
|
270
|
+
// 报错也更贴模型要的东西(它要的是"这个名字不行",不是内部规则)。
|
|
271
|
+
if (scene.indexOf('/') >= 0 || scene.indexOf('\\') >= 0) {
|
|
272
|
+
throw new Error('场景名是单个路径段,不能含 / 或 \\:' + scene);
|
|
273
|
+
}
|
|
274
|
+
// ① 场景记录:`rules-create-scene` 本身是**幂等 upsert**(已存在则更新描述/标签/绑定的
|
|
275
|
+
// 预设,不报错),所以不必先查再分建/改 —— 与记忆 save 的"有则改、无则建"同一口径。
|
|
276
|
+
// 总是调它:模型给 scene + 档案段而场景还不存在时,这一步把场景建出来,否则
|
|
277
|
+
// `scene-archive-save` 会以"场景不存在"拒绝。
|
|
278
|
+
const payload = { name: scene };
|
|
279
|
+
const touchedRecord = [];
|
|
280
|
+
if (args.label !== undefined) {
|
|
281
|
+
payload.label = String(args.label);
|
|
282
|
+
touchedRecord.push('label');
|
|
283
|
+
}
|
|
284
|
+
if (args.description !== undefined) {
|
|
285
|
+
payload.description = String(args.description);
|
|
286
|
+
touchedRecord.push('description');
|
|
287
|
+
}
|
|
288
|
+
if (args.prompt !== undefined) {
|
|
289
|
+
payload.prompt = String(args.prompt);
|
|
290
|
+
touchedRecord.push('prompt');
|
|
291
|
+
}
|
|
292
|
+
const created = await deps.rulesOps['rules-create-scene'](payload);
|
|
293
|
+
if (!created || created.ok === false)
|
|
294
|
+
throw new Error((created && created.error) || '保存场景失败');
|
|
295
|
+
// ② 档案三段。**必须 read-modify-write**:`scene-archive-save` 是**整份替换**
|
|
296
|
+
// (`slice.archives[scene] = archive`),只把模型给的段传过去,没给的段会被静默
|
|
297
|
+
// 清掉 —— 与 0.14.0 里 `subagent_manager_save` 省略字段被清空同一形状,只是这次
|
|
298
|
+
// 清掉的是"这个场景进入时要开哪些东西"。
|
|
299
|
+
// 注意档案的段语义:**段不存在 = 不限制(全部照常)**,段存在但为空 = 全部停用。
|
|
300
|
+
// 所以模型给 `mcp: {}` 得到的是"全不勾",不是"清除该段" —— 后者只能靠不给。
|
|
301
|
+
const touched = ARCHIVE_SECTIONS.filter((k) => args[k] !== undefined);
|
|
302
|
+
let archiveNote = '';
|
|
303
|
+
if (touched.length) {
|
|
304
|
+
const cur = await deps.archiveOps['scene-mode-get']({});
|
|
305
|
+
if (!cur || cur.ok === false)
|
|
306
|
+
throw new Error((cur && cur.error) || '读取场景档案失败');
|
|
307
|
+
const before = (cur.archives || {})[scene] || {};
|
|
308
|
+
const archive = { ...before };
|
|
309
|
+
for (const k of touched)
|
|
310
|
+
archive[k] = args[k];
|
|
311
|
+
const r = await deps.archiveOps['scene-archive-save']({ scene, archive });
|
|
312
|
+
if (!r || r.ok === false)
|
|
313
|
+
throw new Error((r && r.error) || '保存场景档案失败');
|
|
314
|
+
// 回执必须回显**这一次替换挤掉了什么**。整段替换本身是对的(省略的段不填回就会被
|
|
315
|
+
// 静默清掉,所以"模型给什么就是什么"),但"给出去的段盖掉了原来的勾选"是**静默**的
|
|
316
|
+
// —— 不写出来,模型只会看到一句"档案段:mcp",以为只是加了一项。
|
|
317
|
+
const diffs = touched.map((k) => segmentDiff(k, before[k], args[k]));
|
|
318
|
+
archiveNote = '档案段:' + diffs.map((d) => d.text).join(';');
|
|
319
|
+
if (diffs.some((d) => d.dropped.length > 0)) {
|
|
320
|
+
archiveNote += '\n注意:这些段是整段替换,上面标 - 的项已被挤掉;要保留就把它们一并写进这次调用。';
|
|
321
|
+
}
|
|
322
|
+
// 当前环境里不存在的键会被 op 丢弃并报告(改名或删除留下的):如实说,
|
|
323
|
+
// 否则模型以为勾上了而实际没有。
|
|
324
|
+
if (r.stale && r.stale.length)
|
|
325
|
+
archiveNote += '(丢弃了当前环境不存在的键:' + r.stale.join('、') + ')';
|
|
326
|
+
// 存盘成功但应用到运行时失败:`scene-archive-save` 对"正是当前模式的那个场景"会就地
|
|
327
|
+
// 重应用,失败不回滚档案。如实报出去,别假装生效了(与界面同口径)。
|
|
328
|
+
if (r.applyError)
|
|
329
|
+
archiveNote += '(已存盘,但应用到运行时失败:' + r.applyError + ')';
|
|
330
|
+
}
|
|
331
|
+
// 收敛提示:`rules-create-scene` 在"历史默认(全部启用)"状态下会把启用集合收窄成
|
|
332
|
+
// 单选(`collapseActiveForNewScene`)—— 那是一次**会改变运行时**的动作,必须说出来。
|
|
333
|
+
// 界面上同样如实报(`scenes.result.created.collapsed`)。
|
|
334
|
+
const collapsed = created.collapsedActive === true;
|
|
335
|
+
const parts = [touchedRecord.length ? '场景记录已更新(' + touchedRecord.join('、') + ')' : '场景已确保存在'];
|
|
336
|
+
if (archiveNote)
|
|
337
|
+
parts.push(archiveNote);
|
|
338
|
+
// 点名 scene_manager_switch 的那句按可见性分叉(它在出厂默认名单里就是关着的):
|
|
339
|
+
// 部分启用(开 save 不开 switch)时指向一条模型没有的工具,只会让它白跑一趟 ——
|
|
340
|
+
// 与 mcp/skill 的 listHint 同一条纪律。
|
|
341
|
+
const enterHint = deps.toolVisible('scene_manager_switch')
|
|
342
|
+
? '要让它生效,用 scene_manager_switch 进入。'
|
|
343
|
+
: '要让它生效,由用户在「场景」页进入。';
|
|
344
|
+
return 'OK: scene「' + scene + '」' + parts.join(';') +
|
|
345
|
+
(collapsed
|
|
346
|
+
? '\n注意:此前所有场景都处于启用状态(历史「全部启用」默认),本次已收窄为只启用一个,新建的这个不在其中 —— 让用户到「场景」页确认。'
|
|
347
|
+
: '') +
|
|
348
|
+
'\n保存不改运行时:' + enterHint;
|
|
349
|
+
},
|
|
350
|
+
}));
|
|
351
|
+
}
|
package/lib/tools/skills.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
// 技能域的 model 工具(2026-09-19 从 index.ts
|
|
2
|
-
// skill_manager_list / skill_manager_read /
|
|
1
|
+
// 技能域的 model 工具(2026-09-19 从 index.ts 的注册区抽出;2026-09-23 合并):
|
|
2
|
+
// skill_manager_list / skill_manager_read / skill_manager_switch / skill_manager_save。
|
|
3
|
+
//
|
|
4
|
+
// create → save:`skill-create` 对同名是**拒绝**的,所以"建还是改"的判定只能在工具这一侧
|
|
5
|
+
// (先读一次 `skill-state`)。改的分支走新加的 `skill-update` op,它只认 hub 里的胜出者。
|
|
6
|
+
import { toKebab } from '../skills/core.js';
|
|
3
7
|
import { text } from './deps.js';
|
|
4
8
|
export function buildSkillTools(deps) {
|
|
5
9
|
const { defineTool, register } = deps;
|
|
@@ -7,7 +11,7 @@ export function buildSkillTools(deps) {
|
|
|
7
11
|
// tools/pre-execute hook below (the model must ask before writing files).
|
|
8
12
|
register(defineTool({
|
|
9
13
|
name: 'skill_manager_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
|
|
14
|
+
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, for each entry\'s source-file path, and to see which source wins a name collision. Defaults to enabled only; all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
|
|
11
15
|
parameters: {
|
|
12
16
|
all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
|
|
13
17
|
},
|
|
@@ -112,9 +116,12 @@ export function buildSkillTools(deps) {
|
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
if (!hits.length) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
// 「去调 skill_manager_list」那句:那条工具在出厂默认里就是关着的,点名一条模型没有的
|
|
120
|
+
// 工具只会让它白跑一趟(执行侧拦住,而它看不出为什么)。
|
|
121
|
+
const listHint = deps.toolVisible('skill_manager_list')
|
|
122
|
+
? ' — call skill_manager_list (pass all=true) to see what is installed'
|
|
123
|
+
: '';
|
|
124
|
+
throw new Error('no skill named "' + wanted + '"' + (rootWanted ? ' in source "' + rootWanted + '"' : '') + listHint);
|
|
118
125
|
}
|
|
119
126
|
// 胜出者优先:同名的影子副本读得到,但那不是生效的一份,所以默认不选它。
|
|
120
127
|
const picked = hits.find((hit) => !hit.shadowedBy && hit.enabled)
|
|
@@ -146,18 +153,39 @@ export function buildSkillTools(deps) {
|
|
|
146
153
|
},
|
|
147
154
|
}));
|
|
148
155
|
register(defineTool({
|
|
149
|
-
name: '
|
|
150
|
-
description: 'Enable or disable one DSH skill (
|
|
156
|
+
name: 'skill_manager_switch',
|
|
157
|
+
description: 'Enable or disable one DSH skill (policy only; source files are never modified), or a whole source folder via `source`. Enabling a shadowed copy has no effect. Only when the user asks or approves.',
|
|
151
158
|
parameters: {
|
|
152
|
-
name: { type: 'string',
|
|
159
|
+
name: { type: 'string', description: 'Skill name (kebab-case).' },
|
|
153
160
|
enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
|
|
154
|
-
|
|
161
|
+
source: { type: 'string', description: 'A source root key to switch as a whole (dsh/hub/agents/codex/claude or a project key).' },
|
|
162
|
+
root: { type: 'string', description: 'Source root key the skill lives in (with `name`); default dsh.' },
|
|
155
163
|
},
|
|
156
164
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
157
165
|
async execute(args) {
|
|
158
166
|
const blocked = await deps.lockedSceneGuard();
|
|
159
167
|
if (blocked)
|
|
160
168
|
throw new Error(blocked);
|
|
169
|
+
const name = String((args && args.name) || '').trim();
|
|
170
|
+
const source = String((args && args.source) || '').trim();
|
|
171
|
+
if (name && source)
|
|
172
|
+
throw new Error('name 与 source 只能给一个:name 是单个技能,source 是整个来源目录');
|
|
173
|
+
if (!name && !source)
|
|
174
|
+
throw new Error('缺少参数:name(单个技能)或 source(整个来源目录)');
|
|
175
|
+
if (source) {
|
|
176
|
+
// 来源级启停:登记表里 `skill-source-enable` / `skill-source-disable` 都是
|
|
177
|
+
// `{ serviceWrite, frozen, syncsArchive }`,所以守卫已过、档案也要同步
|
|
178
|
+
// —— 同步的入参是 `{ root }`(来源级没有独立字段,档案靠"来源下有没有被勾的技能"反推)。
|
|
179
|
+
if (args.root !== undefined)
|
|
180
|
+
throw new Error('source 已经是来源级的键,不要再给 root');
|
|
181
|
+
const op = args.enabled ? 'skill-source-enable' : 'skill-source-disable';
|
|
182
|
+
const r = await deps.skillsOps[op]({ root: source });
|
|
183
|
+
if (!r || r.ok === false)
|
|
184
|
+
throw new Error((r && r.error) || 'skill source toggle failed');
|
|
185
|
+
const syncErr = await deps.syncSwitchToScene(op, { root: source, enabled: args.enabled === true });
|
|
186
|
+
return 'OK: source ' + source + ' now ' + (args.enabled ? 'enabled' : 'disabled') +
|
|
187
|
+
(syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
|
|
188
|
+
}
|
|
161
189
|
const root = String(args.root || 'dsh');
|
|
162
190
|
const op = args.enabled ? 'skill-enable' : 'skill-disable';
|
|
163
191
|
const r = await deps.skillsOps[op]({ name: args.name, root });
|
|
@@ -192,26 +220,74 @@ export function buildSkillTools(deps) {
|
|
|
192
220
|
},
|
|
193
221
|
}));
|
|
194
222
|
register(defineTool({
|
|
195
|
-
name: '
|
|
223
|
+
name: 'skill_manager_save',
|
|
196
224
|
// 落点必须和 UI「创建技能」一致:两者都走 core 的默认落点(hub 的
|
|
197
225
|
// tool-management/skills/,hub 缺失时退回 DSH_HOME/skills)。以前这里硬编码
|
|
198
226
|
// root:'dsh',于是同一个「新建技能」动作,人点界面和模型调用会落到两个不同的根。
|
|
199
|
-
|
|
227
|
+
// 改的分支**只认 hub 里的胜出者**:同名技能在 dsh/agents/claude 各有一份时只有一份
|
|
228
|
+
// 生效,改错那一份会返回 OK 而技能毫无变化 —— 那层判定在 `skill-update` op 里。
|
|
229
|
+
description: 'Create a DSH skill under DSH_HOME/tool-management/skills, or update the one that already has this name — the receipt says which. Only skills this plugin wrote can be updated. Use only when the user asks to save a reusable skill.',
|
|
200
230
|
parameters: {
|
|
201
231
|
name: { type: 'string', required: true, description: 'Skill name; normalized to kebab-case.' },
|
|
202
|
-
description: { type: 'string',
|
|
203
|
-
body: { type: 'string',
|
|
232
|
+
description: { type: 'string', description: 'A concise routing description. Required when creating; omit = keep.' },
|
|
233
|
+
body: { type: 'string', description: 'Markdown instructions. Required when creating; omit = keep.' },
|
|
204
234
|
},
|
|
205
235
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
206
236
|
async execute(args) {
|
|
207
237
|
const blocked = await deps.lockedSceneGuard();
|
|
208
238
|
if (blocked)
|
|
209
239
|
throw new Error(blocked);
|
|
210
|
-
const
|
|
240
|
+
const wanted = String((args && args.name) || '').trim();
|
|
241
|
+
if (!wanted)
|
|
242
|
+
throw new Error('缺少参数:name');
|
|
243
|
+
const kebab = toKebab(wanted);
|
|
244
|
+
if (!kebab)
|
|
245
|
+
throw new Error('无法生成合法 kebab-case 名称(原始名: ' + wanted + ')');
|
|
246
|
+
// 建还是改:先看这个(规范化后的)名字在不在。`skill-create` 对同名是**拒绝**的,
|
|
247
|
+
// 所以这条判定只能在这里做 —— 不能指望 op 层兜,也不能让模型自己先查一次
|
|
248
|
+
// (它手里的清单可能已经过期)。
|
|
249
|
+
const state = await deps.skillsOps['skill-state']({});
|
|
250
|
+
if (!state || state.ok === false)
|
|
251
|
+
throw new Error((state && state.error) || 'skill-state failed');
|
|
252
|
+
let exists = false;
|
|
253
|
+
for (const root of ((state.data && state.data.roots) || [])) {
|
|
254
|
+
for (const skill of root.skills || []) {
|
|
255
|
+
// 与 list / read 同一套名字口径:`declaredName || name`,再比目录名。
|
|
256
|
+
if (String(skill.declaredName || skill.name || '') === kebab || String(skill.name || '') === kebab)
|
|
257
|
+
exists = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!exists) {
|
|
261
|
+
// 建的分支:正文与简介都必填。core 自己会拒(error.create.descriptionRequired /
|
|
262
|
+
// bodyRequired),但那是给用户看的 op 错;模型需要的是"这条工具要什么"。
|
|
263
|
+
const description = String((args && args.description) || '').trim();
|
|
264
|
+
const body = String((args && args.body) || '').trim();
|
|
265
|
+
const missing = [
|
|
266
|
+
description === '' ? 'description' : '',
|
|
267
|
+
body === '' ? 'body' : '',
|
|
268
|
+
].filter((x) => x !== '');
|
|
269
|
+
if (missing.length)
|
|
270
|
+
throw new Error('新建技能必须给 ' + missing.join(' 与 ') + '(' + kebab + ' 还不存在)');
|
|
271
|
+
const r = await deps.skillsOps['skill-create']({ name: wanted, description: args.description, body: args.body });
|
|
272
|
+
if (!r || r.ok === false)
|
|
273
|
+
throw new Error((r && r.error) || 'skill create failed');
|
|
274
|
+
const data = r.data || {};
|
|
275
|
+
return 'Created DSH skill ' + (data.name || kebab) + ' at ' + (data.path || '(unknown)');
|
|
276
|
+
}
|
|
277
|
+
// 改的分支:省略 = 保持。两个可改字段一个都没给就拒 —— op 会按"保持原样"重写一次文件
|
|
278
|
+
// 并刷新时间戳,模型以为改了点什么,其实只是把同一份内容又写了一遍。
|
|
279
|
+
const touched = [];
|
|
280
|
+
if (args.description !== undefined)
|
|
281
|
+
touched.push('description');
|
|
282
|
+
if (args.body !== undefined)
|
|
283
|
+
touched.push('body');
|
|
284
|
+
if (!touched.length)
|
|
285
|
+
throw new Error('没有要改的东西:description / body 至少给一个');
|
|
286
|
+
const r = await deps.skillsOps['skill-update']({ name: wanted, description: args.description, body: args.body });
|
|
211
287
|
if (!r || r.ok === false)
|
|
212
|
-
throw new Error((r && r.error) || 'skill
|
|
288
|
+
throw new Error((r && r.error) || 'skill update failed');
|
|
213
289
|
const data = r.data || {};
|
|
214
|
-
return '
|
|
290
|
+
return 'Updated DSH skill ' + (data.name || kebab) + ' at ' + (data.path || '(unknown)') + '(改了 ' + touched.join('、') + ')';
|
|
215
291
|
},
|
|
216
292
|
}));
|
|
217
293
|
}
|