dsh-plugin-tool-management 0.9.1 → 0.10.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.
@@ -46,9 +46,9 @@ export function scanBlockRanges(lines) {
46
46
  /**
47
47
  * 读一个覆盖块三件事:id、disabled、是不是"纯块"。
48
48
  *
49
- * 只看**顶层/一层缩进**的锚定写法(`- id:`、`name:`、`disabled:`):生成器写出来的
50
- * 就是这几行,而 config 里的同名键缩进更深 —— 万一手工把 `disabled` 藏进 config,
51
- * 这里会判成"非纯块"(保留不删),保守方向是对的。
49
+ * 只认**精确列位**的写法(顶层 `- id:`,生成器的 2 空格 `name:` / `disabled:`):
50
+ * config 里的同名键缩进更深,正则不匹配,于是落入下面的 `pure = false` ——
51
+ * 手工把 `disabled` 藏进 config 的块会被判成"非纯块"(保留不删),保守方向是对的。
52
52
  *
53
53
  * 不筛 `name:` —— 补丁是按 **loader id** 生效的,别的模块名写同一个 id 时改的仍是
54
54
  * 那个条目;真正决定"能不能删"的是 pure 与 decider 两条规则,不是名字。
@@ -66,9 +66,9 @@ export function readToggleEntry(lines) {
66
66
  id = head[1];
67
67
  continue;
68
68
  }
69
- if (/^\s{2,}name:\s*\S+\s*$/.test(line))
69
+ if (/^ {2}name:\s*\S+\s*$/.test(line))
70
70
  continue;
71
- const flag = line.match(/^\s{2,}disabled:\s*(true|false)\s*$/);
71
+ const flag = line.match(/^ {2}disabled:\s*(true|false)\s*$/);
72
72
  if (flag) {
73
73
  disabled = flag[1] === 'true';
74
74
  continue;
@@ -101,6 +101,10 @@ export function scanOverrideBlocks(content) {
101
101
  /**
102
102
  * **insert 行自带的** 启停基准(`- insert:` 里每个子条目的 `disabled`,缺失 = 启用)。
103
103
  *
104
+ * 子条目键只认 6 空格列位(生成器 ` - id:` 之下的 ` disabled:`)—— config 内嵌的
105
+ * 同名键缩进更深,匹配不上,该 id 就按"基准未知"处理(调用方只删被 decider 盖住的块,
106
+ * 绝不删 decider),少删不会错删。
107
+ *
104
108
  * 这是收敛判定的唯一合法基准来源:绝不能用 `parseRows()` 那种"合并覆盖块之后的生效值",
105
109
  * 因为生效值就等于最后一条覆盖块的值,"覆盖块的值 == 基准"会恒成立,decider 会被全删。
106
110
  */
@@ -124,7 +128,7 @@ export function insertBaseRows(content) {
124
128
  }
125
129
  if (!id)
126
130
  continue;
127
- const flag = line.match(/^\s+disabled:\s*(true|false)\s*$/);
131
+ const flag = line.match(/^ {6}disabled:\s*(true|false)\s*$/);
128
132
  if (flag)
129
133
  disabled = flag[1] === 'true';
130
134
  }
@@ -9,18 +9,33 @@
9
9
  // 这类信息在任何工具 schema 里都拿不到:工具只暴露 `mcp__<server>__<tool>` 的名字与
10
10
  // 参数,永远不会说「A 挂了改用 B」。所以「列出 server」只是载体。
11
11
  //
12
- // 范围(2026-09-15 用户裁定):
13
- // - **只列「当前真正可用」的 server**:配置上已启用 **且** 有**未被停用**的工具
14
- // (enabledToolCount > 0;场景档案收窄与手动逐工具开关都会写停用表,这里如实扣减)。
15
- // 没开的不进(用户明确要求);工具全被停用的也不进(模型调不到,留着只会谎报数量)。
12
+ // 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"的判据):
13
+ // - 分两档,判据全部可验证:
14
+ // ① **可用**:配置上已启用 **且** 当前真实注册了未被停用的工具
15
+ // (`liveEnabledToolCount > 0`;场景档案收窄与手动逐工具开关都会写停用表,这里如实扣减)。
16
+ // ② **曾经连上、现在不可用**:已启用、当前一个工具都没注册,但「已知工具」缓存非空。
17
+ // 这是**状态变化**,要报 —— 模型据此给"检查网络/命令/凭据"的建议,而不是把
18
+ // "配了但连不上"读成"本机没配"(后者会让它建议用户去装一个)。同时,用户写在那台
19
+ // server 上的备注(placeholder 就是「A 不可用时改用 B 兜底」)恰恰只在此时才有用,
20
+ // 不列就永远送不到。
21
+ // 不列的三类:**没开**(用户明确要求)、**工具全被停用**(模型调不到,留着只会谎报数量)、
22
+ // **从未连上过**(缓存也空 —— 一直没成功过,不该反复打扰;这条抄的是 Claude Code 的通知
23
+ // 策略:一直没连上的不提示,昨天还好今天挂的才提示)。
16
24
  // - 只列 **server 名 + 工具数 + 备注**;**具体工具名绝不注入** ——
17
25
  // 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
18
26
  // 段里再列一遍是重复;模型按前缀就能对上号。
19
27
  // - 工具数只算**当前可用**的:被停用的那一部分不显示(它就是模型 schema 里没有的),
20
- // 数字必须与模型能调到的工具对得上。
28
+ // 数字必须与模型能调到的工具对得上。②档没有当前数字,所以写的是「上次连上时 N 个工具」——
29
+ // 那是历史事实,不是现状,措辞上必须区分开。
21
30
  // - 无备注 → 只有名字 + 工具数(正是「不写就只知道名字」)。
22
- // - 不写「已启用」字样:能进段的都是可用的,标状态是废话。
23
- // - 无可用 server → 返回 `''`(renderPrompt 删空段,零 token 成本)。
31
+ // - 不写「已启用」字样:能进段的都是可用的,标状态是废话;②档则必须标,否则就是谎报。
32
+ // - 两档都空 → 返回 `''`(renderPrompt 删空段,零 token 成本)。
33
+ //
34
+ // 一条**测不出来**的边界(写在这里免得后来者以为它是 bug):宿主没有暴露"MCP 已连接"这个信号
35
+ // (插件清单只有 entryId / moduleName / enabled / fiberPhase),所以本模块只能读"工具有没有注册"。
36
+ // 而官方 dsh-mcp-client 在**掉线后不会立刻摘掉工具**:`maxAttempts: 10`、退避 500ms 翻倍到 30s,
37
+ // 约 2.5 分钟后才 `tools unregistered`;若把 `reconnect.enabled` 设为 false 则**永久保留**。
38
+ // 也就是说那个窗口内本段仍会把它算作①档。这不是判据写错,是插件身份决定的观测上限。
24
39
  //
25
40
  // 同步性:`text()` 必须同步返回(renderPrompt 不接受 Promise),而 MCP 清单是异步读盘 →
26
41
  // 与子智能体目录同构的 stale-while-revalidate(见 src/subagents/catalog.ts)。
@@ -53,32 +68,60 @@ export function normalizeMcpNote(value, maxLength) {
53
68
  /**
54
69
  * 渲染 MCP 状态段(纯函数,便于单独推理)。
55
70
  *
71
+ * 分两档:**可用**(当前真注册了可用工具)与**曾经连上、现在不可用**(当前没注册,
72
+ * 但「已知工具」缓存证明它曾经成功过)。判据与不列的三类见文件头。
73
+ *
56
74
  * 返回 `''` 表示不注入 —— 没有可用 server 的用户零 token 成本。
57
75
  */
58
76
  export function renderMcpStateSection(rows, opts = {}) {
59
77
  const maxEntries = opts.maxEntries ?? DEFAULT_MCP_MAX_ENTRIES;
60
78
  const noteMaxLength = opts.noteMaxLength ?? DEFAULT_MCP_NOTE_MAX_LENGTH;
61
- // 工具数取**可用数**(停用表扣减后的);旧调用方只给 toolCount 时退回它,行为不变。
62
- const countOf = (r) => (Number.isFinite(Number(r.enabledToolCount)) ? Number(r.enabledToolCount) : (Number(r.toolCount) || 0));
63
- // 「当前真正可用」= 已启用 且 还有没被停用的工具。任一不满足都不进段。
64
- const usable = rows.filter((r) => !r.disabled && countOf(r) > 0);
65
- if (!usable.length)
79
+ // 当前真实可用的工具数(停用表扣减后)。**优先读 live 那个数** —— 旧调用方只给
80
+ // enabledToolCount / toolCount 时逐级退回,行为与拆分之前一致(那个数在"从未连上过"的
81
+ // server 上恰好也是 0,所以退回不会造成谎报,只会少报)。
82
+ const liveEnabledOf = (r) => {
83
+ if (Number.isFinite(Number(r.liveEnabledToolCount)))
84
+ return Number(r.liveEnabledToolCount);
85
+ return Number.isFinite(Number(r.enabledToolCount)) ? Number(r.enabledToolCount) : (Number(r.toolCount) || 0);
86
+ };
87
+ // 当前注册了**几个**工具(不扣停用)。用来把"什么都没注册"与"注册了但都被停用"分开:
88
+ // 后者是用户的选择(该不列),前者才是"连不上"(该列并标注)。
89
+ const liveOf = (r) => (Number.isFinite(Number(r.liveToolCount)) ? Number(r.liveToolCount) : liveEnabledOf(r));
90
+ // 曾经连上过的证据。缺省 0 = 从未连上过(旧调用方给不出这个信号,按"不列"处理 —— 少说不错)。
91
+ const knownOf = (r) => Number(r.knownToolCount) || 0;
92
+ const usable = rows.filter((r) => !r.disabled && liveEnabledOf(r) > 0);
93
+ const offline = rows.filter((r) => !r.disabled && liveOf(r) === 0 && knownOf(r) > 0);
94
+ if (!usable.length && !offline.length)
66
95
  return '';
67
96
  // 按展示名排序:即使补丁文件里的行序变化,段文本也保持逐字节稳定(前缀缓存契约)。
68
- const sorted = [...usable].sort((a, b) => {
97
+ // 两档各自排序,可用的一律排在前面(它才是模型当下能用的东西)。
98
+ const byName = (a, b) => {
69
99
  const x = String(a.serverName ?? '');
70
100
  const y = String(b.serverName ?? '');
71
101
  return x < y ? -1 : x > y ? 1 : 0;
72
- });
73
- const shown = sorted.slice(0, Math.max(0, maxEntries));
74
- const lines = shown.map((r) => {
75
- const count = countOf(r);
76
- const head = '- **' + String(r.serverName ?? r.id) + '**(' + count + ' 个工具)';
102
+ };
103
+ const cap = Math.max(0, maxEntries);
104
+ const shownUsable = [...usable].sort(byName).slice(0, cap);
105
+ const shownOffline = [...offline].sort(byName).slice(0, cap);
106
+ const lineOf = (r, count, isOffline) => {
107
+ const name = String(r.serverName ?? r.id);
108
+ // ②档必须把状态写出来:不写就等于谎报"可用"。「上次连上时」四个字不能省 ——
109
+ // 那个数字是历史事实,写成"(N 个工具)"会被读成现状。
110
+ const head = isOffline
111
+ ? '- **' + name + '**(当前未连上;上次连上时 ' + count + ' 个工具)'
112
+ : '- **' + name + '**(' + count + ' 个工具)';
77
113
  const note = normalizeMcpNote(r.notes, noteMaxLength);
78
114
  return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
79
- });
80
- const out = ['## MCP 服务器', '', ...lines];
81
- const hidden = sorted.length - shown.length;
115
+ };
116
+ const lines = [
117
+ ...shownUsable.map((r) => lineOf(r, liveEnabledOf(r), false)),
118
+ ...shownOffline.map((r) => lineOf(r, knownOf(r), true)),
119
+ ];
120
+ // 只有清单:标题(`## 本机 MCP 服务器的当前状态`)与"该拿它做什么"由注入通道的框架承担
121
+ // (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
122
+ // 框架已经写了标题,正文再写一遍就是每步多付一行 token。
123
+ const out = [...lines];
124
+ const hidden = (usable.length + offline.length) - (shownUsable.length + shownOffline.length);
82
125
  if (hidden > 0)
83
126
  out.push('', `(另有 ${hidden} 台未列出。)`);
84
127
  return out.join('\n');
@@ -12,22 +12,70 @@ export function createArchiveEngine(deps) {
12
12
  chain = queued.catch(() => undefined);
13
13
  return queued;
14
14
  };
15
+ /**
16
+ * 快照里的上层两行 → 退出时**真正要回写**的那些:现状 ≠ 快照值、且这一行现在还存在。
17
+ *
18
+ * 为什么必须过滤而不是逐行无条件回写:
19
+ * - 快照是全量记录(进场景前每一行的原值),场景没碰过的行占绝大多数,逐行回写会让
20
+ * 补丁文件 / 状态文件白写一遍(MCP 那一侧还会连带触发宿主热重载与备份噪音);
21
+ * - 现状已经等于原值的行本来就无需还原。真正要回写的只有「场景期间被改过的行」——
22
+ * 档案改的,或用户自己在场景页面上改的(未锁定时可用,改动同步进档案)。
23
+ * 后者正是用户报的「场景里关掉 A 目录,退出后 A 与它下面的技能都没开回来」:
24
+ * 旧口径只记「进场景时被档案改动过的行」,而 A 在进场景时没被改动(档案里勾着
25
+ * A 下面的技能),快照里根本没有 A 这一行。
26
+ *
27
+ * 快照里记过、但现在已不存在的行直接跳过:这类行在场景期间被删掉了,没有可还原的状态,
28
+ * 硬写还会让退出失败(MCP 启停按 id 定位,`未找到条目` 直接报错)。
29
+ * 读不到现状(列表读取抛错)→ 退回旧行为全量回写:宁可多写几行,不可少还原。
30
+ */
31
+ async function mcpServerRowsToRestore(rows) {
32
+ if (!rows.length)
33
+ return [];
34
+ let current;
35
+ try {
36
+ current = await deps.mcpServerStates();
37
+ }
38
+ catch {
39
+ return rows;
40
+ }
41
+ const byKey = new Map(current.map((s) => [s.id + '\u0000' + s.level, s.disabled]));
42
+ return rows.filter((x) => {
43
+ const key = x.id + '\u0000' + x.level;
44
+ return byKey.has(key) && byKey.get(key) !== x.disabled;
45
+ });
46
+ }
47
+ /** 同 mcpServerRowsToRestore:来源级的还原行。 */
48
+ async function skillSourceRowsToRestore(rows) {
49
+ if (!rows.length)
50
+ return [];
51
+ let current;
52
+ try {
53
+ current = await deps.skillSourceStates();
54
+ }
55
+ catch {
56
+ return rows;
57
+ }
58
+ const byRoot = new Map(current.map((s) => [s.root, s.enabled]));
59
+ return rows.filter((x) => byRoot.has(x.root) && byRoot.get(x.root) !== x.enabled);
60
+ }
15
61
  /**
16
62
  * 还原到快照(退出模式与失败回滚共用同一条路径):
17
63
  * MCP 停用表**按快照原文整体回写**——模式自己写进去的键(未勾服务器的 ['*'])必须随之消失,
18
64
  * 否则退出后用户环境仍被静默停用(比"多留一个键"严重得多);模式期间的手动改动按设计 §2.2
19
65
  * 不保留(「退出 = 恢复 mode.snapshot」,手动改动只在「保存到场景」时回写)。
20
66
  * 技能只写快照列出的键(这是既有批量通道的语义):模式期间新增的技能保持现状。
67
+ * 上层两行(服务器级 / 来源级)按**全量**快照还原 —— 只回写与现状不同的行,见
68
+ * mcpServerRowsToRestore / skillSourceRowsToRestore。
21
69
  */
22
70
  async function restoreSnapshot(snapshot) {
23
71
  // 顺序与进入时**相反**:先恢复服务器级 / 来源级,再恢复工具级 / 技能级 ——
24
72
  // 来源还关着的时候写技能级策略会被吞掉(`skills/core.js` 的 sourceEnabled 判定)。
25
73
  // 老 snapshot 没有这两栏 → `?? []`,按旧行为只恢复下层。
26
- const servers = snapshot.mcpServers ?? [];
74
+ const servers = await mcpServerRowsToRestore(snapshot.mcpServers ?? []);
27
75
  if (servers.length) {
28
76
  await deps.applyMcpServerSwitches(servers.map((x) => ({ id: x.id, level: x.level, enabled: !x.disabled })));
29
77
  }
30
- const sources = snapshot.skillSources ?? [];
78
+ const sources = await skillSourceRowsToRestore(snapshot.skillSources ?? []);
31
79
  if (sources.length) {
32
80
  await deps.applySkillSourceSwitches(sources.map((x) => ({ root: x.root, enabled: x.enabled })));
33
81
  }
@@ -291,17 +339,24 @@ export function createArchiveEngine(deps) {
291
339
  // 备注段是例外:未定义 = 不覆盖任何备注(没有东西"因为未定义而需要关掉")。
292
340
  const mcpSpec = (archive && archive.mcp) || {};
293
341
  const skillsSpec = (archive && archive.skills) || [];
294
- // 先算计划、再拍快照:快照只记**将被改动**的服务器行 / 来源 / 人设(带改动前的状态),
295
- // 退出时按记录精确恢复 —— 不动用户手动设置的其他行。
342
+ // 先读现状、再算计划:现状既喂给计划(算「哪些行需要改」),也**全量**写进快照。
343
+ // 快照记的是**每一行**服务器 / 来源的进场景前状态,不是只记「本次计划要改的行」——
344
+ // 场景期间用户可以在页面上改开关(未锁定时可用,改动同步进档案),只记计划行的话
345
+ // 这些改动就没有原值可回:用户报的「场景里关掉 A 目录,退出后 A 和它下面的技能都没开回来」,
346
+ // 就是 A 在进场景时没被档案改动(档案里勾着 A 下面的技能)而未进快照。
296
347
  let mcpPlan = null;
297
348
  let skillsPlan = null;
349
+ let mcpServerStates = [];
350
+ let skillSources = [];
298
351
  try {
352
+ mcpServerStates = await deps.mcpServerStates();
353
+ skillSources = await deps.skillSourceStates();
299
354
  mcpPlan = computeMcpPlan(mcpSpec, {
300
355
  configuredServers: await deps.configuredServers(),
301
356
  knownTools: await deps.serverKnownTools(),
302
- serverStates: await deps.mcpServerStates(),
357
+ serverStates: mcpServerStates,
303
358
  });
304
- skillsPlan = computeSkillsPlan(skillsSpec, await deps.knownSkillKeys(), await deps.skillSourceStates());
359
+ skillsPlan = computeSkillsPlan(skillsSpec, await deps.knownSkillKeys(), skillSources);
305
360
  }
306
361
  catch (e) {
307
362
  return { ok: false, error: `读取运行时状态失败(未改动任何东西):${msg(e)}` };
@@ -338,9 +393,9 @@ export function createArchiveEngine(deps) {
338
393
  }
339
394
  // 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
340
395
  // 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
341
- const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(),
342
- // 直接用计划带出的 `*Before`(改动前的状态),不在这里反推 —— 反推容易搞反方向。
343
- mcpPlan.serverSwitches.map((s) => ({ id: s.id, level: s.level, disabled: s.disabledBefore })), skillsPlan.sourceSwitches.map((s) => ({ root: s.root, enabled: s.enabledBefore })), personaRestore, personaRestoreOn, noteBefore, personaStates);
396
+ // 上层两行传**现状全量**(上面的 mcpServerStates / skillSources 就是进场景前的值),
397
+ // 不是计划里那几行 —— 退出按「现状 ≠ 记录值」回写(见 mcpServerRowsToRestore)。
398
+ 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);
344
399
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
345
400
  try {
346
401
  await deps.saveSlice({ ...slice, mode: entered.mode });
@@ -160,8 +160,9 @@ export function computeSkillsPlan(skills, knownSkillKeys, sourceStates) {
160
160
  /**
161
161
  * 把「这次要改的上层行」并进快照 —— **已记录的行保持原值**(先记的才是进场景前的状态)。
162
162
  *
163
- * 为什么需要:进入模式时快照只记了**当时将要改动**的行;模式进行中用户改档案(改档案 = 立即生效)
164
- * 又可能新改到别的服务器 / 来源级行。退出必须回到「进场景前」,所以这些新改的行也得有记录。
163
+ * 为什么需要:新快照已是**全量**(进入时就记了每一行),这里是兜底 —— 旧快照只记了
164
+ * 当时将要改动的行,而模式进行中用户改档案(改档案 = 立即生效)又可能新改到别的行;
165
+ * 退出必须回到「进场景前」,所以这些新改的行也得有记录。
165
166
  * 反之,若某行在进入时就记过,它的 `*Before` 才是进场景前的值 —— 这次的中间态值必须丢弃。
166
167
  */
167
168
  export function mergeSnapshotSwitches(snapshot, mcpServers = [], skillSources = []) {
@@ -189,10 +190,8 @@ export function mergeSnapshotSwitches(snapshot, mcpServers = [], skillSources =
189
190
  };
190
191
  }
191
192
  export function snapshotRuntime(mcpRaw, skills,
192
- /** **将被档案改动**的服务器行,带改动前的 `disabled`。 */
193
- mcpServers = [],
194
- /** **将被档案改动**的来源,带改动前的 `enabled`。 */
195
- skillSources = [],
193
+ /** 上层两行:进场景前**每一行**的原值(全量;退出按「现状 ≠ 原值」回写)。 */
194
+ mcpServers = [], skillSources = [],
196
195
  /** **将被档案启用**的人设名(改动前停用的子集;退出时按此停回)。 */
197
196
  subagents = [],
198
197
  /** **将被档案停用**的人设名(改动前启用的子集;退出时按此重新打开)。 */