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.
Files changed (66) hide show
  1. package/CHANGELOG.md +915 -829
  2. package/README.md +237 -254
  3. package/README_EN.md +234 -223
  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 +32 -30
  22. package/lib/client.js +9480 -6970
  23. package/lib/compat/preset-reach.js +5 -2
  24. package/lib/context-inject-frame.js +13 -8
  25. package/lib/hub.js +104 -11
  26. package/lib/index.js +72 -24
  27. package/lib/mcp/manager.js +45 -13
  28. package/lib/mcp/secret-guard.js +2 -2
  29. package/lib/mcp/state-section.js +1 -1
  30. package/lib/memories/archive-engine.js +4 -5
  31. package/lib/memories/constants.js +11 -4
  32. package/lib/memories/service.js +6 -1
  33. package/lib/op-registry.js +13 -11
  34. package/lib/ops/memory.js +42 -4
  35. package/lib/ops/scene-records.js +43 -9
  36. package/lib/ops/sessions.js +67 -46
  37. package/lib/ops/trash.js +107 -8
  38. package/lib/request-gate.js +9 -2
  39. package/lib/scene-prompt-sync.js +48 -23
  40. package/lib/sessions/history.js +32 -22
  41. package/lib/sessions/workspace.js +0 -10
  42. package/lib/skills/catalog.js +17 -5
  43. package/lib/skills/core.js +189 -7
  44. package/lib/skills/frontmatter.js +31 -0
  45. package/lib/skills/manager-state.js +73 -3
  46. package/lib/skills/service.js +53 -13
  47. package/lib/skills/skill-roots.js +54 -4
  48. package/lib/skills/skill-trash.js +118 -12
  49. package/lib/skills/upload-pipeline.js +110 -26
  50. package/lib/subagents/catalog.js +9 -5
  51. package/lib/subagents/service.js +44 -6
  52. package/lib/subagents/tools.js +45 -15
  53. package/lib/tool-table-switch.js +6 -6
  54. package/lib/tools/deps.js +43 -0
  55. package/lib/tools/mcp.js +74 -27
  56. package/lib/tools/memory.js +49 -9
  57. package/lib/tools/prompt.js +38 -9
  58. package/lib/tools/remove.js +206 -0
  59. package/lib/tools/scene.js +155 -32
  60. package/lib/tools/skills.js +162 -55
  61. package/lib/tools/subagent.js +65 -31
  62. package/lib/tools/table.js +39 -12
  63. package/lib/trash-id.js +16 -0
  64. package/lib/trash-retention.js +3 -3
  65. package/package.json +115 -114
  66. package/lib/mcp/inspect-probe.js +0 -199
package/lib/tools/mcp.js CHANGED
@@ -13,6 +13,12 @@
13
13
  // 所以 switch 里只有 on|off 过 `lockedSceneGuard()`,restart 不过;与面板侧
14
14
  // `guardLockedOps(frozenOps('all'))` 的口径逐字一致(两处口径不同才是真的"说假话")。
15
15
  //
16
+ // restart 现在是**唯一**的重启入口:面板侧那颗重启键用户此前已删除(2026-10-12 复核,
17
+ // 客户端已无任何 restart 调用方)。这不是要把它也删掉 —— 工具是模型按需调用的面,与面板
18
+ // 按钮是两回事;面板删掉只说明"不想让人手点",而"服务器卡住、让模型重连一下"这条恢复路径
19
+ // 仍然成立,且它正是上面那条"锁定期间唯一不冻结的写"的立足点。删掉它等于把场景锁定时的
20
+ // 唯一恢复手段一并拿掉。
21
+ //
16
22
  // **改一台已配置服务器时绝不读明文凭据**:`mcpm-edit` 的守卫(`resolveMaskedKv` /
17
23
  // `resolveMaskedUrl`)就是为"接受打码值并还原"写的 —— 界面编辑框预填的本来就是打码值。
18
24
  // 所以这里拿 `mcpmListView()` 的**打码视图**把模型没给的字段填回去,让 op 去还原。
@@ -21,13 +27,22 @@
21
27
  // `resolveMaskedKv({}, 旧值)` 把整份 headers / env 静默清空、连 warning 都不给。
22
28
  import { DEFAULT_MCP_NOTE_MAX_LENGTH, normalizeMcpNote } from '../mcp/state-section.js';
23
29
  import { text } from './deps.js';
24
- /** 「去看清单」那句:`mcp_manager_list` 被关掉时不能点名它(调用方按 `toolVisible` 决定给不给)。 */
30
+ /** 「去看清单」那句:`mcp_manager_list` 不在模型手里时不能点名它(调用方按 `toolVisibleFor` 决定给不给)。 */
25
31
  const MCP_LIST_HINT = '(用 mcp_manager_list 看清单,all=true 连停用的也列)';
32
+ /**
33
+ * 指不到 `mcp_manager_list` 时的**中性出口**:不点名任何工具,两条常驻来源都成立
34
+ * —— 模型自己的 `mcp__<server>__<tool>` 工具名(就在它的 schema 里),以及注入段。
35
+ *
36
+ * 为什么必须有这一句(2026-10-14 对抗性审查 P0-1):`mcp_manager_switch` 是默认**唯一可见**
37
+ * 的 MCP 工具,猜错 serverName 时若报错里只有「没有这台服务器:X」,模型拿不到任何补救线索
38
+ * —— 它只能猜第二次。出口要么指向工具(那条工具得在它手里),要么指向这两条常驻来源。
39
+ */
40
+ const MCP_NEUTRAL_HINT = '(服务器名照抄你自己 `mcp__<server>__<tool>` 工具名里的那一段,或「当前 MCP 服务器」段里列出的名字)';
26
41
  /** 按 serverName 定位条目;同名(project + global 各一台)时要求 level 明确指定。 */
27
42
  function locate(rows, server, level, listHint = MCP_LIST_HINT) {
28
43
  const hits = (rows || []).filter((r) => String(r.serverName) === server);
29
44
  if (!hits.length) {
30
- throw new Error('没有这台服务器:' + server + listHint);
45
+ throw new Error('没有这台服务器:' + server + (listHint || MCP_NEUTRAL_HINT));
31
46
  }
32
47
  if (level) {
33
48
  const picked = hits.filter((r) => r.level === level);
@@ -58,15 +73,27 @@ function argsToText(value) {
58
73
  }
59
74
  export function buildMcpTools(deps) {
60
75
  const { defineTool, register } = deps;
61
- /** 那句"去看清单"要不要给:`mcp_manager_list` 在出厂默认里就是关着的,点名一条模型没有的工具
62
- * 只会让它白跑一趟(执行侧拦住它,而它看不出为什么)。 */
63
- const listHint = () => (deps.toolVisible('mcp_manager_list') ? MCP_LIST_HINT : '');
76
+ /**
77
+ * 那句"去看清单"要不要给:`mcp_manager_list` 在出厂默认里就是关着的,点名一条模型没有的工具
78
+ * 只会让它白跑一趟(执行侧拦住它,而它看不出为什么)。
79
+ *
80
+ * 判据按 **agent** 而不是全局开关(2026-10-14 对抗性审查 P0-1):全局关着、但这个人设没砍掉
81
+ * 它时,那句话是对的;反过来,全局开着、而这个人设用 `toolsDeny` 砍掉了它时,只读全局开关
82
+ * 就会让我们点名一条它手里没有的工具。与注入框架 `how` 行同一份判据(`toolVisibleFor`)。
83
+ */
84
+ const listHint = (agent) => (deps.toolVisibleFor('mcp_manager_list', agent) ? MCP_LIST_HINT : '');
64
85
  register(defineTool({
65
86
  name: 'mcp_manager_list',
66
87
  // 注入段与本工具的关系要**如实说成筛过的视图**(而非"同一份清单"):注入段只列可用 +
67
88
  // 曾连上现掉线的服务器(停用的不进),也不带 level / loader / 工具数。把这个盲区写出来
68
89
  // 是行动依据 —— 模型要知道"停用的那台去哪查"就得靠这句(all=true)。
69
- description: 'List configured MCP servers (level, enabled state, live loader status, tool count excluding switched-off tools, note). Read a server\'s note before choosing it. The「本机 MCP 服务器的当前状态」reminder carries a filtered view (usable servers, ones that were reachable before but are down now, and their notes; disabled servers are omitted); this tool is the raw view — defaults to enabled servers, all=true for every configured one.',
90
+ //
91
+ // 2026-10-15 注入文本审查:删掉两处白付的 token ——
92
+ // ① 括号里那句把「当前 MCP 服务器」reminder 的**内容**逐项列了一遍(可用 / 曾连上 /
93
+ // 备注 / 停用不进),而那段 reminder 每轮就在模型上下文里,它自己看得到;
94
+ // ② `where loader:on:<phase> is the loader's current phase` —— 用自己解释自己
95
+ // (phase 就是 phase),零信息。字段名本身留在输出里,模型照抄即可。
96
+ description: 'List configured MCP servers (level project|global; enabled state; live loader status; tool count excluding switched-off tools; user hint). The「当前 MCP 服务器」reminder is a filtered view; this tool is the raw one — defaults to enabled servers, all=true for every configured one. Pass tools=true to list each server\'s tool names.',
70
97
  parameters: {
71
98
  all: { type: 'boolean', description: 'Include disabled servers (default false).' },
72
99
  tools: { type: 'boolean', description: 'Also list each server\'s tool names, marking the switched-off ones (default false).' },
@@ -92,7 +119,7 @@ export function buildMcpTools(deps) {
92
119
  // 工具数 = 可用数(停用表扣减后):段里给模型的是同一个口径,模型据此与
93
120
  // 自己 schema 里的 `mcp__*` 工具对得上;被场景收窄 / 手动关掉的工具不算。
94
121
  const usableTools = typeof x.enabledToolCount === 'number' ? x.enabledToolCount : x.toolCount;
95
- lines.push(x.id + ' | ' + x.serverName + ' | ' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
122
+ lines.push('server:' + x.serverName + ' | level:' + x.level + ' | ' + (x.disabled ? 'disabled' : 'enabled') +
96
123
  (x.live ? ' | loader:' + (x.live.enabled ? 'on' : 'off') + (x.live.phase ? ':' + x.live.phase : '') : '') +
97
124
  (typeof usableTools === 'number' ? ' | tools:' + usableTools : '') +
98
125
  (note ? ' | user-hint:' + note : ''));
@@ -126,15 +153,22 @@ export function buildMcpTools(deps) {
126
153
  name: 'mcp_manager_switch',
127
154
  // 为什么 action 是必填枚举而不是两个可选布尔:模型最容易犯的错是"想重启却只传了 server",
128
155
  // 让它在三个动词里选一个,比让它猜两个布尔的组合省一次往返。
129
- description: 'Turn one configured MCP server on or off, or restart it (reconnects and re-syncs tools). Pass `tool` to switch just that tool. Only when the user asks or approves.',
156
+ // `restart` 的含义只在 `action` 参数里说一次(2026-10-15 注入文本审查):描述里那句
157
+ // "(reconnects and re-syncs tools)" 与参数说明逐字重复,而模型读参数时两处都在手上。
158
+ description: 'Turn one configured MCP server on or off, or restart it. Pass `tool` to switch just that tool. Blocked while the active scene is locked — except restart, which is the recovery path. Only when the user asks or approves.',
130
159
  parameters: {
131
- server: { type: 'string', required: true, description: 'Server name as shown in the list.' },
160
+ // 出口必须指向**常驻**来源,不能指向 `mcp_manager_list`(2026-10-14 对抗性审查 P0-1):
161
+ // 那条工具在出厂默认里就是关着的,`listHint()` 在它不可见时返回空串,于是猜错名字时的
162
+ // 报错退化成「没有这台服务器:<你猜的>」—— 一个字都不告诉它还能去哪看。
163
+ // 指向模型自己的工具表(`mcp__<server>__<tool>` 就在它的 schema 里)与常驻注入段,
164
+ // 这两条**不需要任何工具可见**就成立。
165
+ server: { type: 'string', required: true, description: 'Server name, as it appears in your own `mcp__<server>__<tool>` tool names (the「当前 MCP 服务器」reminder lists the usable ones).' },
132
166
  action: { type: 'string', required: true, enum: ['on', 'off', 'restart'], description: 'on = enable, off = disable, restart = reconnect and re-sync tools.' },
133
167
  level: { type: 'string', description: 'project or global — only to disambiguate same-name servers.' },
134
168
  tool: { type: 'string', description: 'One tool name on that server (no mcp__ prefix). Only for on/off.' },
135
169
  },
136
170
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
137
- async execute(args) {
171
+ async execute(args, exec) {
138
172
  const action = String((args && args.action) || '');
139
173
  if (action !== 'on' && action !== 'off' && action !== 'restart') {
140
174
  throw new Error('action 只能是 on / off / restart(收到:' + (action || '(空)') + ')');
@@ -155,7 +189,7 @@ export function buildMcpTools(deps) {
155
189
  if (!list || list.ok === false)
156
190
  throw new Error((list && list.error) || '读取 MCP 配置失败');
157
191
  const level = args && args.level !== undefined ? String(args.level) : undefined;
158
- const row = locate(list.rows || [], server, level, listHint());
192
+ const row = locate(list.rows || [], server, level, listHint(exec && exec.agent));
159
193
  const enabled = action === 'on';
160
194
  if (tool !== '') {
161
195
  const r = await deps.mcpmToolEnabled({ serverName: String(row.serverName), tool, enabled });
@@ -187,20 +221,24 @@ export function buildMcpTools(deps) {
187
221
  }));
188
222
  register(defineTool({
189
223
  name: 'mcp_manager_save',
190
- description: 'Add a new MCP server, or update the one with this name — the receipt says which. Omitted fields keep their current values, so never restate the whole server. Only on the user\'s explicit request.',
224
+ description: 'Add a new MCP server, or update the one with this name — the receipt says which. Omitted fields keep their current values, so never restate the whole server. Pass `hint` to add a usage hint or fallback note. Blocked while the active scene is locked. Only on the user\'s instruction.',
191
225
  parameters: {
192
226
  server: { type: 'string', required: true, description: 'Server name (1-32 chars, [A-Za-z0-9_-]) — the name the mcp__<server>__ tools use.' },
193
227
  transport: { type: 'string', description: 'streamable-http or stdio. Required when creating; omit = keep.' },
194
228
  url: { type: 'string', description: 'Server URL (streamable-http). Omit = keep.' },
195
229
  command: { type: 'string', description: 'Executable to spawn (stdio). Omit = keep.' },
196
- args: { type: 'string', description: 'Arguments, space separated (stdio). Omit = keep.' },
230
+ args: { type: 'string', description: 'Arguments, space or comma separated (stdio). Omit = keep.' },
197
231
  headers: { type: 'string', description: 'Extra headers, key=value per line (streamable-http). Omit = keep — omitting is what preserves the real secrets.' },
198
232
  env: { type: 'string', description: 'Extra env vars, key=value per line (stdio). Omit = keep.' },
199
233
  level: { type: 'string', description: 'project or global (project when creating); only needed for same-name servers.' },
200
- note: { type: 'string', description: 'Short hint for later: how to use it, or which one to fall back to when it is down.' },
234
+ // 名字与注入段对齐(2026-10-14 对抗性审查 P1-2):注入段与工具输出都写 `user-hint`
235
+ // (对应中文那句「用户提示:」),参数名跟着叫 `hint`,一份数据三个面一个词。
236
+ // 「写什么」那半句已经由注入框架的 `how` 行逐字说过("选服务器之前先看一眼"),
237
+ // 这里只留参数独有的信息:长度上限与截断行为(2026-10-14 审查 P2-4)。
238
+ hint: { type: 'string', description: "Short hint for later (≤300 chars; longer is stored whole but truncated in listings). '' clears the existing hint; omit = keep." },
201
239
  },
202
240
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
203
- async execute(args) {
241
+ async execute(args, exec) {
204
242
  const blocked = await deps.lockedSceneGuard();
205
243
  if (blocked)
206
244
  throw new Error(blocked);
@@ -208,8 +246,13 @@ export function buildMcpTools(deps) {
208
246
  if (!server)
209
247
  throw new Error('缺少参数:server(服务器名)');
210
248
  const levelArg = args && args.level !== undefined ? String(args.level) : undefined;
211
- const noteText = args && args.note !== undefined ? String(args.note) : '';
212
- const hasNote = noteText.trim() !== '';
249
+ // `hint` 的三态(2026-10-15 修 P1-5):省略 = 保持;`''` = **清空**;非空 = 设置。
250
+ // 此前用 `hintText.trim() !== ''` 判"要不要写备注",把"清空"与"省略"混成一件事 ——
251
+ // 模型传 `hint: ""` 想清掉已有备注时收到「没有要改的东西」,而 op 侧 `mcpm-note`
252
+ // 本来就支持空串清空(`manager.ts:431`)。判据改成"参数给没给",不是"值空不空"。
253
+ const hintGiven = Boolean(args && args.hint !== undefined);
254
+ const hintText = hintGiven ? String(args.hint) : '';
255
+ const clearsNote = hintGiven && hintText.trim() === '';
213
256
  const list = await deps.mcpmListView();
214
257
  if (!list || list.ok === false)
215
258
  throw new Error((list && list.error) || '读取 MCP 配置失败');
@@ -244,15 +287,15 @@ export function buildMcpTools(deps) {
244
287
  // 备注单独走 `mcpm-note`(`mcpm-add` 不认这个字段)。失败**不回滚**主写入:
245
288
  // 服务器已经建好了,"备注没写上"是 warning,不是失败(与 0.13.0 界面同规格)。
246
289
  let warn = '';
247
- if (hasNote) {
248
- const n = await deps.mcpmNote({ id: String(r.row.id), note: noteText });
290
+ if (hintGiven && !clearsNote) {
291
+ const n = await deps.mcpmNote({ id: String(r.row.id), note: hintText });
249
292
  if (!n || n.ok === false)
250
293
  warn = '\nWARN: 服务器已建好,但备注没写进去(' + ((n && n.error) || '未知原因') + ')';
251
294
  }
252
295
  return 'OK: created ' + r.row.id + ' at ' + r.row.level + warn;
253
296
  }
254
297
  // ── 改:省略 = 保持,且必须**显式填回**(见文件头那条 parseKv 的坑)────────
255
- const cur = locate(hits, server, levelArg, listHint());
298
+ const cur = locate(hits, server, levelArg, listHint(exec && exec.agent));
256
299
  const transport = args && args.transport !== undefined ? String(args.transport) : String(cur.transport);
257
300
  if (transport !== 'stdio' && transport !== 'streamable-http') {
258
301
  throw new Error('transport 只能是 stdio 或 streamable-http(这台现在是:' + String(cur.transport) + ')');
@@ -321,26 +364,30 @@ export function buildMcpTools(deps) {
321
364
  // 只给了备注:不必走 `mcpm-edit` 把整条重建一遍(那是一次无意义的写盘 + 刷新 updatedAt),
322
365
  // 直接写备注。这与"三个可改字段一个都没给就拒"是同一条理由的两面。
323
366
  if (!touched.length && !levelChanged) {
324
- if (!hasNote)
325
- throw new Error('没有要改的东西:transport / url / command / args / headers / env / level / note 至少给一个');
326
- const n = await deps.mcpmNote({ id: cur.id, note: noteText });
367
+ if (!hintGiven)
368
+ throw new Error('没有要改的东西:transport / url / command / args / headers / env / level / hint 至少给一个');
369
+ const n = await deps.mcpmNote({ id: cur.id, note: hintText });
327
370
  if (!n || n.ok === false)
328
371
  throw new Error((n && n.error) || '写备注失败');
329
- return 'OK: updated ' + String(cur.serverName) + '(改了 note)';
372
+ return 'OK: updated ' + String(cur.serverName) + '(' + (clearsNote ? 'hint 已清空' : '改了 hint') + ')';
330
373
  }
331
374
  const r = await deps.mcpmEdit(payload);
332
375
  if (!r || r.ok === false)
333
376
  throw new Error((r && r.error) || '修改服务器失败');
334
- const changed = touched.concat(levelChanged ? ['level'] : [], hasNote ? ['note'] : []);
377
+ // `hint` 进 `changed` 必须**等写成功之后**(2026-10-15 修 P1-6):此前按意图先算进去,
378
+ // mcpmNote 失败时回执一边说"改了 hint"、一边 WARN"备注没写进去",模型读不出到底改没改。
379
+ const changed = touched.concat(levelChanged ? ['level'] : []);
335
380
  let warn = '';
336
381
  // `mcpm-edit` 的守卫会把"打码值被顶替回真值"这件事回报出来(只含键名,不含值)——
337
382
  // 转给模型,它才知道"我没重述的密钥确实保住了"。
338
383
  if (r.warning)
339
384
  warn += '\nNOTE: ' + String(r.warning);
340
- if (hasNote) {
341
- const n = await deps.mcpmNote({ id: cur.id, note: noteText });
385
+ if (hintGiven) {
386
+ const n = await deps.mcpmNote({ id: cur.id, note: hintText });
342
387
  if (!n || n.ok === false)
343
388
  warn += '\nWARN: 服务器已保存,但备注没写进去(' + ((n && n.error) || '未知原因') + ')';
389
+ else
390
+ changed.push(clearsNote ? 'hint(已清空)' : 'hint');
344
391
  }
345
392
  if (ignored.length) {
346
393
  warn += '\nWARN: ' + ignored.join(' / ') + ' 与当前 transport(' + transport + ')无关,已忽略';
@@ -26,15 +26,23 @@ export function buildMemoryTools(deps) {
26
26
  name: 'memory_manager_list',
27
27
  // 末尾**不点名** `scene_manager_list`:场景一族在出厂默认里就是关着的,指向一条模型没有的
28
28
  // 工具只会让它去猜名字(用户 2026-09-23 把默认名单扩到十五条时去掉的这句)。
29
- description: 'List memories (id, scene, enabled, description) plus the scene injection currently follows. What is injected each turn is in the「本机当前的记忆」reminder; use this for ids and for entries that are off. all=true lists everything.',
29
+ description: 'List memories (id, scene, enabled, form flat|bundle, description) plus the scene injection currently follows. What is injected each turn is in the「当前记忆」reminder; use this for ids and for entries that are off. Pass a `scene` to filter by scene; all=true lists everything.',
30
30
  parameters: {
31
- group: { type: 'string', description: 'Optional scene filter.' },
31
+ // 参数名与其余五个域同词(2026-10-14 对抗性审查 P1-3):同一个概念在工具面只该有一个名字,
32
+ // 而 `scene` 是它在 `scene_manager_*`、`<scene>/<name>` 与注入段标题里的叫法。
33
+ // op 的内部契约字段仍是 `group`(`rules-list` 与界面侧调用方都读它),所以 execute 里
34
+ // 显式映射一次 —— 改 op 的字段名要动界面,那不是这一条能顺手做的事。
35
+ scene: { type: 'string', description: 'Optional scene filter.' },
32
36
  all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
33
37
  },
34
38
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
35
39
  async execute(args, exec) {
36
40
  const showAll = Boolean(args && args.all === true);
37
- const r = await deps.rulesOps['rules-list'](args);
41
+ // `scene` → `group`:只把 op 认的那个字段传过去,别把 `scene` 也塞给它(它不认)。
42
+ const opArgs = {};
43
+ if (args && args.scene !== undefined)
44
+ opArgs.group = args.scene;
45
+ const r = await deps.rulesOps['rules-list'](opArgs);
38
46
  if (!r || r.ok === false)
39
47
  throw new Error((r && r.error) || '读取规则失败');
40
48
  // 「会注入」的判定与记忆段同源(renderSceneMemory):global 场景恒常注入,其余场景要
@@ -54,6 +62,11 @@ export function buildMemoryTools(deps) {
54
62
  const rows = (r.rules || []).map((x) => ({ x, ...stateOf(x) }));
55
63
  const shown = showAll ? rows : rows.filter((row) => row.injects);
56
64
  const lines = shown.map(({ x, label }) => ('- ' + x.id + ' [' + (x.group || '未归属场景') + '] ' + label +
65
+ // 形态与附件(2026-10-15):此前不报,模型分不清哪条是 bundle、更不知道它带了几个附件
66
+ // (`rules-list` 早就把 `form` 与 bundle 的附件摘要给了出来,只是这里没渲染)。
67
+ (x.form === 'bundle'
68
+ ? ' · bundle' + (x.attach && x.attach.count ? '(' + x.attach.count + ' 个附件)' : '')
69
+ : '') +
57
70
  (x.description ? ' — ' + x.description : '')));
58
71
  // 注入按哪个场景走 —— 上面那句「场景未启用」只有配上它才可解读:少了这一行,
59
72
  // 模型看到一片"未启用"会以为是条目自己的问题。(场景本身的清单在 `scene_manager_list`,
@@ -74,7 +87,10 @@ export function buildMemoryTools(deps) {
74
87
  }));
75
88
  register(defineTool({
76
89
  name: 'memory_manager_read',
77
- 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).',
90
+ // "dropped by the byte budget" 改成"left out of it"(2026-10-15 注入文本审查):byte budget
91
+ // 是本插件内部术语,模型没有这个概念 —— 它只知道"这条在不在我上下文里"。说内部机制
92
+ // 等于让它去猜一个它观测不到的量。
93
+ description: 'Read the full body of one memory, plus its form (flat|bundle) and the names of its attachments. Only for ones not already in your context (disabled, in an inactive scene, or left out of it). Attachment contents are not returned — read those with your file tools.',
78
94
  parameters: {
79
95
  memory: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
80
96
  },
@@ -84,7 +100,16 @@ export function buildMemoryTools(deps) {
84
100
  const r = await deps.rulesOps['rules-read']({ id: String((args && args.memory) || '') });
85
101
  if (!r || r.ok === false)
86
102
  throw new Error((r && r.error) || '读取规则失败');
87
- return '# ' + r.rule.id + '\n\n' + (r.rule.body || '');
103
+ const rule = r.rule || {};
104
+ // 形态与附件名(2026-10-15):op 一直把 `attachments` 给了出来,这里此前**整个丢掉**,
105
+ // 于是模型连"这条记忆是 bundle、带了 3 个附件"都看不到。只给名字不给内容 ——
106
+ // 附件可能是图片或大 md,全文塞回来会吃光这一轮的上下文(与注入段附件行同口径)。
107
+ const attach = Array.isArray(rule.attachments) ? rule.attachments : [];
108
+ const form = rule.form === 'bundle' ? 'bundle' : 'flat';
109
+ const attLine = attach.length
110
+ ? '\n附件(' + attach.length + ' 个):' + attach.map((a) => String((a && a.name) || a)).join('、')
111
+ : '';
112
+ return '# ' + rule.id + '(' + form + ')' + attLine + '\n\n' + (rule.body || '');
88
113
  },
89
114
  }));
90
115
  register(defineTool({
@@ -102,7 +127,9 @@ export function buildMemoryTools(deps) {
102
127
  // 三个写侧工具共用一句 consent(`Only when the user asks or approves.` / `Only on the
103
128
  // user's instruction.`):这些动的都是"以后每一轮都会带上"的东西,模型不该顺手做。
104
129
  // 描述同时按"省 token"重写了一遍 —— 这几段文字每次请求都要发出去,废话是常驻成本。
105
- 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.',
130
+ // 后半句"freeing the byte budget"删掉(2026-10-15 注入文本审查):byte budget 是本插件
131
+ // 内部术语,模型据此做不了任何判断 —— "不再被注入"已经是它需要的全部因果。
132
+ description: 'Enable or disable one memory. Disabled memories stop being injected. Reversible. Only when the user asks or approves.',
106
133
  parameters: {
107
134
  memory: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
108
135
  enabled: { type: 'boolean', required: true, description: 'true = enable, false = disable; omission is refused.' },
@@ -145,12 +172,15 @@ export function buildMemoryTools(deps) {
145
172
  // (场景段 128 KiB),而模型很乐意把「这个项目用 pnpm」记一条 —— 那看一眼 `package.json`
146
173
  // 就知道,记下来却永久占着每一轮请求。合并 write/update 后这两句更要留:upsert 降低了
147
174
  // "再记一条"的摩擦,而摩擦正是这里唯一拦得住它的东西。
148
- description: 'Create a memory, or update the one that already has this id — the receipt says which happened. 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.',
175
+ description: 'Create a memory, or update the one that already has this id — the receipt says which happened. 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. Blocked while the active scene is locked. Only on the user\'s instruction.',
149
176
  parameters: {
150
177
  memory: { type: 'string', required: true, description: 'Memory id like <scene>/<name> — copy it from the list. `global` is the always-on scene.' },
151
178
  description: { type: 'string', description: 'One-sentence description (<=500 chars). Required when creating. Empty string = drop it and re-derive from the body; omit = keep.' },
152
179
  body: { type: 'string', description: 'Markdown body (<=256 KiB). Required when creating. Omit to keep the current body.' },
153
- nextName: { type: 'string', description: 'New memory name (= file name); omit to keep it. The old file goes to the trash.' },
180
+ // 不再承诺「旧文件进回收站」(2026-10-14 对抗性审查 P0-3):bundle 记忆改名走的是
181
+ // 「只重写正文、目录不动」那条路,一个字都不进回收站 —— 对 bundle 说进回收站是假话。
182
+ // 与上一轮 P1-1 同口径:**只说可逆,不说实现细节**。
183
+ nextName: { type: 'string', description: 'New memory name (= file name); omit to keep it.' },
154
184
  },
155
185
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
156
186
  async execute(args) {
@@ -232,7 +262,17 @@ export function buildMemoryTools(deps) {
232
262
  const warn = dup.length
233
263
  ? `\n注意:已有 ${dup.length} 条名字或描述与它很像(${dup.join('、')})—— 若是同一件事,改那一条而不是留着两条。`
234
264
  : '';
235
- return 'OK: created ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)' + warn;
265
+ // 回执必须同时说清**两个**条件(2026-10-15 smoke-test P1-3):新建的记忆在 op 层是
266
+ // `enabled: false`(ops/memory.ts 的 rulesCreate),而原先这句只提场景 —— 读起来像
267
+ // "场景一开它就自动生效",模型据此以为已经生效,实际还差一次开关。与 skill_manager_save
268
+ // 的 enableHint 同一形状:点名的工具在出厂默认名单里是关着的,关着就改说"用户开启后"。
269
+ const sceneActive = group === 'global' ||
270
+ (group !== '' && (all.activeMode === 'all' || String(all.activeScene || '') === group));
271
+ const sceneTail = sceneActive ? '' : ',并启用场景「' + group + '」';
272
+ const enableHint = deps.toolVisible('memory_manager_switch')
273
+ ? '(新建默认停用,要生效先 memory_manager_switch 开它' + sceneTail + ')'
274
+ : '(新建默认停用,用户开启后才能生效' + sceneTail + ')';
275
+ return 'OK: created ' + r.rule.id + enableHint + warn;
236
276
  },
237
277
  }));
238
278
  }
@@ -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,32 +63,51 @@ 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
- 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.',
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).' },
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).' },
64
79
  },
65
80
  output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
66
81
  async execute(args) {
67
82
  const blocked = await deps.lockedSceneGuard();
68
83
  if (blocked)
69
84
  throw new Error(blocked);
70
- const r = await deps.applyPresetGuarded(args.id);
85
+ if (args.apply === false) {
86
+ // 取消方向:`agentsmd-unapply` 自带两条护栏(场景驱动时拒绝、没有链起点时给确定
87
+ // 错误码),原样透传。回执要说清恢复成哪种 —— 起点没有文件时是**删文件**,
88
+ // 不说的话用户以为 AGENTS.md 还在(0.19.1 界面弹窗报的就是这两种)。
89
+ const r = await deps.unapplyPreset();
90
+ if (!r.ok)
91
+ throw new Error(r.error || '取消应用失败');
92
+ return r.removed === true
93
+ ? 'OK: application cancelled — ~/.dsh/AGENTS.md did not exist before the run started, so the file was removed (effective next turn).'
94
+ : 'OK: application cancelled — ~/.dsh/AGENTS.md is back to what it was before the run started (effective next turn).';
95
+ }
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);
71
100
  if (!r.ok)
72
101
  throw new Error(r.error);
73
102
  // 场景驱动时写的是「场景绑定」,回执必须说清改的是哪个场景 —— 只说「已应用到
74
103
  // AGENTS.md」会让模型对用户谎报(用户不知道自己的场景绑定被换掉了)。
75
104
  if (r.viaScene) {
76
- 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).';
77
106
  }
78
107
  // 生效时机与宿主行为对齐(index.ts 提示词服务一节考证过):dsh-agent-instructions
79
108
  // 每个 pre-step 都 stat 比对版本,写文件即下一轮重读 —— 不是"下个会话"。说错了
80
109
  // 模型会劝用户重开会话,而其实下一句话就已经在新基线下跑。
81
- 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__)' : '');
82
111
  },
83
112
  }));
84
113
  }