dsh-plugin-tool-management 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +3 -1
  3. package/README_EN.md +1 -1
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/images/logo.gif +0 -0
  21. package/docs/update.md +2 -0
  22. package/lib/client.js +4040 -2446
  23. package/lib/compat/preset-reach.js +5 -2
  24. package/lib/context-inject-frame.js +13 -8
  25. package/lib/hub.js +8 -5
  26. package/lib/index.js +56 -20
  27. package/lib/mcp/manager.js +21 -0
  28. package/lib/mcp/state-section.js +1 -1
  29. package/lib/memories/archive-engine.js +4 -5
  30. package/lib/memories/constants.js +2 -2
  31. package/lib/op-registry.js +7 -5
  32. package/lib/ops/memory.js +42 -4
  33. package/lib/ops/scene-records.js +43 -9
  34. package/lib/ops/sessions.js +5 -39
  35. package/lib/ops/trash.js +45 -5
  36. package/lib/request-gate.js +9 -2
  37. package/lib/scene-prompt-sync.js +48 -23
  38. package/lib/scenes/candidates.js +5 -1
  39. package/lib/sessions/workspace.js +0 -10
  40. package/lib/skills/catalog.js +17 -5
  41. package/lib/skills/core.js +121 -5
  42. package/lib/skills/frontmatter.js +31 -0
  43. package/lib/skills/manager-state.js +41 -2
  44. package/lib/skills/service.js +46 -12
  45. package/lib/skills/skill-roots.js +54 -4
  46. package/lib/skills/skill-trash.js +118 -12
  47. package/lib/skills/upload-pipeline.js +110 -26
  48. package/lib/subagents/catalog.js +9 -5
  49. package/lib/subagents/service.js +44 -6
  50. package/lib/subagents/tools.js +45 -15
  51. package/lib/tool-table-switch.js +6 -6
  52. package/lib/tools/deps.js +43 -0
  53. package/lib/tools/mcp.js +68 -27
  54. package/lib/tools/memory.js +49 -9
  55. package/lib/tools/prompt.js +26 -9
  56. package/lib/tools/remove.js +206 -0
  57. package/lib/tools/scene.js +18 -13
  58. package/lib/tools/skills.js +162 -55
  59. package/lib/tools/subagent.js +65 -31
  60. package/lib/tools/table.js +39 -12
  61. package/lib/trash-id.js +16 -0
  62. package/package.json +4 -3
@@ -1,7 +1,14 @@
1
1
  // AGENTS.md 预设域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
2
  // prompt_manager_list / prompt_manager_apply。
3
3
  //
4
- // 模型可查、可切,**不能造 / 不能删** —— 避免模型乱删用户预设。
4
+ // 模型可查、可切;**不能造** —— 预设正文是用户自己写的一份长文(`AGENTS.md`),
5
+ // 模型没有创建入口,要新建只能由用户在面板里写。
6
+ //
7
+ // 删除在 `tools/remove.ts` 的 `prompt_manager_remove`(2026-10-15 加)。此前这里写的是
8
+ // 「不能造 / **不能删** —— 避免模型乱删用户预设」,那条理由针对的是**不可恢复**的删除:
9
+ // 预设正文是用户手写的、删了就得重写。回收站把它的前提消掉了 —— 五条 `*_manager_remove`
10
+ // 一律「进回收站、可恢复、需用户指令、过场景冻结守卫」,永久删除仍只留在面板。
11
+ // 换句话说:**能删的是可恢复的那一步,不可逆的那一步照旧不给模型。**
5
12
  import { join } from 'node:path';
6
13
  import { normalizePresetDescription } from '../prompts/service.js';
7
14
  import { text } from './deps.js';
@@ -9,7 +16,10 @@ export function buildPromptTools(deps) {
9
16
  const { defineTool, register } = deps;
10
17
  register(defineTool({
11
18
  name: 'prompt_manager_list',
12
- description: 'List AGENTS.md presets (id, description, 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.',
19
+ // 那句 `(that reminder is absent when the preset carries the official instructions line)`
20
+ // 删掉(2026-10-15 注入文本审查):模型**观测不到**"official instructions line"这个条件,
21
+ // 拿它做不了任何决定 —— reminder 在不在,它抬头就能看见,不必由描述来预告。
22
+ description: 'List AGENTS.md presets (id, description, 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.',
13
23
  parameters: {
14
24
  all: { type: 'boolean', description: 'Include inactive presets (default false).' },
15
25
  },
@@ -20,7 +30,7 @@ export function buildPromptTools(deps) {
20
30
  if (!r || r.ok === false)
21
31
  throw new Error((r && r.error) || '读取提示词预设失败');
22
32
  const all = r.presets || [];
23
- // 默认只列「当前这份 ~/.dsh/AGENTS.md 是从哪个预设来的」(用户裁定 2026-09-16)。
33
+ // 默认只列「这份 AGENTS.md 来自哪个预设」(用户裁定 2026-09-16)。
24
34
  // 判定分两级:`active`(场景驱动时 = 场景绑的那份;否则 = 内容逐字节相同)优先,
25
35
  // 否则看服务层记下的「最近一次应用」—— 用户手改过全局文件时,第二级仍能给出答案,
26
36
  // 不必退化成全列(那会白烧上下文)。
@@ -53,15 +63,19 @@ export function buildPromptTools(deps) {
53
63
  // 同上:AGENTS.md 由官方 dsh-agent-instructions 行承载(极简没挂这一行),
54
64
  // 这一行不在时文件内容不会进上下文。
55
65
  const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
56
- return header + (summary.join('\n') || '(none)') + '\n(DSH re-reads ~/.dsh/AGENTS.md every turn, so applying takes effect on the next turn.)' + notice;
66
+ return header + (summary.join('\n') || '(none)') + notice;
57
67
  },
58
68
  }));
59
69
  register(defineTool({
60
70
  name: 'prompt_manager_apply',
61
71
  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. Pass apply=false to cancel instead — AGENTS.md goes back to what it was before the run of applies (the file is removed if there was none to begin with); a scene driving the baseline refuses that, rebind the scene instead. Only on the user\'s instruction.',
62
72
  parameters: {
63
- id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens). Ignored when apply=false.' },
64
- apply: { type: 'boolean', description: 'Omit or true = apply the preset in `id`; false = cancel — undo the run of applies.' },
73
+ // `id` 从 required 摘掉(2026-10-15 smoke-test P1-5):描述一直写着 "Ignored when
74
+ // apply=false",schema 却把它标成必填 —— 模型照描述「取消不用给 id」操作,先被 schema
75
+ // 拒掉(missing required property "id"),只能编一个 dummy id 才过得去。条件必填由
76
+ // execute 里那句守卫表达,与 `skill_manager_switch` 的 enabled / prefer 同一形状。
77
+ id: { type: 'string', description: 'Preset id (lowercase letters, digits, hyphens). Required unless apply=false.' },
78
+ apply: { type: 'boolean', description: 'Omit or true = apply the preset in `id`; false = cancel — undo the run of applies (no `id` needed).' },
65
79
  },
66
80
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
67
81
  async execute(args) {
@@ -79,18 +93,21 @@ export function buildPromptTools(deps) {
79
93
  ? 'OK: application cancelled — ~/.dsh/AGENTS.md did not exist before the run started, so the file was removed (effective next turn).'
80
94
  : 'OK: application cancelled — ~/.dsh/AGENTS.md is back to what it was before the run started (effective next turn).';
81
95
  }
82
- const r = await deps.applyPresetGuarded(args.id);
96
+ const id = String((args && args.id) || '').trim();
97
+ if (id === '')
98
+ throw new Error('缺少参数:id(只有 apply=false 取消时才不用给)');
99
+ const r = await deps.applyPresetGuarded(id);
83
100
  if (!r.ok)
84
101
  throw new Error(r.error);
85
102
  // 场景驱动时写的是「场景绑定」,回执必须说清改的是哪个场景 —— 只说「已应用到
86
103
  // AGENTS.md」会让模型对用户谎报(用户不知道自己的场景绑定被换掉了)。
87
104
  if (r.viaScene) {
88
- return 'OK: preset ' + args.id + ' is now the prompt binding of scene 「' + (r.scene || '') + '」 and the scene was re-synced (effective next turn; ~/.dsh/AGENTS.md now carries that scene\'s binding).';
105
+ return 'OK: preset ' + id + ' is now the prompt binding of scene 「' + (r.scene || '') + '」 and the scene was re-synced (effective next turn; ~/.dsh/AGENTS.md now carries that scene\'s binding).';
89
106
  }
90
107
  // 生效时机与宿主行为对齐(index.ts 提示词服务一节考证过):dsh-agent-instructions
91
108
  // 每个 pre-step 都 stat 比对版本,写文件即下一轮重读 —— 不是"下个会话"。说错了
92
109
  // 模型会劝用户重开会话,而其实下一句话就已经在新基线下跑。
93
- return 'OK: preset ' + args.id + ' applied to ~/.dsh/AGENTS.md — effective next turn' + (r.backedUp ? ' (previous backed up to __last-applied__)' : '');
110
+ return 'OK: preset ' + id + ' applied to ~/.dsh/AGENTS.md — effective next turn' + (r.backedUp ? ' (previous backed up to __last-applied__)' : '');
94
111
  },
95
112
  }));
96
113
  }
@@ -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
+ }
@@ -87,9 +87,14 @@ function archiveText(archive) {
87
87
  parts.push('工具表方案「' + tt + '」');
88
88
  // 快捷提示词绑定(0.20.0):进了这个场景会动到 `/` 菜单里哪些条目,不报就只能靠猜。
89
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` 印名字)。
90
95
  const qp = archive.quickPrompts;
91
96
  if (Array.isArray(qp))
92
- parts.push(qp.length ? '快捷提示词 ' + qp.length + ' 条' : '快捷提示词:进入后全部停用');
97
+ parts.push(qp.length ? '快捷提示词 ' + toolNameList(qp) : '快捷提示词:进入后全部停用');
93
98
  return parts.join(' | ');
94
99
  }
95
100
  /**
@@ -143,16 +148,16 @@ export function buildSceneTools(deps) {
143
148
  // 这种筛选(未启用的场景照样要能被进入,藏着只会让模型以为它不存在)。
144
149
  // 描述与参数说明里**不点名同族工具**(scene_manager_list / _switch / _save 三条都在出厂
145
150
  // 默认的隐藏名单里):静态文本没法按 toolVisible 改写,点名一条模型没有的工具只会让它
146
- // 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the「本机当前的场景」reminder
151
+ // 白跑一趟。要说"去哪看"就用中性说法(the scene listing / the「当前场景」reminder
147
152
  // —— 后者是常驻源,只要场景域开着就在)。点名只留在**运行时回执**里、按可见性分叉。
148
- 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. 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.',
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.',
149
154
  parameters: {},
150
155
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
151
156
  async execute() {
152
157
  const r = await deps.rulesOps['rules-list']({});
153
158
  if (!r || r.ok === false)
154
159
  throw new Error((r && r.error) || '读取场景失败');
155
- // 档案与「当前进入的是哪个」要额外读一次(纯读盘,`scene-mode-get`)。
160
+ // 档案与「已进入哪个场景」要额外读一次(纯读盘,`scene-mode-get`)。
156
161
  // 读不到**不阻断**清单:场景行退化成只有名字与启用状态,而不是让整条工具失败 ——
157
162
  // 一份少了档案的清单仍然有用,一次失败什么都没有。
158
163
  // `archives = null` 是"没读到",与"读到了但某个场景没建档案"是两件事:混成一份会让
@@ -204,10 +209,10 @@ export function buildSceneTools(deps) {
204
209
  // 为什么 `action` 是必填枚举而不是一个布尔:与 `mcp_manager_switch` 同一条理由 ——
205
210
  // 让模型在 enter / exit 两个动词里选一个,比让它猜"scene 传 null 是什么意思"省一次往返。
206
211
  // 参数里刻意不出现 `null`:那是 op(`scene-mode-set`)的形态,模型照着填只会填错。
207
- description: 'Enter a scene, or leave the one currently entered. Entering switches on exactly what its archive lists and everything else off — MCP servers, skills, personas, quick prompts, and the model tool table when the scene binds a scheme — 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.',
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.',
208
213
  parameters: {
209
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).' },
210
- 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.' },
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.' },
211
216
  },
212
217
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
213
218
  async execute(args) {
@@ -292,11 +297,11 @@ export function buildSceneTools(deps) {
292
297
  // 「写入指引」保留("写成指令而不是备注"—— 这是场景说明质量的杠杆),但不再声称
293
298
  // "注入后模型会被要求照办":2026-09-23 定稿把场景段的授权语删了(注入只陈述现状),
294
299
  // 参数说明与注入侧各说各话时,模型信的那份是错的。改为如实说明去向。
295
- description: 'Save a scene — a named bundle the user enters: entering it switches on exactly what its archive lists (MCP servers, 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.',
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.',
296
301
  parameters: {
297
- scene: { type: 'string', required: true, description: 'Scene name (one path segment, no slashes). Created if absent.' },
298
- label: { type: 'string', description: 'Display name shown in the UI. Omit = keep.' },
299
- 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.' },
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.' },
300
305
  // 与下面 `mcp` / `skills` / `subagents` 同一句式("Omit = keep; X = 一个都不"):
301
306
  // 界面 2026-10-10 补了「无」这个选项,工具侧的空串语义要跟着说清,否则模型只知道
302
307
  // 「省略 = 保持」,不知道还有一条「解绑」的路。
@@ -378,7 +383,7 @@ export function buildSceneTools(deps) {
378
383
  const setToolTable = args.toolTablePreset !== undefined && String(args.toolTablePreset).trim() !== '';
379
384
  const setQuickPrompts = args.quickPrompts !== undefined;
380
385
  if (setQuickPrompts && !Array.isArray(args.quickPrompts)) {
381
- throw new Error('quickPrompts 必须是 id 数组([] = 进入后全部停用;要解绑请到「场景」页)');
386
+ throw new Error('quickPrompts 必须是 id 数组([] = 进入后全部停用;解绑需用户手动操作,本工具不提供)');
382
387
  }
383
388
  const touched = ARCHIVE_SECTIONS.filter((k) => args[k] !== undefined);
384
389
  const mustWriteArchive = touched.length > 0 || setToolTable || setQuickPrompts;
@@ -492,10 +497,10 @@ export function buildSceneTools(deps) {
492
497
  // 与 mcp/skill 的 listHint 同一条纪律。
493
498
  const enterHint = deps.toolVisible('scene_manager_switch')
494
499
  ? '要让它生效,用 scene_manager_switch 进入。'
495
- : '要让它生效,由用户在「场景」页进入。';
500
+ : '要让它生效,需用户手动进入该场景(本会话未开放 scene_manager_switch)。';
496
501
  return 'OK: scene「' + scene + '」' + parts.join(';') + warnTail + clearWarn +
497
502
  (collapsed
498
- ? '\n注意:此前所有场景都处于启用状态(历史「全部启用」默认),本次已收窄为只启用一个,新建的这个不在其中 —— 让用户到「场景」页确认。'
503
+ ? '\n注意:此前所有场景都处于启用状态(历史「全部启用」默认),本次已收窄为只启用一个,新建的这个不在其中 —— 需用户确认启用状态。'
499
504
  : '') +
500
505
  '\n保存不改运行时:' + enterHint;
501
506
  },