dsh-plugin-tool-management 0.8.0 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -319,11 +319,15 @@ export default {
319
319
  const raw = await readDisabledTools();
320
320
  for (const [server, list] of Object.entries(raw))
321
321
  out[server] = list.filter((t) => t !== '*');
322
+ // 「已知工具」缓存:未运行服务器也能按最后见过的名单计算补集。
323
+ for (const [server, list] of Object.entries(await readKnownMcpTools())) {
324
+ out[server] = [...new Set([...(out[server] || []), ...list])].sort();
325
+ }
322
326
  let schemas = [];
323
327
  try {
324
328
  schemas = await tools.schemas();
325
329
  }
326
- catch { /* 无 live 工具 → 仅启停表 */ }
330
+ catch { /* 无 live 工具 → 仅停用表 + 缓存 */ }
327
331
  for (const s of schemas) {
328
332
  const p = toolKeyParts(String((s && s.name) || ''));
329
333
  if (!p)
@@ -399,6 +403,31 @@ export default {
399
403
  // 补丁改动由宿主热重载(实测 ≤5s)。这里**不等**,避免进入模式被拖住十几秒;
400
404
  // 界面在应用完成后即可用,工具集合会在数秒内补齐。
401
405
  },
406
+ // 场景备注(v0.8.1):备注写在 `dsh-plugin-tool-management-notes.json`(按 loader id),
407
+ // 场景档案按 serverName 存 —— 引擎负责映射与读写,与 mcpm-note 同一条写锁。
408
+ currentMcpNotes: async () => ({ ...(await readNotes()) }),
409
+ applyMcpNotes: async (entries) => {
410
+ await withWriteLock(async () => {
411
+ const map = Object.assign({}, await readNotes(true));
412
+ for (const e of entries) {
413
+ if (e.note)
414
+ map[e.id] = e.note;
415
+ else
416
+ delete map[e.id];
417
+ }
418
+ const p = await ensurePaths();
419
+ await writeJsonFile(sidecarPath(p.home, 'dsh-plugin-tool-management-notes.json'), map);
420
+ notesCache = { at: Date.now(), value: map };
421
+ });
422
+ },
423
+ mcpIdOfServer: async (serverName) => {
424
+ const r = await mcpmListView();
425
+ for (const row of ((r && r.rows) || [])) {
426
+ if (String(row.serverName || row.id) === serverName)
427
+ return String(row.id);
428
+ }
429
+ return undefined;
430
+ },
402
431
  skillSourceStates: async () => {
403
432
  const r = await skillsService.ops['skill-state']({});
404
433
  const roots = (r && r.data && r.data.roots) || [];
@@ -562,8 +591,12 @@ export default {
562
591
  }
563
592
  catch { /* 守卫读状态失败不拦正常流程(引擎自身校验兜底) */ }
564
593
  const res = await baseSceneModeSet(args);
565
- if (res && res.ok !== false)
594
+ if (res && res.ok !== false) {
595
+ // 进/退/切换模式会改 MCP 启停(服务器级)与备注(场景备注覆盖/恢复),
596
+ // 状态段必须立即重算 —— 场景退出后下一次请求就该看到恢复的全局备注与启停。
566
597
  void subagentCatalog.refresh();
598
+ void mcpStateCatalog.refresh();
599
+ }
567
600
  return res;
568
601
  };
569
602
  }
@@ -1569,6 +1602,34 @@ export default {
1569
1602
  }
1570
1603
  return out;
1571
1604
  }
1605
+ // ---------- 「已知工具」侧车(v0.8.1)-----------------------------------
1606
+ // MCP 工具名只在服务器**运行**时可见(tools.schemas() 里才有)。未运行的服务器在
1607
+ // 场景档案里既显示 0 工具、工具明细也空空如也 —— 用户没法给未运行服务器挑工具。
1608
+ // 每次枚举到 live 工具时按 serverName 记一份「最后见过的工具名」,未运行时用它兜底:
1609
+ // 场景档案能列出/勾选,进入场景后按实际注册的工具生效(restrict 会过滤掉不存在的名字)。
1610
+ const KNOWN_MCP_TOOLS_FILE = 'dsh-plugin-tool-management-mcp-tools.json';
1611
+ let knownMcpToolsCache = null;
1612
+ async function readKnownMcpTools(force = false) {
1613
+ if (knownMcpToolsCache && !force && Date.now() - knownMcpToolsCache.at < SIDECAR_TTL_MS)
1614
+ return knownMcpToolsCache.value;
1615
+ const p = await ensurePaths();
1616
+ const raw = await readJsonFile(sidecarPath(p.home, KNOWN_MCP_TOOLS_FILE));
1617
+ const out = {};
1618
+ if (raw && typeof raw === 'object') {
1619
+ for (const serverName of Object.keys(raw)) {
1620
+ const list = raw[serverName];
1621
+ if (Array.isArray(list))
1622
+ out[serverName] = list.map((name) => String(name)).filter((name) => /^[A-Za-z0-9_-]{1,128}$/.test(name)).sort();
1623
+ }
1624
+ }
1625
+ knownMcpToolsCache = { at: Date.now(), value: out };
1626
+ return out;
1627
+ }
1628
+ async function writeKnownMcpTools(value) {
1629
+ const p = await ensurePaths();
1630
+ await writeJsonFile(sidecarPath(p.home, KNOWN_MCP_TOOLS_FILE), value);
1631
+ knownMcpToolsCache = { at: Date.now(), value: value };
1632
+ }
1572
1633
  /** 单工具停用判定(含整服务器通配 `*`——场景档案的"MCP 工具集=整台"写的就是它)。 */
1573
1634
  function isToolDisabled(map, fullName) {
1574
1635
  if (!fullName.startsWith('mcp__'))
@@ -1799,8 +1860,77 @@ export default {
1799
1860
  toolsList.push({ name, description: '(已停用;描述暂不可用)', enabled: false, parameters: [] });
1800
1861
  }
1801
1862
  }
1863
+ // 「已知工具」缓存兜底:服务器没运行时 schemas() 里没有它任何工具 —— 场景档案里
1864
+ // 想给未运行服务器挑工具就全靠这份最后见过的名单(描述/参数不可用,按名字勾选)。
1865
+ for (const name of (await readKnownMcpTools())[serverName] || []) {
1866
+ if (listed.has(name))
1867
+ continue;
1868
+ toolsList.push({ name, description: '(服务器未运行;描述暂不可用)', enabled: !disabledHere.has(name), parameters: [] });
1869
+ listed.add(name);
1870
+ }
1802
1871
  return { ok: true, tools: toolsList };
1803
1872
  }
1873
+ /**
1874
+ * 临时启动服务器以枚举工具(v0.8.1):从未运行过的服务器在 schemas() 与「已知工具」
1875
+ * 缓存里都没有工具名,场景档案勾选器对它一无所知。这里**临时**把它启用(写补丁),
1876
+ * 轮询等它的工具注册(npx 冷启动要下载,上限 30s),拿到名单后就**恢复原启停状态**——
1877
+ * 用户环境的服务器开关不受影响。已有已知工具时直接返回现有清单,不做任何改动。
1878
+ */
1879
+ async function mcpmToolsRefresh(args) {
1880
+ const serverName = String((args && args.serverName) || '').trim();
1881
+ if (!serverName)
1882
+ return { ok: false, error: 'serverName 不能为空' };
1883
+ const existing = await mcpmTools({ serverName });
1884
+ if (existing && existing.ok === false)
1885
+ return existing;
1886
+ if (existing && (existing.tools || []).length)
1887
+ return existing;
1888
+ const list = await mcpmListView();
1889
+ const row = ((list && list.rows) || []).find((r) => String(r.serverName) === serverName);
1890
+ if (!row)
1891
+ return { ok: false, error: `未找到服务器: ${serverName}` };
1892
+ if (row.level !== 'global' && row.level !== 'project') {
1893
+ return { ok: false, error: `该服务器不驻留在补丁文件(level=${row.level}),无法临时启动;请先在 MCP 页启用它一次以记录工具` };
1894
+ }
1895
+ const wasDisabled = !!row.disabled;
1896
+ try {
1897
+ if (wasDisabled) {
1898
+ const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: true });
1899
+ if (r && r.ok === false)
1900
+ return r;
1901
+ }
1902
+ const deadline = Date.now() + 30000;
1903
+ for (;;) {
1904
+ const t = await mcpmTools({ serverName });
1905
+ if (t && t.ok && (t.tools || []).length) {
1906
+ // 记进「已知工具」缓存:以后未运行时场景档案也能列出。
1907
+ const names = t.tools.map((x) => String(x.name)).filter(Boolean);
1908
+ if (names.length) {
1909
+ try {
1910
+ const cache = await readKnownMcpTools();
1911
+ const merged = [...new Set([...(cache[serverName] || []), ...names])].sort();
1912
+ cache[serverName] = merged;
1913
+ await writeKnownMcpTools(cache);
1914
+ }
1915
+ catch { /* 缓存写失败不影响本次返回 */ }
1916
+ }
1917
+ return t;
1918
+ }
1919
+ if (Date.now() >= deadline)
1920
+ break;
1921
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1922
+ }
1923
+ return { ok: false, error: `等待超时:${serverName} 在 30 秒内没有注册任何工具(服务器可能启动失败或连接过慢)` };
1924
+ }
1925
+ finally {
1926
+ if (wasDisabled) {
1927
+ const r = await mcpmSetEnabled({ id: row.id, level: row.level, enabled: false });
1928
+ if (!r || r.ok === false)
1929
+ console.warn('[dsh-plugin-tool-management] mcpm-tools-refresh: 恢复服务器停用状态失败', serverName);
1930
+ }
1931
+ void mcpStateCatalog.refresh();
1932
+ }
1933
+ }
1804
1934
  async function mcpmList() {
1805
1935
  const p = await ensurePaths();
1806
1936
  const rows = [];
@@ -1822,23 +1952,54 @@ export default {
1822
1952
  const toolCounts = {};
1823
1953
  try {
1824
1954
  const schemas = await tools.schemas();
1955
+ const knownCache = await readKnownMcpTools();
1956
+ const liveNames = {};
1825
1957
  const seen = new Set();
1826
1958
  for (const s of schemas) {
1827
1959
  const fullName = String(s && s.name || '');
1828
- seen.add(fullName);
1829
- const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
1830
- if (m)
1831
- toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
1832
- }
1833
- // Restricted tools may be absent from schemas(); they still belong to
1834
- // the per-server count so the UI does not show a phantom drop.
1835
- for (const fullName of disabledToolNames(await readDisabledTools())) {
1836
1960
  if (seen.has(fullName))
1837
1961
  continue;
1838
- const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__/);
1839
- if (m)
1840
- toolCounts[m[1]] = (toolCounts[m[1]] || 0) + 1;
1962
+ seen.add(fullName);
1963
+ const m = fullName.match(/^mcp__([A-Za-z0-9_-]+)__(.+)$/);
1964
+ if (!m)
1965
+ continue;
1966
+ const list = liveNames[m[1]] || (liveNames[m[1]] = []);
1967
+ if (list.indexOf(m[2]) < 0)
1968
+ list.push(m[2]);
1969
+ }
1970
+ // 工具数 = live ∪ 停用表里的单个工具名 ∪ 「已知工具」缓存 —— 未运行的服务器
1971
+ // 也能显示最后一次见过的工具数,而不是永远 0。
1972
+ const union = {};
1973
+ const addTool = (server, tool) => {
1974
+ if (!server || !tool)
1975
+ return;
1976
+ const set = union[server] || (union[server] = new Set());
1977
+ set.add(tool);
1978
+ };
1979
+ for (const [server, list] of Object.entries(liveNames))
1980
+ for (const t of list)
1981
+ addTool(server, t);
1982
+ for (const [server, list] of Object.entries(await readDisabledTools()))
1983
+ for (const t of list)
1984
+ if (t !== '*')
1985
+ addTool(server, t);
1986
+ for (const [server, list] of Object.entries(knownCache))
1987
+ for (const t of list)
1988
+ addTool(server, t);
1989
+ for (const [server, set] of Object.entries(union))
1990
+ toolCounts[server] = set.size;
1991
+ // 回写「已知工具」:live 见到的名字并入缓存(有变化才写盘,读路径上的 best-effort)。
1992
+ let cacheChanged = false;
1993
+ for (const [server, list] of Object.entries(liveNames)) {
1994
+ const prev = knownCache[server] || [];
1995
+ const next = [...new Set([...prev, ...list])].sort();
1996
+ if (next.join('\u0000') !== prev.join('\u0000')) {
1997
+ knownCache[server] = next;
1998
+ cacheChanged = true;
1999
+ }
1841
2000
  }
2001
+ if (cacheChanged)
2002
+ void writeKnownMcpTools(knownCache).catch(() => { });
1842
2003
  }
1843
2004
  catch (e) { /* ignore */ }
1844
2005
  let live = [];
@@ -1924,7 +2085,10 @@ export default {
1924
2085
  }
1925
2086
  const before = content;
1926
2087
  content = appendBlock(content, buildInsertBlock(row));
1927
- if (args.enabled === false)
2088
+ // v0.8.5:新增服务器默认不启动(用户裁定,与技能 / 子智能体同口径);
2089
+ // 显式传 enabled: true 才创建即启动。
2090
+ const defaultDisabled = args.enabled !== true;
2091
+ if (defaultDisabled)
1928
2092
  content = appendBlock(content, buildDisableBlock(id, true));
1929
2093
  const guard = duplicateGuard(before, content);
1930
2094
  if (guard)
@@ -1935,7 +2099,7 @@ export default {
1935
2099
  catch (e) {
1936
2100
  return { ok: false, error: '写入补丁失败: ' + message(e) };
1937
2101
  }
1938
- return { ok: true, row: { ...row, level, disabled: args.enabled === false } };
2102
+ return { ok: true, row: { ...row, level, disabled: defaultDisabled } };
1939
2103
  });
1940
2104
  }
1941
2105
  async function mcpmEdit(args) {
@@ -2484,6 +2648,24 @@ export default {
2484
2648
  }
2485
2649
  const sessionPaths = registry.sessionPaths instanceof Map ? registry.sessionPaths : undefined;
2486
2650
  const headers = registry.headers instanceof Map ? registry.headers : undefined;
2651
+ // 「目录真实存在」检查缓存:canRegister 从「宿主 sessionPaths 命中」放宽为
2652
+ // 「目录存在」时,同一目录只 stat 一次;结果按归一化键缓存,避免每次刷新重复探盘。
2653
+ const dirExistsCache = new Map();
2654
+ async function directoryExists(p) {
2655
+ const k = workspacePathKey(p);
2656
+ const hit = dirExistsCache.get(k);
2657
+ if (hit !== undefined)
2658
+ return hit;
2659
+ let ok = false;
2660
+ try {
2661
+ ok = (await stat(p)).isDirectory();
2662
+ }
2663
+ catch {
2664
+ ok = false;
2665
+ }
2666
+ dirExistsCache.set(k, ok);
2667
+ return ok;
2668
+ }
2487
2669
  for (const it of items) {
2488
2670
  const sid = it.sessionId;
2489
2671
  let wid = owned.get(sid);
@@ -2513,17 +2695,21 @@ export default {
2513
2695
  const known = snapshot ? snapshot.get(workspacePathKey(path)) : undefined;
2514
2696
  const existing = groupsById.get(key);
2515
2697
  if (existing) {
2516
- // 同目录的多个会话:目录存在(sessionPaths 命中)即可重新登记。
2517
- if (canonical !== undefined)
2698
+ // 同目录的多个会话:宿主索引命中,或磁盘目录真实存在,即可重新登记。
2699
+ if (canonical !== undefined || (await directoryExists(path))) {
2518
2700
  existing.canRegister = true;
2701
+ existing.dirMissing = false;
2702
+ }
2519
2703
  continue;
2520
2704
  }
2705
+ const canRegister = canonical !== undefined || (await directoryExists(path));
2521
2706
  const view = {
2522
2707
  id: key,
2523
2708
  title: known?.title || workspaceBaseName(path) || path,
2524
2709
  kind: 'detached',
2525
2710
  registered: known !== undefined,
2526
- canRegister: canonical !== undefined,
2711
+ canRegister,
2712
+ dirMissing: !canRegister,
2527
2713
  path: known?.path || path,
2528
2714
  };
2529
2715
  groupsById.set(key, view);
@@ -2779,6 +2965,7 @@ export default {
2779
2965
  'mcpm-tool-enabled': mcpmToolEnabled,
2780
2966
  'skill-open': skillOpen,
2781
2967
  'mcpm-tools': mcpmTools,
2968
+ 'mcpm-tools-refresh': mcpmToolsRefresh,
2782
2969
  'mcpm-add': mcpmAdd,
2783
2970
  'mcpm-edit': mcpmEdit,
2784
2971
  'mcpm-set-enabled': mcpmSetEnabled,
@@ -2834,14 +3021,19 @@ export default {
2834
3021
  if (!name || seen.has(name))
2835
3022
  continue;
2836
3023
  seen.add(name);
3024
+ const live = !!(row.live && row.live.enabled);
2837
3025
  mcpServers.push({
2838
3026
  name,
2839
3027
  level: row.level || null,
2840
- live: !!(row.live && row.live.enabled),
3028
+ live,
2841
3029
  serverDisabled: !!row.disabled,
2842
3030
  // 停用表里的 ['*'] = 整台工具停用(勾选器据此预勾,避免把"全停"读成"全启用")。
2843
3031
  allToolsDisabled: (disabledRaw[name] || []).indexOf('*') >= 0,
2844
- toolCount: typeof row.toolCount === 'number' ? row.toolCount : null,
3032
+ // 未运行且没有任何已知工具 ⇒ null(客户端显示「工具数未知」并引导读取),
3033
+ // 不再给出误导性的 0。
3034
+ toolCount: live || Number(row.toolCount) > 0
3035
+ ? (typeof row.toolCount === 'number' ? row.toolCount : null)
3036
+ : null,
2845
3037
  });
2846
3038
  }
2847
3039
  return {
@@ -3539,7 +3731,7 @@ export default {
3539
3731
  // 覆盖所有会改变「哪些 server 可用 / 有哪些备注」的写入口 —— 集中在这一处包,
3540
3732
  // 不需要在界面层逐个补(漏一个就会出现「改了但模型看不到」的静默不一致)。
3541
3733
  for (const opName of ['mcpm-add', 'mcpm-edit', 'mcpm-remove', 'mcpm-set-enabled', 'mcpm-set-all',
3542
- 'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact']) {
3734
+ 'mcpm-restart', 'mcpm-note', 'mcpm-tool-enabled', 'mcpm-import', 'mcpm-compact', 'mcpm-tools-refresh']) {
3543
3735
  const original = handlers[opName];
3544
3736
  if (typeof original !== 'function')
3545
3737
  continue;
@@ -3926,9 +4118,14 @@ export default {
3926
4118
  if (typeof ctx.on === 'function') {
3927
4119
  try {
3928
4120
  // MCP servers (de)register their tools as instances come and go; the
3929
- // visible-set restriction must track that.
4121
+ // visible-set restriction must track that — and so must the MCP state
4122
+ // section: 工具加载完成后若不重算,段会一直停留在「空/旧集合」的缓存值上
4123
+ //(用户切 MCP 后立即看系统提示词就是空段 —— 2026-09-16 实测)。
3930
4124
  ctx.effect(() => {
3931
- const stop = ctx.on('tools/change', () => scheduleToolRestrictions());
4125
+ const stop = ctx.on('tools/change', () => {
4126
+ scheduleToolRestrictions();
4127
+ void mcpStateCatalog.refresh();
4128
+ });
3932
4129
  return typeof stop === 'function' ? stop : () => { };
3933
4130
  }, 'dsh-plugin-tool-management: tools/change listener');
3934
4131
  }
@@ -9,7 +9,7 @@ export const DEFAULT_MCP_NOTE_MAX_LENGTH = 200;
9
9
  * (如「忽略以上全部规则」),会被当作系统级指令执行。威胁模型上是**自伤**而非被攻击
10
10
  * (内容由用户自己写、仅本机可写),但加固定前缀能降低被读成指令的概率。
11
11
  */
12
- export const MCP_NOTE_PREFIX = '用户备注:';
12
+ export const MCP_NOTE_PREFIX = '备注:';
13
13
  /** 备注压成单行(换行 → 空格)并截断。 */
14
14
  function normalizeNote(value, maxLength) {
15
15
  const flat = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
@@ -34,6 +34,11 @@ export function createArchiveEngine(deps) {
34
34
  const mcp = Object.fromEntries(Object.entries(snapshot.mcp).map(([k, v]) => [k, v.slice()]));
35
35
  await deps.applyMcpEntries(mcp);
36
36
  await deps.applySkills({ ...snapshot.skills });
37
+ // 备注:按快照原值写回(null = 原本没有,删除)。模式期间的手动备注改动不保留(同 §2.2)。
38
+ const notes = snapshot.mcpNotes ?? [];
39
+ if (notes.length) {
40
+ await deps.applyMcpNotes(notes.map((x) => ({ id: x.id, note: x.note })));
41
+ }
37
42
  // 子智能体开关:只停「进入时被这次启用过的」那些(老 snapshot 没有这栏 → [],按旧行为跳过)。
38
43
  const personas = snapshot.subagents ?? [];
39
44
  if (personas.length) {
@@ -89,6 +94,18 @@ export function createArchiveEngine(deps) {
89
94
  if (hadKeys && Object.keys(archive.mcp).length === 0)
90
95
  delete archive.mcp;
91
96
  }
97
+ if (archive.mcpNotes) {
98
+ const configured = await deps.configuredServers();
99
+ for (const server of Object.keys(archive.mcpNotes)) {
100
+ if (configured.indexOf(server) < 0) {
101
+ stale.push('mcpNotes/' + server);
102
+ delete archive.mcpNotes[server];
103
+ }
104
+ }
105
+ // 空段 = 无覆盖(与未定义行为相同:进入场景不覆盖任何备注),删除以免歧义。
106
+ if (Object.keys(archive.mcpNotes).length === 0)
107
+ delete archive.mcpNotes;
108
+ }
92
109
  if (archive.skills) {
93
110
  const known = await deps.knownSkillKeys();
94
111
  const hadKeys = archive.skills.length > 0;
@@ -162,7 +179,7 @@ export function createArchiveEngine(deps) {
162
179
  // ① 场景开关(P6)是唯一入口,纯记忆场景点开关不能报错;
163
180
  // ② 顶部「当前模式」横幅的渲染条件是 `mode.scene`,不写就永远不显示 ——
164
181
  // 用户裁定:无档案场景开开关也要显示横幅,副标题走 `scenes.mode.noProfile`。
165
- const appliesArchive = !!archive && (hasSection(archive, 'mcp') || hasSection(archive, 'skills') || hasSection(archive, 'subagents'));
182
+ const appliesArchive = !!archive && (hasSection(archive, 'mcp') || hasSection(archive, 'skills') || hasSection(archive, 'subagents') || hasSection(archive, 'mcpNotes'));
166
183
  // 先算计划、再拍快照:快照只记**将被改动**的服务器行 / 来源 / 人设(带改动前的状态),
167
184
  // 退出时按记录精确恢复 —— 不动用户手动设置的其他行。
168
185
  let mcpPlan = null;
@@ -196,10 +213,40 @@ export function createArchiveEngine(deps) {
196
213
  return { ok: false, error: `读取人设开关状态失败(未改动任何东西):${msg(e)}` };
197
214
  }
198
215
  }
216
+ // 场景备注(v0.8.1):把 `mcpNotes`(serverName → 场景备注)映射到 loader id,
217
+ // 记录**改动前**的备注(快照恢复用),应用时写进 notes.json 覆盖全局备注。
218
+ let noteTargets = null;
219
+ let noteBefore = [];
220
+ if (archive && archive.mcpNotes) {
221
+ try {
222
+ const current = await deps.currentMcpNotes();
223
+ const targets = [];
224
+ const before = [];
225
+ // 只对**mcp 段里实际勾选**的服务器应用场景备注:未勾选的服务器该场景根本不用它,
226
+ // 覆盖它的全局备注属于误伤(2026-09-16 用户实测「没勾的服务器备注也变了」)。
227
+ const selectedServers = archive.mcp ? Object.keys(archive.mcp) : [];
228
+ for (const [server, note] of Object.entries(archive.mcpNotes)) {
229
+ if (selectedServers.indexOf(server) < 0)
230
+ continue;
231
+ const id = await deps.mcpIdOfServer(server);
232
+ if (id === undefined)
233
+ continue; // 保存时已 stale 过,这里再兜底
234
+ targets.push({ id, note });
235
+ before.push({ id, note: current[id] ?? null });
236
+ }
237
+ if (targets.length) {
238
+ noteTargets = targets;
239
+ noteBefore = before;
240
+ }
241
+ }
242
+ catch (e) {
243
+ return { ok: false, error: `读取备注状态失败(未改动任何东西):${msg(e)}` };
244
+ }
245
+ }
199
246
  const snapshot = appliesArchive
200
247
  ? snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(),
201
248
  // 直接用计划带出的 `*Before`(改动前的状态),不在这里反推 —— 反推容易搞反方向。
202
- (mcpPlan?.serverSwitches ?? []).map((s) => ({ id: s.id, level: s.level, disabled: s.disabledBefore })), (skillsPlan?.sourceSwitches ?? []).map((s) => ({ root: s.root, enabled: s.enabledBefore })), personaRestore)
249
+ (mcpPlan?.serverSwitches ?? []).map((s) => ({ id: s.id, level: s.level, disabled: s.disabledBefore })), (skillsPlan?.sourceSwitches ?? []).map((s) => ({ root: s.root, enabled: s.enabledBefore })), personaRestore, noteBefore)
203
250
  : null;
204
251
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
205
252
  try {
@@ -231,6 +278,10 @@ export function createArchiveEngine(deps) {
231
278
  if (boundPersonas.length) {
232
279
  await deps.applySubagentSwitches(boundPersonas.map((n) => ({ name: n, enabled: true })));
233
280
  }
281
+ // ④ 场景备注:覆盖全局备注(notes.json)。恢复走快照的 mcpNotes 原值。
282
+ if (noteTargets && noteTargets.length) {
283
+ await deps.applyMcpNotes(noteTargets.map((x) => ({ id: x.id, note: x.note })));
284
+ }
234
285
  }
235
286
  catch (e) {
236
287
  // 无档案段时没有运行时改动可回滚(snapshot 为 null)。
@@ -246,7 +297,7 @@ export function createArchiveEngine(deps) {
246
297
  return {
247
298
  ok: true,
248
299
  mode: entered.mode,
249
- applied: { mcp: !!(archive && archive.mcp), skills: !!(archive && archive.skills), subagents: !!(archive && archive.subagents) },
300
+ applied: { mcp: !!(archive && archive.mcp), mcpNotes: !!(archive && archive.mcpNotes), skills: !!(archive && archive.skills), subagents: !!(archive && archive.subagents) },
250
301
  // 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
251
302
  switched: {
252
303
  mcpServers: mcpPlan ? mcpPlan.serverSwitches.length : 0,
@@ -32,6 +32,23 @@ export function normalizeMcpSpec(raw) {
32
32
  }
33
33
  return out;
34
34
  }
35
+ /**
36
+ * MCP 场景备注规范化:serverName → 非空备注文本;空值/畸形形态丢弃。
37
+ * 空对象返回 `{}`(段已定义但无覆盖 = 合法,键存在即段已定义)。
38
+ */
39
+ export function normalizeMcpNotes(raw) {
40
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
41
+ return {};
42
+ const out = {};
43
+ for (const [serverRaw, v] of Object.entries(raw)) {
44
+ const server = serverRaw.trim();
45
+ const note = String(v ?? '').trim();
46
+ if (!server || !note)
47
+ continue;
48
+ out[server] = note;
49
+ }
50
+ return out;
51
+ }
35
52
  /** 'mcp'/'skills'/'subagents'/'memories' 键存在且值非 null 才视为"段已定义"——存在性独立于集合空否(null/缺失 = 未定义)。 */
36
53
  export function normalizeArchive(raw) {
37
54
  const obj = (raw && typeof raw === 'object' ? raw : {});
@@ -41,6 +58,8 @@ export function normalizeArchive(raw) {
41
58
  if (mcp)
42
59
  out.mcp = mcp;
43
60
  }
61
+ if (obj.mcpNotes != null)
62
+ out.mcpNotes = normalizeMcpNotes(obj.mcpNotes);
44
63
  if (obj.skills != null)
45
64
  out.skills = normalizeStringList(obj.skills);
46
65
  if (obj.subagents != null)
@@ -144,13 +163,16 @@ mcpServers = [],
144
163
  /** **将被档案改动**的来源,带改动前的 `enabled`。 */
145
164
  skillSources = [],
146
165
  /** **将被档案启用**的人设名(改动前停用的子集;退出时按此停回)。 */
147
- subagents = []) {
166
+ subagents = [],
167
+ /** **将被档案改动**的备注行,带改动前的备注(null = 原本没有)。 */
168
+ mcpNotes = []) {
148
169
  return {
149
170
  mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
150
171
  skills: { ...skills },
151
172
  mcpServers: mcpServers.map((x) => ({ id: x.id, level: x.level, disabled: x.disabled })),
152
173
  skillSources: skillSources.map((x) => ({ root: x.root, enabled: x.enabled })),
153
174
  ...(subagents.length ? { subagents: subagents.slice() } : {}),
175
+ ...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
154
176
  };
155
177
  }
156
178
  // ── 记忆勾选(v3)──────────────────────────────────────────────────────────
@@ -420,7 +420,7 @@ function parseArchives(raw) {
420
420
  }
421
421
  return out;
422
422
  }
423
- function parseModeState(raw) {
423
+ export function parseModeState(raw) {
424
424
  const obj = (raw && typeof raw === 'object' ? raw : {});
425
425
  const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
426
426
  const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
@@ -470,6 +470,12 @@ function parseModeState(raw) {
470
470
  .map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
471
471
  .filter((x) => x.root !== '');
472
472
  const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
473
+ // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
474
+ //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
475
+ const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
476
+ .filter((x) => !!x && typeof x === 'object')
477
+ .map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
478
+ .filter((x) => x.id !== '');
473
479
  return {
474
480
  scene,
475
481
  snapshot: scene
@@ -479,6 +485,7 @@ function parseModeState(raw) {
479
485
  ...(mcpServers.length ? { mcpServers } : {}),
480
486
  ...(skillSources.length ? { skillSources } : {}),
481
487
  ...(subagents.length ? { subagents } : {}),
488
+ ...(mcpNotes.length ? { mcpNotes } : {}),
482
489
  }
483
490
  : null,
484
491
  };
@@ -961,7 +968,7 @@ function renderSceneMemory(probe, index, maxBytes) {
961
968
  let seq = 0;
962
969
  for (const scene of [...buckets.keys()].sort((a, b) => compareSceneBuckets(a, b, index))) {
963
970
  const sceneFiles = (buckets.get(scene) || []).slice().sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
964
- const header = `${sceneHeading(scene)}\n\n`;
971
+ const header = `${sceneHeading(scene)}\n\n${SCENE_MEMORY_NOTE}\n\n`;
965
972
  for (const file of sceneFiles) {
966
973
  const block = `### ${ruleHeading(file)}\n\n${String(file.body ?? '').trim()}\n`;
967
974
  candidates.push({
@@ -1100,6 +1107,11 @@ function sceneLabel(scene, index) {
1100
1107
  }
1101
1108
  /** 场景标题:`''` → 全局;其余用目录名(可追溯)。 */
1102
1109
  const sceneHeading = (scene) => `## 场景记忆:${sceneLabel(scene)}`;
1110
+ /**
1111
+ * 场景标题后的固定引导语(常量,不破坏前缀缓存稳定):让模型知道这段是当前场景的
1112
+ * 常驻参考知识,而不是对话历史或临时说明 —— 相关就用、无关可忽略。
1113
+ */
1114
+ const SCENE_MEMORY_NOTE = '(本场景的常驻参考知识:与当前任务相关时直接采用,无关时忽略)';
1103
1115
  /** 单条记忆的标题:显式 description 优先;派生描述与正文重复,改用文件名。 */
1104
1116
  const ruleHeading = (f) => (f.descriptionDerived || !f.description ? f.name : f.description);
1105
1117
  const byteLen = (s) => Buffer.byteLength(s, 'utf8');
@@ -1653,6 +1653,38 @@ async function setPolicySkillEnabled(root, name, enabled, log) {
1653
1653
  export async function setSkillEnabled(root, name, enabled, log) {
1654
1654
  return setPolicySkillEnabled(root, name, enabled, log);
1655
1655
  }
1656
+ /**
1657
+ * 批量显式停用技能(v0.8.5:新建 / 导入 / 回收站恢复默认不启动 —— 用户裁定)。
1658
+ * 直接写 disabledSkills 名单(与 setSkillEnabled 同一张表),不做 frontmatter 等
1659
+ * 二次校验——调用方传入的必然是刚落盘/刚恢复的条目。
1660
+ * 必须在调用方的写锁(service 的 write())内执行。
1661
+ * @param {{ root: string, name: string }[]} entries - root = 来源 key。
1662
+ */
1663
+ export async function markSkillsDisabled(entries, log) {
1664
+ const list = Array.isArray(entries) ? entries : [];
1665
+ const clean = list
1666
+ .map((e) => ({ root: String((e && e.root) || ""), name: String((e && e.name) || "") }))
1667
+ .filter((e) => e.root !== "" && e.name !== "");
1668
+ if (!clean.length)
1669
+ return { ok: true };
1670
+ const current = await readManagerState();
1671
+ if (current.writable === false)
1672
+ return invalidManagerStateWrite();
1673
+ let dirty = false;
1674
+ for (const e of clean) {
1675
+ const prev = current.state.disabledSkills[e.root] || [];
1676
+ if (prev.indexOf(e.name) >= 0)
1677
+ continue;
1678
+ current.state.disabledSkills[e.root] = [...prev, e.name].sort();
1679
+ dirty = true;
1680
+ }
1681
+ if (dirty) {
1682
+ await writeManagerState(current.state);
1683
+ if (log)
1684
+ log("skill-disable-default", `默认停用 ${clean.map((e) => `${e.root}/${e.name}`).join("、")}`);
1685
+ }
1686
+ return { ok: true };
1687
+ }
1656
1688
  /**
1657
1689
  * 同名技能「首选来源」:默认同名技能按来源 rank 取优先级最高者生效、其余显示为被覆盖;
1658
1690
  * 这里让用户显式指定哪个同名技能生效(preferred=false 取消,回到 rank 顺序)。