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.
Files changed (62) hide show
  1. package/CHANGELOG.md +560 -474
  2. package/README.md +91 -123
  3. package/README_EN.md +99 -126
  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/update.md +391 -337
  21. package/lib/audit-log.js +157 -0
  22. package/lib/client.js +4204 -2454
  23. package/lib/compat/patch-dialect.js +3 -1
  24. package/lib/compat/preset-reach.js +43 -9
  25. package/lib/compat/probe.js +149 -7
  26. package/lib/context-inject.js +91 -35
  27. package/lib/index.js +479 -114
  28. package/lib/mcp/loader-token.js +53 -28
  29. package/lib/mcp/manager.js +149 -21
  30. package/lib/mcp/secret-guard.js +21 -0
  31. package/lib/mcp/state-section.js +13 -11
  32. package/lib/memories/archive-engine.js +70 -2
  33. package/lib/memories/archive.js +8 -1
  34. package/lib/memories/constants.js +72 -8
  35. package/lib/memories/index-io.js +7 -1
  36. package/lib/memories/projection.js +67 -31
  37. package/lib/memories/service.js +17 -2
  38. package/lib/memories/snapshot.js +113 -22
  39. package/lib/op-registry.js +26 -6
  40. package/lib/ops/candidates.js +11 -0
  41. package/lib/ops/compat.js +113 -31
  42. package/lib/ops/scene-records.js +34 -0
  43. package/lib/ops/scene-sync.js +12 -1
  44. package/lib/ops/sessions.js +24 -9
  45. package/lib/ops/snapshot.js +596 -0
  46. package/lib/prompts/service.js +25 -2
  47. package/lib/scenes/candidates.js +66 -5
  48. package/lib/sessions/bridge.js +5 -0
  49. package/lib/sessions/workspace.js +17 -5
  50. package/lib/skills/core.js +105 -3
  51. package/lib/skills/service.js +34 -2
  52. package/lib/subagents/service.js +37 -4
  53. package/lib/subagents/tools.js +11 -4
  54. package/lib/tools/deps.js +16 -1
  55. package/lib/tools/mcp.js +291 -50
  56. package/lib/tools/memory.js +131 -80
  57. package/lib/tools/prompt.js +27 -13
  58. package/lib/tools/scene.js +351 -0
  59. package/lib/tools/skills.js +93 -17
  60. package/lib/tools/subagent.js +56 -45
  61. package/lib/tools/table.js +208 -15
  62. package/package.json +24 -20
@@ -9,8 +9,10 @@
9
9
  // 全部抹掉、把 `!!js` 引号风格改掉、把不认识的键重排 —— 一次"设置令牌"不该顺带重写整个
10
10
  // 文件。(这与 `override-blocks.ts` 选择"只算删除区间"是同一条理由。)
11
11
  //
12
- // 边界:只动**本插件 id 那条 insert 子条目**内的 `config:` / `token:` 两行,别的一概不碰。
13
- // 找不到那条行时如实回 `found: false`,由调用方决定怎么报错,绝不"找不到就新建一条"。
12
+ // 边界:只动**本插件 id 那条条目**内的 `config:` / `token:` 两行,别的一概不碰。条目有两种
13
+ // 形状(见 ROW_ID_RE 的注释):0.1.5 补丁里嵌套的 insert 子条目、0.1.7 profile 补丁里顶层
14
+ // 的覆盖条目 —— 同一套相对缩进逻辑都认。找不到那条行时如实回 `found: false`,由调用方
15
+ // 决定怎么报错;唯一的"新建"路径是 0.1.7 的覆盖条目(buildLoaderOverrideEntry,见下)。
14
16
  //
15
17
  // 两个**不改写、只拒绝**的形状(2026-09-19 审计 T-28):
16
18
  // - `config:` 写成 flow style(`config: {token: x}`)或缩进不是 6 空格 ⇒ 本模块的
@@ -22,17 +24,15 @@
22
24
  const yq = (value) => JSON.stringify(value);
23
25
  /** 本插件 loader 行的 id(与 cordis.patch.yml 里那条 insert 子条目一致)。 */
24
26
  export const PLUGIN_LOADER_ID = 'dsh-plugin-tool-management';
25
- const ROW_ID_RE = /^ {4}- id:\s*(\S+)\s*$/;
26
- const CONFIG_RE = /^ {6}config:\s*$/;
27
+ /**
28
+ * 行首 `- id:` 的缩进就是条目的几何原点:0.1.5 的补丁把条目嵌在映射里(4 空格,config
29
+ * 在 6、键在 8);0.1.7 的 profile 覆盖条目在顶层(0 空格,config 在 2、键在 4)。两类形状
30
+ * 用同一套「相对缩进」逻辑定位,readLoaderToken / rewriteConfigKey 都按条目自己的缩进算。
31
+ */
32
+ const ROW_ID_RE = /^( *)- id:\s*(\S+)\s*$/;
27
33
  /** `config:` 的任何别的写法(flow style `config: {…}`、非空值、别的缩进)—— 一律不改写。 */
28
34
  const CONFIG_OTHER_RE = /^\s*config:\s*\S/;
29
- const keyRe = (key) => new RegExp('^ {8}' + key + ':\\s*(.*)$');
30
- const TOKEN_RE = keyRe('token');
31
- const DISABLED_RE = keyRe('tokenDisabled');
32
- /** 该子条目块内的行:缩进 ≥ 6(键),或空行/注释(块间空隙,归前一块)。 */
33
- const inRowBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || /^ {6,}\S/.test(line);
34
- /** config 块内的行:缩进 ≥ 8,或空行/注释。 */
35
- const inConfigBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || /^ {8,}\S/.test(line);
35
+ const keyRe = (indent, key) => new RegExp('^ {' + indent + '}' + key + ':\\s*(.*)$');
36
36
  const unquote = (raw) => {
37
37
  const text = raw.trim();
38
38
  if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
@@ -76,12 +76,23 @@ function parseFlag(raw) {
76
76
  /** 定位插件 loader 行及其 `config:` 块(找不到行时 `start < 0`)。 */
77
77
  function locate(content, pluginId) {
78
78
  const lines = content.split(/\r?\n/);
79
- const start = lines.findIndex((line) => {
80
- const m = ROW_ID_RE.exec(line);
81
- return !!m && m[1] === pluginId;
82
- });
79
+ let start = -1;
80
+ let indent = 0;
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const m = ROW_ID_RE.exec(lines[i]);
83
+ if (m && m[2] === pluginId) {
84
+ start = i;
85
+ indent = m[1].length;
86
+ break;
87
+ }
88
+ }
83
89
  if (start < 0)
84
- return { lines, start: -1, end: -1, configAt: -1, configEnd: -1, configUnrecognised: false, configDuplicated: false };
90
+ return { lines, start: -1, end: -1, indent: 0, configAt: -1, configEnd: -1, configUnrecognised: false, configDuplicated: false };
91
+ // 该子条目块内的行:缩进 ≥ 条目+2(键),或空行/注释(块间空隙,归前一块)。
92
+ const inRowBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || new RegExp('^\\s{' + (indent + 2) + ',}\\S').test(line);
93
+ // config 块内的行:缩进 ≥ 条目+4,或空行/注释。
94
+ const inConfigBlock = (line) => line.trim() === '' || /^\s*#/.test(line) || new RegExp('^\\s{' + (indent + 4) + ',}\\S').test(line);
95
+ const configRe = new RegExp('^ {' + (indent + 2) + '}config:\\s*$');
85
96
  let end = start + 1;
86
97
  while (end < lines.length && inRowBlock(lines[end]))
87
98
  end++;
@@ -89,7 +100,7 @@ function locate(content, pluginId) {
89
100
  let configCount = 0;
90
101
  let configUnrecognised = false;
91
102
  for (let i = start + 1; i < end; i++) {
92
- if (CONFIG_RE.test(lines[i])) {
103
+ if (configRe.test(lines[i])) {
93
104
  configCount += 1;
94
105
  if (configAt < 0)
95
106
  configAt = i;
@@ -106,24 +117,26 @@ function locate(content, pluginId) {
106
117
  while (configEnd < end && inConfigBlock(lines[configEnd]))
107
118
  configEnd++;
108
119
  }
109
- return { lines, start, end, configAt, configEnd, configUnrecognised, configDuplicated: configCount > 1 };
120
+ return { lines, start, end, indent, configAt, configEnd, configUnrecognised, configDuplicated: configCount > 1 };
110
121
  }
111
122
  /** 读插件 loader 行里的 `config.token` / `config.tokenDisabled`(没写 / 没这条行都返回空值)。 */
112
123
  export function readLoaderToken(content, pluginId = PLUGIN_LOADER_ID) {
113
- const { lines, start, end, configAt, configEnd } = locate(content, pluginId);
124
+ const { lines, start, configAt, configEnd, indent } = locate(content, pluginId);
114
125
  if (start < 0)
115
126
  return { found: false, token: '', disabled: false };
116
127
  if (configAt < 0)
117
128
  return { found: true, token: '', disabled: false };
129
+ const tokenRe = keyRe(indent + 4, 'token');
130
+ const disabledRe = keyRe(indent + 4, 'tokenDisabled');
118
131
  let token = '';
119
132
  let disabled = false;
120
133
  for (let i = configAt + 1; i < configEnd; i++) {
121
- const t = TOKEN_RE.exec(lines[i]);
134
+ const t = tokenRe.exec(lines[i]);
122
135
  if (t) {
123
136
  token = unquote(t[1]);
124
137
  continue;
125
138
  }
126
- const d = DISABLED_RE.exec(lines[i]);
139
+ const d = disabledRe.exec(lines[i]);
127
140
  // `null` = 值既不是 true 也不是 false(宿主同样不当成"已关闭"),按未关闭处理。
128
141
  if (d)
129
142
  disabled = parseFlag(d[1]) === true;
@@ -143,8 +156,8 @@ export function readLoaderToken(content, pluginId = PLUGIN_LOADER_ID) {
143
156
  *
144
157
  * @param line - 要写入的整行(含缩进);`null` = 删除该键。
145
158
  */
146
- function rewriteConfigKey(content, key, line, pluginId) {
147
- const { lines, start, end, configAt, configEnd, configUnrecognised, configDuplicated } = locate(content, pluginId);
159
+ function rewriteConfigKey(content, key, value, pluginId) {
160
+ const { lines, start, end, indent, configAt, configEnd, configUnrecognised, configDuplicated } = locate(content, pluginId);
148
161
  if (start < 0)
149
162
  return { found: false };
150
163
  // 写之前先自检:这两种形状一旦落盘就是"DSH 下次起不来"(官方 js-yaml 对重复映射键 throw),
@@ -165,7 +178,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
165
178
  refused: '本插件那条 loader 行里的 `config:` 不是插件认识的写法(flow style 或缩进不是 6 空格),自动改写会插入重复键并导致 DSH 启动失败:请手工把它改回 ` config:` + 缩进 8 空格的键值行,或在配置里手工写 token。',
166
179
  };
167
180
  }
168
- const re = keyRe(key);
181
+ const re = keyRe(indent + 4, key);
169
182
  const keyAt = (() => {
170
183
  if (configAt < 0)
171
184
  return -1;
@@ -174,6 +187,8 @@ function rewriteConfigKey(content, key, line, pluginId) {
174
187
  return i;
175
188
  return -1;
176
189
  })();
190
+ // 要写的整行(含缩进);`value === null` = 删除该键。缩进按条目自己的原点算。
191
+ const line = value === null ? null : ' '.repeat(indent + 4) + key + ': ' + value;
177
192
  // 写之后的第二道自检:不管走哪条分支,产物里 `config:` 只能有一个。宁可原样退回,
178
193
  // 也不让一次界面点击产出一个"DSH 下次起不来"的补丁文件。
179
194
  const commit = (nextContent) => {
@@ -194,7 +209,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
194
209
  // 这个键之外还有别的键吗?(在原行上判断,避免 splice 之后的下标漂移算错)
195
210
  const otherKeys = lines
196
211
  .slice(configAt + 1, configEnd)
197
- .some((row, index) => configAt + 1 + index !== keyAt && /^ {8,}\S/.test(row));
212
+ .some((row, index) => configAt + 1 + index !== keyAt && new RegExp('^\\s{' + (indent + 4) + ',}\\S').test(row));
198
213
  next.splice(keyAt, 1);
199
214
  if (!otherKeys)
200
215
  next.splice(configAt, 1);
@@ -211,7 +226,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
211
226
  return commit(next.join('\n'));
212
227
  }
213
228
  // 没有 config:紧跟 `- id:` 之后插入(映射键的顺序不影响 YAML 语义,位置固定才好断言)。
214
- next.splice(start + 1, 0, ' config:', line);
229
+ next.splice(start + 1, 0, ' '.repeat(indent + 2) + 'config:', line);
215
230
  return commit(next.join('\n'));
216
231
  }
217
232
  /**
@@ -223,7 +238,7 @@ function rewriteConfigKey(content, key, line, pluginId) {
223
238
  * 否则给出新全文与"是否真的改了"。
224
239
  */
225
240
  export function applyLoaderToken(content, token, pluginId = PLUGIN_LOADER_ID) {
226
- return rewriteConfigKey(content, 'token', token === null ? null : ' token: ' + yq(token), pluginId);
241
+ return rewriteConfigKey(content, 'token', token === null ? null : yq(token), pluginId);
227
242
  }
228
243
  /**
229
244
  * 打开(`true`)或关掉(`false`)令牌功能 —— **不动 `config.token` 本身**。
@@ -234,5 +249,15 @@ export function applyLoaderToken(content, token, pluginId = PLUGIN_LOADER_ID) {
234
249
  * @param disabled - `true` 写开关;`false` / `null` 删掉该键。
235
250
  */
236
251
  export function applyLoaderTokenDisabled(content, disabled, pluginId = PLUGIN_LOADER_ID) {
237
- return rewriteConfigKey(content, 'tokenDisabled', disabled === true ? ' tokenDisabled: true' : null, pluginId);
252
+ return rewriteConfigKey(content, 'tokenDisabled', disabled === true ? 'true' : null, pluginId);
253
+ }
254
+ /**
255
+ * 0.1.7 bundle 挂载下的令牌落点:profile 补丁里的**覆盖条目**(官方 `applyEntryPatches`
256
+ * 的 id-targeted override —— 不带 name / insert、只带 id + config,宿主在 bundle 层之后
257
+ * 应用它,整体替换那条 loader 的 `config`;bundle 条目没有 config,替换零丢失)。
258
+ * 注意:同 id 的 **insert** 条目才会重复挂载、启动失败;覆盖条目是官方支持的改法,
259
+ * 目标不存在的树只是逐条警告跳过(一份 overlay 打多个表面,不必每棵树都命中)。
260
+ */
261
+ export function buildLoaderOverrideEntry(token, pluginId = PLUGIN_LOADER_ID) {
262
+ return '- id: ' + pluginId + '\n config:\n token: ' + yq(token);
238
263
  }
@@ -1,4 +1,4 @@
1
- // MCP 管理域 —— 侧车读写(备注 / 设置 / 停用表)+ 补丁文件行编辑 + 16 个 op 的实现。
1
+ // MCP 管理域 —— 侧车读写(备注 / 设置 / 停用表)+ 补丁文件行编辑 + 17 个 op 的实现。
2
2
  //
3
3
  // 2026-09-19 从 index.ts 的 apply 闭包原样抽出(块 A 1795-1913 + 块 B 1920-3100)。
4
4
  // 为什么单独一个文件:这 1300 行只做一件事 —— 把「MCP 配置」翻译成补丁文件的改动与
@@ -18,12 +18,15 @@
18
18
  // 其余 14 项(ensurePaths / withWriteLock / readPatch / pluginInventory / memoriesService 等)
19
19
  // 由 deps 显式传入。
20
20
  import { clearRuntimeNote, noteRuntime } from '../compat/runtime-notes.js';
21
+ // 配置体检查 PATH 用(见 mcpmInspect):execFile 不走 shell,参数是数组 —— 体检只查询
22
+ // 命令名,**绝不执行**配置里的命令本身。
23
+ import { execFile } from 'node:child_process';
21
24
  import { MCP_CLIENT_MODULE } from '../host-names.js';
22
25
  import { hubPath } from '../hub.js';
23
26
  import { planOverrideCompaction } from './override-blocks.js';
24
27
  import { createMcpStateCatalog } from './state-section.js';
25
28
  import { appendBlock, buildDisableBlock, buildInsertBlock, parseRows, removeEntryAll, removeMarked, spliceRanges, splitLines, } from './patch-yaml.js';
26
- import { describeMaskedOutcome, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
29
+ import { describeMaskedOutcome, maskUrlQuery, maskedKeysIn, resolveMaskedKv, resolveMaskedUrl } from './secret-guard.js';
27
30
  /**
28
31
  * 归一化「已知工具」侧车的一行值。
29
32
  *
@@ -524,6 +527,7 @@ export function createMcpManager(deps) {
524
527
  kind: 'write',
525
528
  fallback: 'inform-only',
526
529
  detail: 'agent scope 上没有可用的 tools.restrict(官方接口变了或该 scope 未暴露 tools):停用的 MCP 工具与兼容页关掉的本插件工具仍会出现在模型可见的工具表里,执行侧拦截仍然生效。',
530
+ detailKey: 'mcp-tool-visibility.no-restrict',
527
531
  });
528
532
  return;
529
533
  }
@@ -550,6 +554,8 @@ export function createMcpManager(deps) {
550
554
  kind: 'write',
551
555
  fallback: 'inform-only',
552
556
  detail: '有工具没能从模型可见的工具表里摘掉(' + message(e) + '):执行侧拦截仍然生效,模型仍能看到该工具的名字。',
557
+ detailKey: 'mcp-tool-visibility.partial',
558
+ params: { reason: message(e) },
553
559
  });
554
560
  }
555
561
  }
@@ -653,19 +659,8 @@ export function createMcpManager(deps) {
653
659
  }
654
660
  return out;
655
661
  }
656
- function maskUrlQuery(url) {
657
- if (!url)
658
- return url;
659
- try {
660
- const parsed = new URL(url);
661
- if (parsed.search)
662
- parsed.search = '?<redacted>';
663
- return parsed.toString();
664
- }
665
- catch (e) {
666
- return url;
667
- }
668
- }
662
+ // `maskUrlQuery` 已挪到 ./secret-guard.js:那里是两条打码形态的唯一口径,而使用方
663
+ // 现在有两个(本文件的列表视图 + index.ts 的确认卡)。
669
664
  /** Shared UI rows: mcpmList plus notes, secrets masked unless `reveal`. */
670
665
  async function mcpmRowsWithNotes(reveal) {
671
666
  const result = await mcpmList();
@@ -1554,6 +1549,132 @@ export function createMcpManager(deps) {
1554
1549
  return { ok: true, removed: total, ids: removed, files };
1555
1550
  });
1556
1551
  }
1552
+ // ---------- 配置体检(0.15.0 B1)--------------------------------------------------
1553
+ // 只读、零副作用:不拉起子进程、不连网、一个字节都不写盘。唯一的对外调用是 `where` /
1554
+ // `which` 查询 PATH —— 它查的是「这个命令名能不能找到」,绝不执行配置里的命令本身。
1555
+ //
1556
+ // 为什么单独读补丁而不复用 `mcpmList()`:那个函数顺带回写「已知工具」缓存、还要问一次
1557
+ // schema 注册表。体检的承诺是「点了什么都不变」,所以这里只走 readPatch + parseRows。
1558
+ const INSPECT_PATH_TIMEOUT_MS = 3000;
1559
+ /** 命令是否在 PATH 上:`unknown`(查不了)与 `missing`(确认没有)必须分开 —— 前者不是问题。 */
1560
+ async function probeCommandOnPath(command) {
1561
+ if (!command)
1562
+ return 'unknown';
1563
+ const finder = process.platform === 'win32' ? 'where' : 'which';
1564
+ return await new Promise((resolve) => {
1565
+ execFile(finder, [command], { timeout: INSPECT_PATH_TIMEOUT_MS, windowsHide: true }, (error, stdout) => {
1566
+ if (!error) {
1567
+ resolve(String(stdout || '').trim() ? 'ok' : 'missing');
1568
+ return;
1569
+ }
1570
+ // ENOENT = 连 where/which 自己都没跑到;killed = 超时。两种都是「无法确认」,
1571
+ // 剩下的(退出码非 0)才是「PATH 上没有这条命令」。
1572
+ const code = error.code;
1573
+ if (code === 'ENOENT' || error.killed === true)
1574
+ resolve('unknown');
1575
+ else
1576
+ resolve('missing');
1577
+ });
1578
+ });
1579
+ }
1580
+ /** 一行配置 → 检查项。`level: 'warn'` 才点黄;`info` 是「查不了 / 不算问题」那类说明。 */
1581
+ async function inspectChecks(row, duplicateIds) {
1582
+ const checks = [];
1583
+ const declared = String(row.transport || '');
1584
+ const url = String(row.url || '');
1585
+ const command = String(row.command || '');
1586
+ // transport 缺失/不认识时按字段形状推:历史上手工改过补丁文件的行会绕过 normalize,
1587
+ // 直接判「没有 transport」会把一条其实能用的配置说成坏的。
1588
+ const transport = declared === 'stdio' || (!declared && command && !url) ? 'stdio'
1589
+ : declared === 'streamable-http' || (!declared && url) ? 'streamable-http'
1590
+ : declared;
1591
+ if (!declared && transport)
1592
+ checks.push({ id: 'transportMissing', level: 'info' });
1593
+ /** 值必须是字符串映射:非对象整片算坏,对象里逐个挑出非字符串的键。 */
1594
+ const badKv = (map) => {
1595
+ if (map == null)
1596
+ return [];
1597
+ if (typeof map !== 'object' || Array.isArray(map))
1598
+ return ['*'];
1599
+ return Object.keys(map).filter((k) => typeof map[k] !== 'string');
1600
+ };
1601
+ if (transport === 'stdio') {
1602
+ if (!command)
1603
+ checks.push({ id: 'noCommand', level: 'warn' });
1604
+ else {
1605
+ // 唯一一次外部调用。npx / uvx 这类 shim 找不到时界面给的下半句是「shim 属正常可忽略」,
1606
+ // 不让用户以为命令坏了。
1607
+ const found = await probeCommandOnPath(command);
1608
+ if (found === 'missing')
1609
+ checks.push({ id: 'cmdMissing', level: 'warn', params: { command } });
1610
+ else if (found === 'unknown')
1611
+ checks.push({ id: 'cmdUnknown', level: 'info', params: { command } });
1612
+ }
1613
+ if (row.args != null && !Array.isArray(row.args))
1614
+ checks.push({ id: 'argsNotArray', level: 'warn' });
1615
+ const badEnv = badKv(row.env);
1616
+ if (badEnv.length)
1617
+ checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'env', keys: badEnv.join('、') } });
1618
+ }
1619
+ else if (transport === 'streamable-http') {
1620
+ if (!url)
1621
+ checks.push({ id: 'noUrl', level: 'warn' });
1622
+ else if (!/^https?:\/\//.test(url))
1623
+ checks.push({ id: 'badUrl', level: 'warn', params: { url } });
1624
+ const badHeaders = badKv(row.headers);
1625
+ if (badHeaders.length)
1626
+ checks.push({ id: 'kvNotString', level: 'warn', params: { where: 'headers', keys: badHeaders.join('、') } });
1627
+ }
1628
+ else if (!command && !url) {
1629
+ checks.push({ id: 'noEndpoint', level: 'warn' });
1630
+ }
1631
+ else {
1632
+ checks.push({ id: 'badTransport', level: 'warn', params: { transport } });
1633
+ }
1634
+ if (duplicateIds > 1)
1635
+ checks.push({ id: 'nameDup', level: 'warn', params: { name: String(row.serverName || ''), count: duplicateIds } });
1636
+ return checks;
1637
+ }
1638
+ async function mcpmInspect(args) {
1639
+ const p = await ensurePaths();
1640
+ const rows = [];
1641
+ const errors = [];
1642
+ for (const level of ['project', 'global']) {
1643
+ const abs = level === 'project' ? p.projectPatch : p.globalPatch;
1644
+ let content = '';
1645
+ try {
1646
+ content = await readPatch(abs);
1647
+ }
1648
+ catch (e) {
1649
+ errors.push(level + ': ' + message(e));
1650
+ continue;
1651
+ }
1652
+ const { rows: fileRows } = parseRows(content);
1653
+ for (const r of fileRows)
1654
+ rows.push(normalizeRow(r, level, abs));
1655
+ }
1656
+ // 重名判据:同一个 serverName 挂在**不同 id** 下。同 id 跨层是正常遮蔽(界面已有「重复 id」标签),
1657
+ // 而按名字注册的 `mcp__<serverName>__*` 会撞车 —— 那才是配置问题。
1658
+ const idsByName = {};
1659
+ for (const r of rows) {
1660
+ const n = String(r.serverName || '');
1661
+ if (!idsByName[n])
1662
+ idsByName[n] = new Set();
1663
+ idsByName[n].add(String(r.id));
1664
+ }
1665
+ const onlyId = args && String(args.id || '') ? String(args.id) : '';
1666
+ const onlyLevel = args && String(args.level || '') ? String(args.level) : '';
1667
+ const results = [];
1668
+ for (const r of rows) {
1669
+ if (onlyId && String(r.id) !== onlyId)
1670
+ continue;
1671
+ if (onlyLevel && String(r.level) !== onlyLevel)
1672
+ continue;
1673
+ const checks = await inspectChecks(r, (idsByName[String(r.serverName || '')] || new Set()).size);
1674
+ results.push({ id: r.id, serverName: r.serverName, level: r.level, transport: r.transport || null, checks });
1675
+ }
1676
+ return { ok: true, results, errors };
1677
+ }
1557
1678
  async function mcpmExport() {
1558
1679
  const p = await ensurePaths();
1559
1680
  const list = await mcpmList();
@@ -1800,6 +1921,7 @@ export function createMcpManager(deps) {
1800
1921
  'mcpm-compact': mcpmCompact,
1801
1922
  'mcpm-export': mcpmExport,
1802
1923
  'mcpm-import': mcpmImport,
1924
+ 'mcpm-inspect': mcpmInspect,
1803
1925
  },
1804
1926
  readDisabledTools,
1805
1927
  readKnownMcpTools,
@@ -1810,13 +1932,19 @@ export function createMcpManager(deps) {
1810
1932
  mcpmRowsWithNotes,
1811
1933
  readPluginSettings,
1812
1934
  isToolDisabled: (name) => isToolDisabledIn(disabledToolsCache ? disabledToolsCache.value : {}, name),
1813
- /** 停用表里的工具条数(读 TTL 缓存;功能总览用)。 */
1814
- disabledToolCount: () => {
1935
+ /** 停用表的两个口径(读 TTL 缓存;功能总览用):`*` 通配的台数 + 单独停用的工具数。 */
1936
+ disabledToolSummary: () => {
1815
1937
  const map = disabledToolsCache ? disabledToolsCache.value : {};
1816
- let n = 0;
1817
- for (const list of Object.values(map))
1818
- n += Array.isArray(list) ? list.length : 0;
1819
- return n;
1938
+ let servers = 0;
1939
+ let tools = 0;
1940
+ for (const list of Object.values(map)) {
1941
+ if (!Array.isArray(list))
1942
+ continue;
1943
+ if (list.indexOf('*') >= 0)
1944
+ servers += 1;
1945
+ tools += list.filter((name) => name !== '*').length;
1946
+ }
1947
+ return { servers, tools };
1820
1948
  },
1821
1949
  warmUp,
1822
1950
  scheduleToolRestrictions,
@@ -69,6 +69,27 @@ export function isMaskedUrl(value) {
69
69
  return false;
70
70
  }
71
71
  }
72
+ /**
73
+ * 把 URL 的查询串换成 `<redacted>` 哨兵 —— 打码的**产出**侧,与上面的 `isMaskedUrl` 配对。
74
+ *
75
+ * 放在这里而不是 manager.ts 里:本模块是这两条打码形态的唯一口径,而使用方已经有两个
76
+ * ——MCP 列表视图(`mcpmRowsWithNotes`),以及模型调 `mcp_manager_save` 时的确认卡
77
+ * (卡里要回显"新 URL",但用户批准的是「跑这个地址」,查询串里的凭据不该明文进卡片)。
78
+ * 两处各写一份的话,改了一处就会漂成两种打码形态,而 `isMaskedUrl` 只认其中一种。
79
+ */
80
+ export function maskUrlQuery(url) {
81
+ if (!url)
82
+ return url;
83
+ try {
84
+ const parsed = new URL(url);
85
+ if (parsed.search)
86
+ parsed.search = '?' + URL_REDACTED_QUERY;
87
+ return parsed.toString();
88
+ }
89
+ catch {
90
+ return url;
91
+ }
92
+ }
72
93
  /**
73
94
  * 把表单来的 URL 收敛成"可以安全写盘"的一个(与 `resolveMaskedKv` 同方向:往旧值收)。
74
95
  *
@@ -21,13 +21,12 @@
21
21
  // 不列的三类:**没开**(用户明确要求)、**工具全被停用**(模型调不到,留着只会谎报数量)、
22
22
  // **从未连上过**(缓存也空 —— 一直没成功过,不该反复打扰;这条抄的是 Claude Code 的通知
23
23
  // 策略:一直没连上的不提示,昨天还好今天挂的才提示)。
24
- // - 只列 **server 名 + 工具数 + 备注**;**具体工具名绝不注入** ——
24
+ // - 只列 **server 名 + 状态(仅②档)+ 备注**;**具体工具名绝不注入** ——
25
25
  // 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
26
26
  // 段里再列一遍是重复;模型按前缀就能对上号。
27
- // - 工具数只算**当前可用**的:被停用的那一部分不显示(它就是模型 schema 里没有的),
28
- // 数字必须与模型能调到的工具对得上。②档没有当前数字,所以写的是「上次连上时 N 个工具」——
29
- // 那是历史事实,不是现状,措辞上必须区分开。
30
- // - 无备注 → 只有名字 + 工具数(正是「不写就只知道名字」)。
27
+ // - **工具数不列**(2026-09-23):模型不能凭"这台有 N 个工具"做任何事(能调哪些在它自己的
28
+ // schema 里),而 17 B/台 × 每轮的开销是白付的。
29
+ // - 无备注 → 只有名字(①档)或名字 + 状态(②档)。
31
30
  // - 不写「已启用」字样:能进段的都是可用的,标状态是废话;②档则必须标,否则就是谎报。
32
31
  // - 两档都空 → 返回 `''`(renderPrompt 删空段,零 token 成本)。
33
32
  //
@@ -108,11 +107,14 @@ export function renderMcpStateSection(rows, opts = {}) {
108
107
  const shownOffline = [...offline].sort(byName).slice(0, cap);
109
108
  const lineOf = (r, count, isOffline) => {
110
109
  const name = String(r.serverName ?? r.id);
111
- // ②档必须把状态写出来:不写就等于谎报"可用"。「上次连上时」四个字不能省 ——
112
- // 那个数字是历史事实,写成"(N 个工具)"会被读成现状。
113
- const head = isOffline
114
- ? '- **' + name + '**(当前未连上;上次连上时 ' + count + ' 个工具)'
115
- : '- **' + name + '**(' + count + ' 个工具)';
110
+ // ②档必须把状态写出来:不写就等于谎报"可用"。
111
+ //
112
+ // 2026-09-23:两档的**工具数都去掉了**(用户看到实际注入后要求精简)。数字对模型没有
113
+ // 动作价值 —— 它不能凭"这台有 8 个工具"列举出工具名(真正能调的都在它自己的工具
114
+ // schema 里,按 `mcp__<server>__` 前缀就能对上号),而每轮都要付 17 B/台。
115
+ // 顺带消掉一处隐患:②档原来写「上次连上时 N 个工具」,那个数字是**历史事实**,措辞上
116
+ // 必须和现状区分开;数字没了,这个歧义也就不存在了。
117
+ const head = isOffline ? '- **' + name + '**(当前未连上)' : '- **' + name + '**';
116
118
  const note = normalizeMcpNote(r.notes, noteMaxLength);
117
119
  return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
118
120
  };
@@ -120,7 +122,7 @@ export function renderMcpStateSection(rows, opts = {}) {
120
122
  ...shownUsable.map((r) => lineOf(r, liveEnabledOf(r), false)),
121
123
  ...shownOffline.map((r) => lineOf(r, knownOf(r), true)),
122
124
  ];
123
- // 只有清单:标题(`## 本机 MCP 服务器的当前状态`)与"该拿它做什么"由注入通道的框架承担
125
+ // 只有清单:标题(`# 本机 MCP 服务器的当前状态`)由注入通道的框架承担
124
126
  // (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
125
127
  // 框架已经写了标题,正文再写一遍就是每步多付一行 token。
126
128
  const out = [...lines];
@@ -108,6 +108,11 @@ export function createArchiveEngine(deps) {
108
108
  await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
109
109
  }
110
110
  }
111
+ // 模型工具表(C1):按快照原值整体回写。老快照没有这一栏 → 一个字都不动(与进场景前一致)。
112
+ if (snapshot.toolTableHidden) {
113
+ await deps.applyToolTableHidden(snapshot.toolTableHidden);
114
+ }
115
+ deps.audit?.('scene-restore', '');
111
116
  }
112
117
  /**
113
118
  * 失败回滚:运行时还原 + 切片写回;每步失败都记下来,绝不谎报「已回滚」。
@@ -172,6 +177,15 @@ export function createArchiveEngine(deps) {
172
177
  if (entries.length)
173
178
  await deps.applyMcpNotes(entries);
174
179
  }
180
+ // 工具表方案(C1):改档案 = 立刻生效,这一域也得跟上。快照里的原值**不动** ——
181
+ // 退出场景仍然回到「进场景前」那份名单,而不是回到上一次档案的方案。
182
+ const reapplyPreset = (archive && archive.toolTablePreset) || '';
183
+ if (reapplyPreset) {
184
+ const target = await deps.toolTablePresetHidden(reapplyPreset);
185
+ if (target)
186
+ await deps.applyToolTableHidden(target);
187
+ }
188
+ deps.audit?.('scene-apply', '');
175
189
  return { mcpServers: mcpPlan.serverSwitches, skillSources: skillsPlan.sourceSwitches };
176
190
  }
177
191
  const ops = {
@@ -343,6 +357,21 @@ export function createArchiveEngine(deps) {
343
357
  for (const s of skillsPlan.sourceSwitches)
344
358
  (s.enabled ? sourcesOn : sourcesOff).push(s.root);
345
359
  const stale = [...mcpPlan.stale, ...skillsPlan.stale];
360
+ // 工具表方案(C1):绑了就说清「会切成哪一份、关几条」。方案在别处被删掉 / 换机器之后
361
+ // 查不到时 hiddenCount 给 null,界面那句会改成"方案已不存在,这次不会切换"——
362
+ // 比让预览卡继续显示一条看不懂的数字诚实。
363
+ const previewPreset = (archive && archive.toolTablePreset) || '';
364
+ let toolPreset = null;
365
+ if (previewPreset) {
366
+ let list = null;
367
+ try {
368
+ list = await deps.toolTablePresetHidden(previewPreset);
369
+ }
370
+ catch (e) {
371
+ return { ok: false, error: `读取模型工具表失败:${msg(e)}` };
372
+ }
373
+ toolPreset = { name: previewPreset, hiddenCount: list ? list.length : null };
374
+ }
346
375
  const notesDefined = !!(archive && archive.mcpNotes);
347
376
  const noteCount = notesDefined
348
377
  ? Object.keys(archive.mcpNotes).filter((server) => archive.mcp && server in archive.mcp).length
@@ -375,6 +404,7 @@ export function createArchiveEngine(deps) {
375
404
  sourcesOn: clip(sourcesOn), sourcesOff: clip(sourcesOff),
376
405
  },
377
406
  subagents: { on: clip(personas.on), off: clip(personas.off) },
407
+ toolPreset,
378
408
  stale: clip(stale),
379
409
  truncated,
380
410
  };
@@ -433,12 +463,29 @@ export function createArchiveEngine(deps) {
433
463
  if (hadKeys && archive.subagents.length === 0)
434
464
  delete archive.subagents;
435
465
  }
466
+ if (archive.toolTablePreset) {
467
+ // 方案名查不到(被删了 / 档案是从别的机器带来的)→ 丢弃这一栏并报告:
468
+ // 绑一个不存在的方案不该在进场景时变成一句"方案不存在"的硬失败。
469
+ const found = await deps.toolTablePresetHidden(archive.toolTablePreset);
470
+ if (!found) {
471
+ stale.push('toolTable/' + archive.toolTablePreset);
472
+ delete archive.toolTablePreset;
473
+ }
474
+ }
436
475
  // 记忆段(P5 已废弃):不再写入、不再校验。记忆的开关是 `rules[*].enabled` 单一真相源,
437
476
  // 老档案里残留的 `memories` 字段被忽略(不迁移、不删除,回滚代码时仍可读)。
438
477
  // 保存档案时顺手把残留字段清掉,避免新旧语义并存造成误读。
439
478
  if (archive.memories !== undefined)
440
479
  delete archive.memories;
441
480
  const slice = await deps.loadSlice();
481
+ // 工具表方案的绑定入口已改到「修改场景」表单(0.15.0 用户裁定):档案保存的载荷
482
+ // 不再携带这一栏,**不带 ≠ 解绑** —— 保留现值(解绑走 rules-update-scene 的空串;
483
+ // 带上来的值仍走上面的 stale 校验,保留的现值由进出场景时的 stale 处理兜底)。
484
+ if (archive.toolTablePreset === undefined) {
485
+ const prev = slice.archives[scene];
486
+ if (prev && typeof prev.toolTablePreset === 'string' && prev.toolTablePreset)
487
+ archive.toolTablePreset = prev.toolTablePreset;
488
+ }
442
489
  if (Object.keys(archive).length === 0)
443
490
  delete slice.archives[scene];
444
491
  else
@@ -590,11 +637,28 @@ export function createArchiveEngine(deps) {
590
637
  return { ok: false, error: `读取备注状态失败(未改动任何东西):${msg(e)}` };
591
638
  }
592
639
  }
640
+ // 工具表方案(C1):**改任何东西之前**先把现状读出来 —— 读失败就等于什么都没动。
641
+ // 没绑定方案就完全不碰这一域(也不往快照里加栏),老场景行为逐字节不变。
642
+ const toolTablePreset = (archive && archive.toolTablePreset) || '';
643
+ let toolTableBefore = null;
644
+ let toolTableTarget = null;
645
+ if (toolTablePreset) {
646
+ try {
647
+ toolTableBefore = await deps.currentToolTableHidden();
648
+ toolTableTarget = await deps.toolTablePresetHidden(toolTablePreset);
649
+ }
650
+ catch (e) {
651
+ return { ok: false, error: `读取模型工具表现状失败(未改动任何东西):${msg(e)}` };
652
+ }
653
+ if (!toolTableTarget) {
654
+ return { ok: false, error: `场景绑定的工具表方案「${toolTablePreset}」已不存在,未进入场景(先在场景档案里换一份或取消绑定)` };
655
+ }
656
+ }
593
657
  // 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
594
658
  // 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
595
659
  // 上层两行传**现状全量**(上面的 mcpServerStates / skillSources 就是进场景前的值),
596
660
  // 不是计划里那几行 —— 退出按「现状 ≠ 记录值」回写(见 mcpServerRowsToRestore)。
597
- const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates);
661
+ const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(), mcpServerStates.map((s) => ({ id: s.id, level: s.level, disabled: s.disabled })), skillSources.map((s) => ({ root: s.root, enabled: s.enabled })), personaRestore, personaRestoreOn, noteBefore, personaStates, toolTableBefore);
598
662
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
599
663
  try {
600
664
  await deps.saveSlice({ ...slice, mode: entered.mode });
@@ -629,6 +693,10 @@ export function createArchiveEngine(deps) {
629
693
  if (noteTargets && noteTargets.length) {
630
694
  await deps.applyMcpNotes(noteTargets.map((x) => ({ id: x.id, note: x.note })));
631
695
  }
696
+ // ⑤ 模型工具表:档案绑了方案就把关停名单**整体换成**那份(省下的 token 按轮算,
697
+ // 所以它是"进入场景"这一动作里唯一直接改每请求内容的域)。
698
+ if (toolTableTarget)
699
+ await deps.applyToolTableHidden(toolTableTarget);
632
700
  }
633
701
  catch (e) {
634
702
  return await rollback(slice, snapshot, '应用档案', e);
@@ -645,7 +713,7 @@ export function createArchiveEngine(deps) {
645
713
  mode: entered.mode,
646
714
  // 三个域都是「按勾选集全量应用」(未定义 = 全关),所以都算已应用;
647
715
  // 备注段未定义 = 不覆盖任何备注,如实报它是否定义。
648
- applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes) },
716
+ applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes), toolTable: !!toolTableTarget },
649
717
  // 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
650
718
  switched: {
651
719
  mcpServers: mcpPlan.serverSwitches.length,
@@ -66,6 +66,10 @@ export function normalizeArchive(raw) {
66
66
  out.subagents = normalizeStringList(obj.subagents);
67
67
  if (obj.memories != null)
68
68
  out.memories = normalizeStringList(obj.memories);
69
+ // 工具表方案是**标量**不是集合:空串 / 非字符串一律视为未绑定(段不存在)。
70
+ const toolTablePreset = typeof obj.toolTablePreset === 'string' ? obj.toolTablePreset.trim() : '';
71
+ if (toolTablePreset)
72
+ out.toolTablePreset = toolTablePreset;
69
73
  return out;
70
74
  }
71
75
  export function hasSection(archive, section) {
@@ -202,7 +206,9 @@ mcpNotes = [],
202
206
  * 进入模式时**全部**人设的开关状态(名字 → 是否开着)。给了就写进 `subagentsAll`:
203
207
  * 退出时按它精确还原(场景里手动开过的人设也会被还原回进场景前的状态)。
204
208
  */
205
- subagentStates = null) {
209
+ subagentStates = null,
210
+ /** 进场景前的模型工具表关停名单(null = 这次不进快照,退出也就不动工具表)。 */
211
+ toolTableHidden = null) {
206
212
  return {
207
213
  mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
208
214
  skills: { ...skills },
@@ -212,6 +218,7 @@ subagentStates = null) {
212
218
  ...(subagentsOn.length ? { subagentsOn: subagentsOn.slice() } : {}),
213
219
  ...(subagentStates ? { subagentsAll: { ...subagentStates } } : {}),
214
220
  ...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
221
+ ...(toolTableHidden ? { toolTableHidden: toolTableHidden.slice() } : {}),
215
222
  };
216
223
  }
217
224
  // ── 记忆勾选(v3)──────────────────────────────────────────────────────────