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/CHANGELOG.md +27 -0
- package/README.md +3 -0
- package/README_EN.md +3 -0
- package/docs/update.md +14 -0
- package/lib/client.js +98 -45
- package/lib/compat/probe.js +6 -4
- package/lib/index.js +220 -23
- package/lib/mcp/state-section.js +1 -1
- package/lib/rules/archive-engine.js +54 -3
- package/lib/rules/archive.js +23 -1
- package/lib/rules/service.js +14 -2
- package/lib/skills/core.js +32 -0
- package/lib/skills/service.js +36 -10
- package/lib/subagents/catalog.js +2 -1
- package/lib/subagents/service.js +14 -16
- package/package.json +8 -8
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
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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
|
|
3028
|
+
live,
|
|
2841
3029
|
serverDisabled: !!row.disabled,
|
|
2842
3030
|
// 停用表里的 ['*'] = 整台工具停用(勾选器据此预勾,避免把"全停"读成"全启用")。
|
|
2843
3031
|
allToolsDisabled: (disabledRaw[name] || []).indexOf('*') >= 0,
|
|
2844
|
-
|
|
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', () =>
|
|
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
|
}
|
package/lib/mcp/state-section.js
CHANGED
|
@@ -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,
|
package/lib/rules/archive.js
CHANGED
|
@@ -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)──────────────────────────────────────────────────────────
|
package/lib/rules/service.js
CHANGED
|
@@ -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');
|
package/lib/skills/core.js
CHANGED
|
@@ -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 顺序)。
|