dsh-plugin-tool-management 0.19.1 → 0.20.1
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 +915 -829
- package/README.md +237 -254
- package/README_EN.md +234 -223
- 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/images/logo.gif +0 -0
- package/docs/update.md +32 -30
- package/lib/client.js +9480 -6970
- package/lib/compat/preset-reach.js +5 -2
- package/lib/context-inject-frame.js +13 -8
- package/lib/hub.js +104 -11
- package/lib/index.js +72 -24
- package/lib/mcp/manager.js +45 -13
- package/lib/mcp/secret-guard.js +2 -2
- package/lib/mcp/state-section.js +1 -1
- package/lib/memories/archive-engine.js +4 -5
- package/lib/memories/constants.js +11 -4
- package/lib/memories/service.js +6 -1
- package/lib/op-registry.js +13 -11
- package/lib/ops/memory.js +42 -4
- package/lib/ops/scene-records.js +43 -9
- package/lib/ops/sessions.js +67 -46
- package/lib/ops/trash.js +107 -8
- package/lib/request-gate.js +9 -2
- package/lib/scene-prompt-sync.js +48 -23
- package/lib/sessions/history.js +32 -22
- package/lib/sessions/workspace.js +0 -10
- package/lib/skills/catalog.js +17 -5
- package/lib/skills/core.js +189 -7
- package/lib/skills/frontmatter.js +31 -0
- package/lib/skills/manager-state.js +73 -3
- package/lib/skills/service.js +53 -13
- package/lib/skills/skill-roots.js +54 -4
- package/lib/skills/skill-trash.js +118 -12
- package/lib/skills/upload-pipeline.js +110 -26
- package/lib/subagents/catalog.js +9 -5
- package/lib/subagents/service.js +44 -6
- package/lib/subagents/tools.js +45 -15
- package/lib/tool-table-switch.js +6 -6
- package/lib/tools/deps.js +43 -0
- package/lib/tools/mcp.js +74 -27
- package/lib/tools/memory.js +49 -9
- package/lib/tools/prompt.js +38 -9
- package/lib/tools/remove.js +206 -0
- package/lib/tools/scene.js +155 -32
- package/lib/tools/skills.js +162 -55
- package/lib/tools/subagent.js +65 -31
- package/lib/tools/table.js +39 -12
- package/lib/trash-id.js +16 -0
- package/lib/trash-retention.js +3 -3
- package/package.json +115 -114
- package/lib/mcp/inspect-probe.js +0 -199
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// 删除 / 回收站族的 model 工具(2026-10-15):五个域各一条 `*_manager_remove`。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一族:此前模型**只能停用、不能删除** —— "把这条过时的记忆删掉"这类要求手里
|
|
4
|
+
// 没有出口,模型会去调 `*_switch(enabled:false)` 然后回一句"已停用",用户以为删了。面板上
|
|
5
|
+
// 每个域都有删除,模型侧一条都没有。
|
|
6
|
+
//
|
|
7
|
+
// 三条纪律:
|
|
8
|
+
// · **删除一律进回收站**(可恢复),与面板同一条路 —— 回执如实说"可恢复"。
|
|
9
|
+
// · **不做 purge**(永久删除):低频且不可恢复,用户裁定"使用频率低的可以不要,
|
|
10
|
+
// 太多功能反而冗余" —— 清空回收站留给面板。
|
|
11
|
+
// · `list` 是只读(不进场景冻结守卫,与 `trash-preview` 只读口径一致);`trash` /
|
|
12
|
+
// `restore` 是写,过 `lockedSceneGuard()`。
|
|
13
|
+
//
|
|
14
|
+
// 参数名统一 `id`:`trash` 时是资源标识(记忆 `<场景>/<名>`、技能名、人设名、预设 id、
|
|
15
|
+
// 场景名),`restore` 时是 `action=list` 给出的**回收站条目 id** —— 两种含义在描述里写清。
|
|
16
|
+
import { locateSkillRoot, text } from './deps.js';
|
|
17
|
+
/** 三个动词共用的描述骨架。 */
|
|
18
|
+
function removeDescription(noun) {
|
|
19
|
+
return 'Delete one ' + noun + ' (moves it to the trash), restore one from the trash, or list the trash. '
|
|
20
|
+
+ 'Nothing here is permanent — emptying the trash is a panel action. '
|
|
21
|
+
+ 'Blocked while the active scene is locked. Only on the user\'s instruction.';
|
|
22
|
+
}
|
|
23
|
+
/** `action` 参数:三个动词 + 每个动词要哪个 `id`。 */
|
|
24
|
+
const ACTION_DESC = 'trash = delete the item named in `id` (moves it to the trash); restore = put back the trash entry `id` (get its id from action=list); list = list the trash (no `id`).';
|
|
25
|
+
/**
|
|
26
|
+
* 恢复 op 回传的**最终落盘名**。五个域把名字放在不同字段上:
|
|
27
|
+
* · 记忆 / 技能 / 人设 / 场景 → `name`(退号恢复时它是新名字,不是回收站里的原名)
|
|
28
|
+
* · 提示词预设 → `id`(预设的 id 就是它的名字)
|
|
29
|
+
* 记忆还会把投影对象放在 `rule.id` 上,作为第三顺位兜底。
|
|
30
|
+
*
|
|
31
|
+
* 还要解一层 `data`(2026-10-15 复核 P1-6 时漏掉的那半):技能的 op 表外面包了
|
|
32
|
+
* `wrap`(`skills/service.ts:239` —— `{ ok: true, data: result }`),名字在 `r.data.name`,
|
|
33
|
+
* 不在 `r.name` 上。其余四个域的 op 是扁平返回。两边都要看,否则技能域的回执仍会退回 trashId。
|
|
34
|
+
*/
|
|
35
|
+
function restoredName(r) {
|
|
36
|
+
const pick = (o) => {
|
|
37
|
+
if (!o || typeof o !== 'object')
|
|
38
|
+
return '';
|
|
39
|
+
if (typeof o.name === 'string' && o.name !== '')
|
|
40
|
+
return o.name;
|
|
41
|
+
if (o.rule && typeof o.rule.id === 'string' && o.rule.id !== '')
|
|
42
|
+
return o.rule.id;
|
|
43
|
+
if (typeof o.id === 'string' && o.id !== '')
|
|
44
|
+
return o.id;
|
|
45
|
+
return '';
|
|
46
|
+
};
|
|
47
|
+
return pick(r) || pick(r && r.data);
|
|
48
|
+
}
|
|
49
|
+
/** 一条删除工具的 execute:三个动词的分派与回执。 */
|
|
50
|
+
async function runRemove(deps, cfg, args) {
|
|
51
|
+
const action = String((args && args.action) || '');
|
|
52
|
+
if (action !== 'trash' && action !== 'restore' && action !== 'list') {
|
|
53
|
+
throw new Error('action 只能是 trash / restore / list(收到:' + (action || '(空)') + ')');
|
|
54
|
+
}
|
|
55
|
+
if (action === 'list') {
|
|
56
|
+
const entries = await cfg.list();
|
|
57
|
+
return entries.length
|
|
58
|
+
? '回收站(' + entries.length + ' 条):\n' + entries.map((e) => '- ' + e.id + ' · ' + e.name + (e.deletedAt ? ' · ' + e.deletedAt : '')).join('\n')
|
|
59
|
+
: '回收站是空的。';
|
|
60
|
+
}
|
|
61
|
+
// 进 / 出回收站都是写:过场景冻结守卫(与面板的删除按钮同一档)。
|
|
62
|
+
const blocked = await deps.lockedSceneGuard();
|
|
63
|
+
if (blocked)
|
|
64
|
+
throw new Error(blocked);
|
|
65
|
+
const id = String((args && args.id) || '').trim();
|
|
66
|
+
if (id === '')
|
|
67
|
+
throw new Error('缺少参数:id(action=' + action + ' 需要)');
|
|
68
|
+
const r = action === 'trash' ? await cfg.trash(args) : await cfg.restore(args);
|
|
69
|
+
if (!r || r.ok === false)
|
|
70
|
+
throw new Error((r && r.error) || (action === 'trash' ? '移入回收站失败' : '恢复失败'));
|
|
71
|
+
if (action === 'trash') {
|
|
72
|
+
return 'OK: ' + cfg.label + '「' + id + '」已移入回收站(可恢复;永久删除在面板的回收站里)';
|
|
73
|
+
}
|
|
74
|
+
// 恢复的回执要说**恢复成了什么**,而不是回收站条目 id(2026-10-15 smoke-test P1-6):
|
|
75
|
+
// `id` 是 `action=list` 给的那一串(记忆 `mv…`、技能 `<时间戳>-<uuid>`),模型看到它
|
|
76
|
+
// 认不出恢复了哪一条;退号恢复时最终落盘的名字还与回收站里的原名不同,更要说准。
|
|
77
|
+
const name = restoredName(r) || id;
|
|
78
|
+
return 'OK: 已从回收站恢复 ' + cfg.label + '「' + name + '」' +
|
|
79
|
+
(name === id ? '' : '(回收站条目 ' + id + ')');
|
|
80
|
+
}
|
|
81
|
+
export function buildRemoveTools(deps) {
|
|
82
|
+
const { defineTool, register } = deps;
|
|
83
|
+
const mem = deps.memoryOps;
|
|
84
|
+
const sk = deps.skillOps;
|
|
85
|
+
const sub = deps.subagentOps;
|
|
86
|
+
const pr = deps.promptOps;
|
|
87
|
+
const sc = deps.sceneOps;
|
|
88
|
+
const memoryCfg = {
|
|
89
|
+
noun: 'memory', label: '记忆',
|
|
90
|
+
trash: (a) => mem['rules-remove']({ id: String(a.id || '') }),
|
|
91
|
+
restore: (a) => mem['rules-restore']({ trashId: String(a.id || '') }),
|
|
92
|
+
list: async () => {
|
|
93
|
+
const r = await mem['rules-trash-list']({});
|
|
94
|
+
if (!r || r.ok === false)
|
|
95
|
+
throw new Error((r && r.error) || '读取回收站失败');
|
|
96
|
+
return (r.entries || []).map((e) => ({ id: String(e.trashId), name: String(e.name), deletedAt: e.deletedAt }));
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const skillCfg = {
|
|
100
|
+
noun: 'skill', label: '技能',
|
|
101
|
+
// 与 `skill_manager_switch` 同一条(smoke-test P0-1):不给 root 就按名字在全部来源里
|
|
102
|
+
// 找胜出的那一份。默认值原本是 dsh,而 `skill_manager_save` 落 hub —— 「建完就删」
|
|
103
|
+
// 必然报「技能不存在」。删一个真的不存在的技能时退回 dsh,交给 op 报它自己的错。
|
|
104
|
+
trash: async (a) => {
|
|
105
|
+
const name = String((a && a.id) || '');
|
|
106
|
+
const explicit = String((a && a.root) || '').trim();
|
|
107
|
+
const root = explicit !== '' ? explicit : ((await locateSkillRoot(sk, name)).root || 'dsh');
|
|
108
|
+
return sk['skill-delete']({ root, name });
|
|
109
|
+
},
|
|
110
|
+
restore: (a) => sk['skill-trash-restore']({ id: String(a.id || '') }),
|
|
111
|
+
// 技能没有独立的回收站列表 op:它挂在 `skill-state` 的 `data.trash` 上(界面同一条来源)。
|
|
112
|
+
list: async () => {
|
|
113
|
+
const r = await sk['skill-state']({});
|
|
114
|
+
if (!r || r.ok === false)
|
|
115
|
+
throw new Error((r && r.error) || 'skill-state failed');
|
|
116
|
+
return ((r.data && r.data.trash) || []).map((e) => ({ id: String(e.id), name: String(e.name), deletedAt: e.deletedAt }));
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const subagentCfg = {
|
|
120
|
+
noun: 'persona', label: '人设',
|
|
121
|
+
trash: (a) => sub['subagent-delete']({ name: String(a.id || '') }),
|
|
122
|
+
restore: (a) => sub['subagent-trash-restore']({ id: String(a.id || '') }),
|
|
123
|
+
list: async () => {
|
|
124
|
+
const r = await sub['subagent-trash-list']({});
|
|
125
|
+
if (!r || r.ok === false)
|
|
126
|
+
throw new Error((r && r.error) || '读取回收站失败');
|
|
127
|
+
return (r.trash || []).map((e) => ({ id: String(e.id), name: String(e.name), deletedAt: e.deletedAt }));
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const promptCfg = {
|
|
131
|
+
noun: 'preset', label: '提示词预设',
|
|
132
|
+
trash: (a) => pr['agentsmd-remove']({ id: String(a.id || '') }),
|
|
133
|
+
restore: (a) => pr['agentsmd-trash-restore']({ id: String(a.id || '') }),
|
|
134
|
+
list: async () => {
|
|
135
|
+
const r = await pr['agentsmd-trash-list']({});
|
|
136
|
+
if (!r || r.ok === false)
|
|
137
|
+
throw new Error((r && r.error) || '读取回收站失败');
|
|
138
|
+
const arr = Array.isArray(r) ? r : (r.presets || r.trash || r.entries || []);
|
|
139
|
+
return arr.map((e) => ({ id: String(e.id), name: String(e.name || e.id), deletedAt: e.deletedAt }));
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const sceneCfg = {
|
|
143
|
+
noun: 'scene', label: '场景',
|
|
144
|
+
// 删场景走 handlers 里那份(`withAgentsMdSync` 包过):直调 `memoriesService.ops` 会漏掉
|
|
145
|
+
// 「场景绑的预设曾写进 AGENTS.md,删了要跟着处理」那一步。
|
|
146
|
+
trash: (a) => deps.sceneRemove({ name: String(a.id || '') }),
|
|
147
|
+
restore: (a) => sc['scene-trash-restore']({ id: String(a.id || '') }),
|
|
148
|
+
list: async () => {
|
|
149
|
+
const r = await sc['scene-trash-list']({});
|
|
150
|
+
if (!r || r.ok === false)
|
|
151
|
+
throw new Error((r && r.error) || '读取回收站失败');
|
|
152
|
+
return (r.trash || []).map((e) => ({ id: String(e.id), name: String(e.name), deletedAt: e.deletedAt }));
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
register(defineTool({
|
|
156
|
+
name: 'memory_manager_remove',
|
|
157
|
+
description: removeDescription('memory'),
|
|
158
|
+
parameters: {
|
|
159
|
+
action: { type: 'string', required: true, enum: ['trash', 'restore', 'list'], description: ACTION_DESC },
|
|
160
|
+
id: { type: 'string', description: 'For trash: the memory id `<scene>/<name>`. For restore: the trash entry id from action=list. Not needed for list.' },
|
|
161
|
+
},
|
|
162
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
163
|
+
async execute(args) { return runRemove(deps, memoryCfg, args); },
|
|
164
|
+
}));
|
|
165
|
+
register(defineTool({
|
|
166
|
+
name: 'skill_manager_remove',
|
|
167
|
+
description: removeDescription('skill'),
|
|
168
|
+
parameters: {
|
|
169
|
+
action: { type: 'string', required: true, enum: ['trash', 'restore', 'list'], description: ACTION_DESC },
|
|
170
|
+
id: { type: 'string', description: 'For trash: the skill name. For restore: the trash entry id from action=list. Not needed for list.' },
|
|
171
|
+
root: { type: 'string', description: 'Source root the skill lives in. Default: whichever source holds that skill; only used by action=trash.' },
|
|
172
|
+
},
|
|
173
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
174
|
+
async execute(args) { return runRemove(deps, skillCfg, args); },
|
|
175
|
+
}));
|
|
176
|
+
register(defineTool({
|
|
177
|
+
name: 'subagent_manager_remove',
|
|
178
|
+
description: removeDescription('persona'),
|
|
179
|
+
parameters: {
|
|
180
|
+
action: { type: 'string', required: true, enum: ['trash', 'restore', 'list'], description: ACTION_DESC },
|
|
181
|
+
id: { type: 'string', description: 'For trash: the persona name. For restore: the trash entry id from action=list. Not needed for list.' },
|
|
182
|
+
},
|
|
183
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
184
|
+
async execute(args) { return runRemove(deps, subagentCfg, args); },
|
|
185
|
+
}));
|
|
186
|
+
register(defineTool({
|
|
187
|
+
name: 'prompt_manager_remove',
|
|
188
|
+
description: removeDescription('AGENTS.md preset'),
|
|
189
|
+
parameters: {
|
|
190
|
+
action: { type: 'string', required: true, enum: ['trash', 'restore', 'list'], description: ACTION_DESC },
|
|
191
|
+
id: { type: 'string', description: 'For trash: the preset id. For restore: the trash entry id from action=list. Not needed for list.' },
|
|
192
|
+
},
|
|
193
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
194
|
+
async execute(args) { return runRemove(deps, promptCfg, args); },
|
|
195
|
+
}));
|
|
196
|
+
register(defineTool({
|
|
197
|
+
name: 'scene_manager_remove',
|
|
198
|
+
description: removeDescription('scene'),
|
|
199
|
+
parameters: {
|
|
200
|
+
action: { type: 'string', required: true, enum: ['trash', 'restore', 'list'], description: ACTION_DESC },
|
|
201
|
+
id: { type: 'string', description: 'For trash: the scene name. For restore: the trash entry id from action=list. Not needed for list.' },
|
|
202
|
+
},
|
|
203
|
+
output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
|
|
204
|
+
async execute(args) { return runRemove(deps, sceneCfg, args); },
|
|
205
|
+
}));
|
|
206
|
+
}
|
package/lib/tools/scene.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
// 不因为多了一条工具就破例(工具描述本来就是写"什么时候该用它"的地方)。
|
|
27
27
|
import { text, toolNameList } from './deps.js';
|
|
28
28
|
import { FACTORY_TOOL_TABLE_PRESET } from './table.js';
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** 档案里由模型设置的段(`memories` 段在 P5 已废弃,不再是档案的一部分)。
|
|
30
|
+
* `mcpNotes`(0.20.0)与其余三段同族:整段替换、段缺席 = 不碰这一域。 */
|
|
31
|
+
const ARCHIVE_SECTIONS = ['mcp', 'mcpNotes', 'skills', 'subagents'];
|
|
31
32
|
/** 段里的名单:数组段是名字列表,`mcp` 段是 serverName → `'*'` | 工具名数组。段缺席 = 空。 */
|
|
32
33
|
function segmentNames(value) {
|
|
33
34
|
if (Array.isArray(value))
|
|
@@ -36,6 +37,21 @@ function segmentNames(value) {
|
|
|
36
37
|
return Object.keys(value);
|
|
37
38
|
return [];
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* `quickPrompts` 入参 → 档案值:去空、去重、保序(与 op 侧 `quickPromptsArg` 同一口径)。
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ 空数组是**合法值**(= 进入后全部停用),不能在这里被当成"没给"滤掉 —— 它与
|
|
44
|
+
* "这一域不碰"(段缺席)是两句相反的话(见 `SceneArchive.quickPrompts` 的注释)。
|
|
45
|
+
*/
|
|
46
|
+
function quickPromptIds(value) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const item of Array.isArray(value) ? value : []) {
|
|
49
|
+
const id = String(item == null ? '' : item).trim();
|
|
50
|
+
if (id && out.indexOf(id) < 0)
|
|
51
|
+
out.push(id);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
39
55
|
/**
|
|
40
56
|
* 档案里绑的工具表方案 → 给人读的那一句。`factory-default` 是个哨兵值而不是本机存过的方案,
|
|
41
57
|
* 原样打出来模型会去找一份叫这个名字的方案(`_save` 那边也没有这一栏,它改不了)。
|
|
@@ -69,6 +85,16 @@ function archiveText(archive) {
|
|
|
69
85
|
const tt = toolTableLabel(archive.toolTablePreset);
|
|
70
86
|
if (tt)
|
|
71
87
|
parts.push('工具表方案「' + tt + '」');
|
|
88
|
+
// 快捷提示词绑定(0.20.0):进了这个场景会动到 `/` 菜单里哪些条目,不报就只能靠猜。
|
|
89
|
+
// 空表是"进入后全部停用",与"没绑"(这一域不碰)是两句相反的话,不能压成一个 "0 条"。
|
|
90
|
+
//
|
|
91
|
+
// 印 **id 而不是条数**(2026-10-15):`scene_manager_save.quickPrompts` 的描述让模型
|
|
92
|
+
// "copy them from a scene binding in the listing" —— 而此前这里只印「N 条」,清单里根本
|
|
93
|
+
// 没有 id 可抄,那句是一条**指不到的死路**(模型照做只会白跑一趟,然后去问用户)。
|
|
94
|
+
// 与 `skills` / `subagents` 两段同款(那两段本来就是 `toolNameList` 印名字)。
|
|
95
|
+
const qp = archive.quickPrompts;
|
|
96
|
+
if (Array.isArray(qp))
|
|
97
|
+
parts.push(qp.length ? '快捷提示词 ' + toolNameList(qp) : '快捷提示词:进入后全部停用');
|
|
72
98
|
return parts.join(' | ');
|
|
73
99
|
}
|
|
74
100
|
/**
|
|
@@ -122,16 +148,16 @@ export function buildSceneTools(deps) {
|
|
|
122
148
|
// 这种筛选(未启用的场景照样要能被进入,藏着只会让模型以为它不存在)。
|
|
123
149
|
// 描述与参数说明里**不点名同族工具**(scene_manager_list / _switch / _save 三条都在出厂
|
|
124
150
|
// 默认的隐藏名单里):静态文本没法按 toolVisible 改写,点名一条模型没有的工具只会让它
|
|
125
|
-
// 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the
|
|
151
|
+
// 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the「当前场景」reminder
|
|
126
152
|
// —— 后者是常驻源,只要场景域开着就在)。点名只留在**运行时回执**里、按可见性分叉。
|
|
127
|
-
description: 'List every scene with its archive (what entering it switches on: MCP servers, skills, personas, and the bound model tool-table scheme when there is one), bound prompt preset, memory count and enabled / entered / locked state.
|
|
153
|
+
description: 'List every scene with its archive (what entering it switches on: MCP servers, skills, personas, quick prompts, and the bound model tool-table scheme when there is one), bound prompt preset, memory count and enabled / entered / locked state. The scene currently entered is also shown in the「当前场景」reminder.',
|
|
128
154
|
parameters: {},
|
|
129
155
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
130
156
|
async execute() {
|
|
131
157
|
const r = await deps.rulesOps['rules-list']({});
|
|
132
158
|
if (!r || r.ok === false)
|
|
133
159
|
throw new Error((r && r.error) || '读取场景失败');
|
|
134
|
-
//
|
|
160
|
+
// 档案与「已进入哪个场景」要额外读一次(纯读盘,`scene-mode-get`)。
|
|
135
161
|
// 读不到**不阻断**清单:场景行退化成只有名字与启用状态,而不是让整条工具失败 ——
|
|
136
162
|
// 一份少了档案的清单仍然有用,一次失败什么都没有。
|
|
137
163
|
// `archives = null` 是"没读到",与"读到了但某个场景没建档案"是两件事:混成一份会让
|
|
@@ -183,10 +209,10 @@ export function buildSceneTools(deps) {
|
|
|
183
209
|
// 为什么 `action` 是必填枚举而不是一个布尔:与 `mcp_manager_switch` 同一条理由 ——
|
|
184
210
|
// 让模型在 enter / exit 两个动词里选一个,比让它猜"scene 传 null 是什么意思"省一次往返。
|
|
185
211
|
// 参数里刻意不出现 `null`:那是 op(`scene-mode-set`)的形态,模型照着填只会填错。
|
|
186
|
-
description: 'Enter a scene, or leave the one currently entered. Entering switches on
|
|
212
|
+
description: 'Enter a scene, or leave the one currently entered. Entering switches on its archive 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. Only when the user asks or approves.',
|
|
187
213
|
parameters: {
|
|
188
214
|
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).' },
|
|
189
|
-
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
|
|
215
|
+
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.' },
|
|
190
216
|
},
|
|
191
217
|
output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
|
|
192
218
|
async execute(args) {
|
|
@@ -271,15 +297,27 @@ export function buildSceneTools(deps) {
|
|
|
271
297
|
// 「写入指引」保留("写成指令而不是备注"—— 这是场景说明质量的杠杆),但不再声称
|
|
272
298
|
// "注入后模型会被要求照办":2026-09-23 定稿把场景段的授权语删了(注入只陈述现状),
|
|
273
299
|
// 参数说明与注入侧各说各话时,模型信的那份是错的。改为如实说明去向。
|
|
274
|
-
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.',
|
|
300
|
+
description: 'Save a scene — a named bundle the user enters: entering it switches on exactly what its archive lists (MCP servers, scene-level MCP note overrides, skills, personas, quick prompts, and the model tool table when a scheme is bound) 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.',
|
|
275
301
|
parameters: {
|
|
276
|
-
scene: { type: 'string', required: true, description: 'Scene name (one path segment, no
|
|
277
|
-
label: { type: 'string', description: 'Display name
|
|
278
|
-
description: { type: 'string', description: 'What this scene is for; shown beside the scene name in the
|
|
302
|
+
scene: { type: 'string', required: true, description: 'Scene name (one path segment, ≤64 chars, no / \\ or < > : " | ? *, not `_shared`). Created if absent.' },
|
|
303
|
+
label: { type: 'string', description: 'Display name for this scene. Omit = keep.' },
|
|
304
|
+
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.' },
|
|
279
305
|
// 与下面 `mcp` / `skills` / `subagents` 同一句式("Omit = keep; X = 一个都不"):
|
|
280
306
|
// 界面 2026-10-10 补了「无」这个选项,工具侧的空串语义要跟着说清,否则模型只知道
|
|
281
307
|
// 「省略 = 保持」,不知道还有一条「解绑」的路。
|
|
282
308
|
prompt: { type: 'string', description: 'Prompt preset id to bind; while this scene is on it is the global baseline. Omit = keep; \'\' = bind none (this scene leaves AGENTS.md alone).' },
|
|
309
|
+
// 模型工具表方案绑定(0.20.0 补上模型侧入口:0.15.0 起界面可绑、档案里一直有这一栏,
|
|
310
|
+
// `_list` / `_switch` 也读它,唯独 `_save` 写不了)。'' = 解绑只能走
|
|
311
|
+
// `rules-update-scene` —— `scene-archive-save` 对载荷里**不带的栏**一律保留现值
|
|
312
|
+
// (archive-engine 的「不带 ≠ 解绑」),所以解绑与设置分成两条路,见 execute 的 ②。
|
|
313
|
+
// 哨兵 `factory-default` 与界面下拉同值(tools/table.ts 的 FACTORY_TOOL_TABLE_PRESET);
|
|
314
|
+
// 不存在的方案名由引擎按 stale 丢弃并在进入时报告,op 不硬拒(与悬空绑定同口径)。
|
|
315
|
+
toolTablePreset: { type: 'string', description: "Model tool-table scheme to bind while this scene is on. Omit = keep; '' = unbind (the scene leaves the tool table alone); 'factory-default' = the factory default; otherwise the name of a scheme saved in the panel. An unknown name is dropped and reported." },
|
|
316
|
+
// 快捷提示词绑定(0.20.0)。段语义与上面三个档案段相反:**空数组是合法值**
|
|
317
|
+
// (进入后全部停用),不是"清除这一段"。本工具不提供解绑(界面可解)—— 与
|
|
318
|
+
// mcp / skills / subagents 三段没有解绑参数同一条口径。id 从场景清单里别的场景的
|
|
319
|
+
// 绑定、或用户口述里来;不存在的 id 被引擎丢弃并在回执报出来。
|
|
320
|
+
quickPrompts: { type: 'array', items: { type: 'string' }, description: 'Quick-prompt ids to switch on when this scene is entered; every other one is switched off, and the pre-entry state is restored on exit. Omit = keep; [] = switch all off on entry. Unknown ids are dropped and reported — copy them from a scene binding in the listing, or ask the user.' },
|
|
283
321
|
mcp: {
|
|
284
322
|
type: 'object',
|
|
285
323
|
// 官方要求对象类型**显式声明开放性**(`additionalProperties` 是必需字段,且只能是
|
|
@@ -288,6 +326,14 @@ export function buildSceneTools(deps) {
|
|
|
288
326
|
additionalProperties: true,
|
|
289
327
|
description: "Archive: serverName -> '*' (whole server) or a list of tool names to leave on. Omit = keep; {} = switch none on.",
|
|
290
328
|
},
|
|
329
|
+
// 场景级 MCP 备注覆盖(0.20.0):进入这个场景时用这份盖掉全局备注,退出时还原。
|
|
330
|
+
// 与 mcp 段同一句式(省略 = 保持;{} = 一个都不覆盖)—— 界面存档编辑器早就能改,
|
|
331
|
+
// `_list` 也显示它,`_save` 此前不接这一段。
|
|
332
|
+
mcpNotes: {
|
|
333
|
+
type: 'object',
|
|
334
|
+
additionalProperties: true,
|
|
335
|
+
description: "Archive: serverName -> note that overrides that server's global note while this scene is on (the global note comes back on exit). Omit = keep; {} = drop every scene-level override.",
|
|
336
|
+
},
|
|
291
337
|
skills: { type: 'array', items: { type: 'string' }, description: 'Archive: skill keys `<root>/<name>` to switch on. Omit = keep; [] = switch none on.' },
|
|
292
338
|
subagents: { type: 'array', items: { type: 'string' }, description: 'Archive: persona names to switch on. Omit = keep; [] = switch none on.' },
|
|
293
339
|
},
|
|
@@ -326,22 +372,72 @@ export function buildSceneTools(deps) {
|
|
|
326
372
|
const created = await deps.rulesOps['rules-create-scene'](payload);
|
|
327
373
|
if (!created || created.ok === false)
|
|
328
374
|
throw new Error((created && created.error) || '保存场景失败');
|
|
329
|
-
// ②
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
375
|
+
// ② 两处「解绑」只能走 `rules-update-scene`:`scene-archive-save` 对载荷里**不带的栏**
|
|
376
|
+
// 一律保留现值(archive-engine 的「不带 ≠ 解绑」),从现值复制出来的合并档案会把
|
|
377
|
+
// 刚解掉的绑定又写回去 —— 所以解绑与设置分成两条路,合并前还要先摘掉刚解掉的键。
|
|
378
|
+
// · `prompt` 的 '' 此前也走不到实处 —— `rules-create-scene` 的 upsert 分支把空串
|
|
379
|
+
// 当"没给"(ops/scene-records.ts),旧绑定原样保留而回执报"已更新"。
|
|
380
|
+
// · `toolTablePreset` 的 '' 同解(那个 op 的更新分支认空串 = 解绑)。
|
|
381
|
+
const clearPrompt = args.prompt !== undefined && String(args.prompt).trim() === '';
|
|
382
|
+
const unbindToolTable = args.toolTablePreset !== undefined && String(args.toolTablePreset).trim() === '';
|
|
383
|
+
const setToolTable = args.toolTablePreset !== undefined && String(args.toolTablePreset).trim() !== '';
|
|
384
|
+
const setQuickPrompts = args.quickPrompts !== undefined;
|
|
385
|
+
if (setQuickPrompts && !Array.isArray(args.quickPrompts)) {
|
|
386
|
+
throw new Error('quickPrompts 必须是 id 数组([] = 进入后全部停用;解绑需用户手动操作,本工具不提供)');
|
|
387
|
+
}
|
|
335
388
|
const touched = ARCHIVE_SECTIONS.filter((k) => args[k] !== undefined);
|
|
336
|
-
|
|
337
|
-
|
|
389
|
+
const mustWriteArchive = touched.length > 0 || setToolTable || setQuickPrompts;
|
|
390
|
+
// 档案现状在读的时候就取一次,且必须在 ② **之前** —— 解绑落盘后这一栏已经没了,
|
|
391
|
+
// 回执的「X → 解绑」要的是解绑前的那个 X。只为看回执的 diff 而读时(解绑且不写档案)
|
|
392
|
+
// 容忍读失败:那一步已经写成功了,别为一句对比话把整次调用判失败。
|
|
393
|
+
let before = {};
|
|
394
|
+
let beforeKnown = false;
|
|
395
|
+
if (mustWriteArchive || unbindToolTable) {
|
|
338
396
|
const cur = await deps.archiveOps['scene-mode-get']({});
|
|
339
|
-
if (!cur || cur.ok === false)
|
|
340
|
-
|
|
341
|
-
|
|
397
|
+
if (!cur || cur.ok === false) {
|
|
398
|
+
if (mustWriteArchive)
|
|
399
|
+
throw new Error((cur && cur.error) || '读取场景档案失败');
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
before = (cur.archives || {})[scene] || {};
|
|
403
|
+
beforeKnown = true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
let clearWarn = '';
|
|
407
|
+
if (clearPrompt || unbindToolTable) {
|
|
408
|
+
const upd = { name: scene };
|
|
409
|
+
if (clearPrompt)
|
|
410
|
+
upd.prompt = '';
|
|
411
|
+
if (unbindToolTable)
|
|
412
|
+
upd.toolTablePreset = '';
|
|
413
|
+
const u = await deps.rulesOps['rules-update-scene'](upd);
|
|
414
|
+
if (!u || u.ok === false) {
|
|
415
|
+
// 主路径已成功、解绑没写上:如实报,别让回执假装绑定已经没了。
|
|
416
|
+
clearWarn = '\nWARN: 解绑未写入(' + ((u && u.error) || '未知错误') + '),旧绑定仍然有效';
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// ③ 档案 read-modify-write:段(mcp / mcpNotes / skills / subagents)与两个绑定栏的
|
|
420
|
+
// 「设置」方向合并成一份,只经 `scene-archive-save` 一条路落盘。
|
|
421
|
+
// **必须 read-modify-write**:`scene-archive-save` 是**整份替换**,只把模型给的段
|
|
422
|
+
// 传过去,没给的段会被静默清掉 —— 与 0.14.0 里 `subagent_manager_save` 省略字段被
|
|
423
|
+
// 清空同一形状,只是这次清掉的是"这个场景进入时要开哪些东西"。
|
|
424
|
+
// 注意段语义:**段不存在 = 不限制(全部照常)**,段存在但为空 = 全部停用。所以模型给
|
|
425
|
+
// `mcp: {}` 得到的是"全不勾",不是"清除该段" —— 后者只能靠不给。
|
|
426
|
+
// `quickPrompts` 与三段都不同:段缺席 = 这一域不碰,空数组 = 进入后全部停用。
|
|
427
|
+
let archiveNote = '';
|
|
428
|
+
let toolTableNote = '';
|
|
429
|
+
let quickNote = '';
|
|
430
|
+
let warnTail = '';
|
|
431
|
+
if (mustWriteArchive) {
|
|
342
432
|
const archive = { ...before };
|
|
343
433
|
for (const k of touched)
|
|
344
434
|
archive[k] = args[k];
|
|
435
|
+
if (unbindToolTable)
|
|
436
|
+
delete archive.toolTablePreset;
|
|
437
|
+
if (setToolTable)
|
|
438
|
+
archive.toolTablePreset = String(args.toolTablePreset).trim();
|
|
439
|
+
if (setQuickPrompts)
|
|
440
|
+
archive.quickPrompts = quickPromptIds(args.quickPrompts);
|
|
345
441
|
const r = await deps.archiveOps['scene-archive-save']({ scene, archive });
|
|
346
442
|
if (!r || r.ok === false)
|
|
347
443
|
throw new Error((r && r.error) || '保存场景档案失败');
|
|
@@ -349,18 +445,41 @@ export function buildSceneTools(deps) {
|
|
|
349
445
|
// 静默清掉,所以"模型给什么就是什么"),但"给出去的段盖掉了原来的勾选"是**静默**的
|
|
350
446
|
// —— 不写出来,模型只会看到一句"档案段:mcp",以为只是加了一项。
|
|
351
447
|
const diffs = touched.map((k) => segmentDiff(k, before[k], args[k]));
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
448
|
+
if (diffs.length) {
|
|
449
|
+
archiveNote = '档案段:' + diffs.map((d) => d.text).join(';');
|
|
450
|
+
if (diffs.some((d) => d.dropped.length > 0)) {
|
|
451
|
+
warnTail += '\n注意:这些段是整段替换,上面标 - 的项已被挤掉;要保留就把它们一并写进这次调用。';
|
|
452
|
+
}
|
|
355
453
|
}
|
|
356
|
-
// 当前环境里不存在的键会被 op
|
|
357
|
-
//
|
|
454
|
+
// 当前环境里不存在的键会被 op 丢弃并报告(改名或删除留下的,含工具表方案名与
|
|
455
|
+
// 快捷提示词 id):如实说,否则模型以为勾上了而实际没有。
|
|
358
456
|
if (r.stale && r.stale.length)
|
|
359
|
-
|
|
457
|
+
warnTail += '\n(丢弃了当前环境不存在的键:' + r.stale.join('、') + ')';
|
|
360
458
|
// 存盘成功但应用到运行时失败:`scene-archive-save` 对"正是当前模式的那个场景"会就地
|
|
361
459
|
// 重应用,失败不回滚档案。如实报出去,别假装生效了(与界面同口径)。
|
|
362
460
|
if (r.applyError)
|
|
363
|
-
|
|
461
|
+
warnTail += '\n(已存盘,但应用到运行时失败:' + r.applyError + ')';
|
|
462
|
+
}
|
|
463
|
+
// 解绑写失败了就别输出「X → 解绑」—— 上面那句 WARN 才是事实;设置方向不受 ② 影响。
|
|
464
|
+
if (args.toolTablePreset !== undefined && !(unbindToolTable && clearWarn !== '')) {
|
|
465
|
+
toolTableNote = beforeKnown
|
|
466
|
+
? '工具表方案:' + (toolTableLabel(before.toolTablePreset) || '未绑') + ' → ' +
|
|
467
|
+
(unbindToolTable ? '解绑' : toolTableLabel(String(args.toolTablePreset).trim()))
|
|
468
|
+
: '工具表方案:已解绑(档案现状读不到,无法对比)';
|
|
469
|
+
}
|
|
470
|
+
if (setQuickPrompts && beforeKnown) {
|
|
471
|
+
const beforeIds = Array.isArray(before.quickPrompts) ? before.quickPrompts.map((x) => String(x)) : [];
|
|
472
|
+
const afterIds = quickPromptIds(args.quickPrompts);
|
|
473
|
+
const added = afterIds.filter((id) => beforeIds.indexOf(id) < 0);
|
|
474
|
+
const dropped = beforeIds.filter((id) => afterIds.indexOf(id) < 0);
|
|
475
|
+
const marks = [];
|
|
476
|
+
if (added.length)
|
|
477
|
+
marks.push('+' + toolNameList(added));
|
|
478
|
+
if (dropped.length)
|
|
479
|
+
marks.push('-' + toolNameList(dropped));
|
|
480
|
+
quickNote = '快捷提示词:' + beforeIds.length + ' 条 → ' + afterIds.length + ' 条' +
|
|
481
|
+
(afterIds.length === 0 ? '(进入后全部停用)' : '') +
|
|
482
|
+
(marks.length ? '(' + marks.join(' ') + ')' : '');
|
|
364
483
|
}
|
|
365
484
|
// 收敛提示:`rules-create-scene` 在"历史默认(全部启用)"状态下会把启用集合收窄成
|
|
366
485
|
// 单选(`collapseActiveForNewScene`)—— 那是一次**会改变运行时**的动作,必须说出来。
|
|
@@ -369,15 +488,19 @@ export function buildSceneTools(deps) {
|
|
|
369
488
|
const parts = [touchedRecord.length ? '场景记录已更新(' + touchedRecord.join('、') + ')' : '场景已确保存在'];
|
|
370
489
|
if (archiveNote)
|
|
371
490
|
parts.push(archiveNote);
|
|
491
|
+
if (toolTableNote)
|
|
492
|
+
parts.push(toolTableNote);
|
|
493
|
+
if (quickNote)
|
|
494
|
+
parts.push(quickNote);
|
|
372
495
|
// 点名 scene_manager_switch 的那句按可见性分叉(它在出厂默认名单里就是关着的):
|
|
373
496
|
// 部分启用(开 save 不开 switch)时指向一条模型没有的工具,只会让它白跑一趟 ——
|
|
374
497
|
// 与 mcp/skill 的 listHint 同一条纪律。
|
|
375
498
|
const enterHint = deps.toolVisible('scene_manager_switch')
|
|
376
499
|
? '要让它生效,用 scene_manager_switch 进入。'
|
|
377
|
-
: '
|
|
378
|
-
return 'OK: scene「' + scene + '」' + parts.join(';') +
|
|
500
|
+
: '要让它生效,需用户手动进入该场景(本会话未开放 scene_manager_switch)。';
|
|
501
|
+
return 'OK: scene「' + scene + '」' + parts.join(';') + warnTail + clearWarn +
|
|
379
502
|
(collapsed
|
|
380
|
-
? '\n注意:此前所有场景都处于启用状态(历史「全部启用」默认),本次已收窄为只启用一个,新建的这个不在其中 ——
|
|
503
|
+
? '\n注意:此前所有场景都处于启用状态(历史「全部启用」默认),本次已收窄为只启用一个,新建的这个不在其中 —— 需用户确认启用状态。'
|
|
381
504
|
: '') +
|
|
382
505
|
'\n保存不改运行时:' + enterHint;
|
|
383
506
|
},
|