dsh-plugin-tool-management 0.8.0 → 0.9.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.
- package/CHANGELOG.md +67 -1
- package/README.md +53 -48
- package/README_EN.md +47 -44
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +39 -0
- package/lib/agents-md/preset-id.js +1 -1
- package/lib/agents-md/service.js +46 -9
- package/lib/approval-policy.js +1 -1
- package/lib/client.js +431 -135
- package/lib/compat/preset-reach.js +155 -93
- package/lib/compat/probe.js +6 -4
- package/lib/context-inject.js +304 -0
- package/lib/hub.js +155 -13
- package/lib/imports/upload.js +34 -14
- package/lib/index.js +870 -226
- package/lib/mcp/state-section.js +46 -15
- package/lib/rules/archive-engine.js +182 -57
- package/lib/rules/archive.js +57 -1
- package/lib/rules/service.js +386 -156
- package/lib/scene-prompt-sync.js +101 -12
- package/lib/skills/catalog.js +125 -0
- package/lib/skills/core.js +38 -4
- package/lib/skills/service.js +36 -10
- package/lib/subagents/catalog.js +15 -5
- package/lib/subagents/service.js +39 -28
- package/lib/subagents/tools.js +18 -13
- package/package.json +14 -9
- package/screenshots.json +8 -8
- package/docs/images/1/345/234/272/346/231/257.png +0 -0
- package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
- package/docs/images/2MCP.png +0 -0
- package/docs/images/2MCP_en.png +0 -0
- package/docs/images/3/346/212/200/350/203/275.png +0 -0
- package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
- package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
- package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
- package/docs/images/6/350/256/260/345/277/206.png +0 -0
- package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
- package/docs/images/7/344/274/232/350/257/235.png +0 -0
- package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
- package/docs/images/8/345/205/274/345/256/271.png +0 -0
- package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
- package/lib/prompt-sections.js +0 -148
package/lib/mcp/state-section.js
CHANGED
|
@@ -1,17 +1,50 @@
|
|
|
1
|
+
// src/mcp/state-section.ts —— MCP 服务器状态段(注入通道的数据源,见 src/context-inject.ts)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:启停 MCP 只改变**请求里的工具 schema 块**,没有任何显式状态陈述。
|
|
4
|
+
// 所以在模型视角里,「配置了但被关掉」和「根本没配过」**完全等价** —— 它连"有哪些
|
|
5
|
+
// MCP server"都不知道,更不知道用户为每台 server 写了什么备注。
|
|
6
|
+
//
|
|
7
|
+
// **备注才是本项的真正价值**:备注输入框的 placeholder 就是
|
|
8
|
+
// 「例如:A 不可用时改用 B 兜底」—— 那是用户写给「未来的模型」的**决策提示**。
|
|
9
|
+
// 这类信息在任何工具 schema 里都拿不到:工具只暴露 `mcp__<server>__<tool>` 的名字与
|
|
10
|
+
// 参数,永远不会说「A 挂了改用 B」。所以「列出 server」只是载体。
|
|
11
|
+
//
|
|
12
|
+
// 范围(2026-09-15 用户裁定):
|
|
13
|
+
// - **只列「当前真正可用」的 server**:配置上已启用 **且** 有**未被停用**的工具
|
|
14
|
+
// (enabledToolCount > 0;场景档案收窄与手动逐工具开关都会写停用表,这里如实扣减)。
|
|
15
|
+
// 没开的不进(用户明确要求);工具全被停用的也不进(模型调不到,留着只会谎报数量)。
|
|
16
|
+
// - 只列 **server 名 + 工具数 + 备注**;**具体工具名绝不注入** ——
|
|
17
|
+
// 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
|
|
18
|
+
// 段里再列一遍是重复;模型按前缀就能对上号。
|
|
19
|
+
// - 工具数只算**当前可用**的:被停用的那一部分不显示(它就是模型 schema 里没有的),
|
|
20
|
+
// 数字必须与模型能调到的工具对得上。
|
|
21
|
+
// - 无备注 → 只有名字 + 工具数(正是「不写就只知道名字」)。
|
|
22
|
+
// - 不写「已启用」字样:能进段的都是可用的,标状态是废话。
|
|
23
|
+
// - 无可用 server → 返回 `''`(renderPrompt 删空段,零 token 成本)。
|
|
24
|
+
//
|
|
25
|
+
// 同步性:`text()` 必须同步返回(renderPrompt 不接受 Promise),而 MCP 清单是异步读盘 →
|
|
26
|
+
// 与子智能体目录同构的 stale-while-revalidate(见 src/subagents/catalog.ts)。
|
|
1
27
|
/** 段里最多列几台 server;超出部分只报数量。 */
|
|
2
28
|
export const DEFAULT_MCP_MAX_ENTRIES = 50;
|
|
3
29
|
/** 备注截断长度。备注是自由文本,界面侧另有 maxLength(500) 兜底,段侧再截一刀。 */
|
|
4
30
|
export const DEFAULT_MCP_NOTE_MAX_LENGTH = 200;
|
|
5
31
|
/**
|
|
6
|
-
*
|
|
32
|
+
* 备注的固定前缀(模型侧口径,用户裁定 2026-09-16:字段与段统一叫 user-hint / 用户提示)。
|
|
7
33
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
34
|
+
* 原为「备注:」,取「降低被读成指令的概率」——备注是自由文本,注入后**由插件背书**地到达模型。
|
|
35
|
+
* 改叫「用户提示:」是有意为之:它本来就该被读成**用户的决策提示**(「A 挂了改用 B」),
|
|
36
|
+
* 而不是系统策略。威胁模型不变(内容由用户自己写、仅本机可写,属**自伤**而非被攻击),
|
|
37
|
+
* 但前缀如实标明来源,模型据此权衡即可。
|
|
11
38
|
*/
|
|
12
|
-
export const MCP_NOTE_PREFIX = '
|
|
13
|
-
/**
|
|
14
|
-
|
|
39
|
+
export const MCP_NOTE_PREFIX = '用户提示:';
|
|
40
|
+
/**
|
|
41
|
+
* 备注压成单行(换行 → 空格)并截断。
|
|
42
|
+
*
|
|
43
|
+
* 导出给模型侧的 `mcp_manager_list` 复用:压制型预设下不注入时,那条工具
|
|
44
|
+
* 是备注唯一的读取路径,两处的压行/截断口径必须一致,否则同一句备注在界面、段、工具里
|
|
45
|
+
* 会是三个样子。
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeMcpNote(value, maxLength) {
|
|
15
48
|
const flat = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
|
|
16
49
|
if (!flat)
|
|
17
50
|
return '';
|
|
@@ -25,8 +58,10 @@ function normalizeNote(value, maxLength) {
|
|
|
25
58
|
export function renderMcpStateSection(rows, opts = {}) {
|
|
26
59
|
const maxEntries = opts.maxEntries ?? DEFAULT_MCP_MAX_ENTRIES;
|
|
27
60
|
const noteMaxLength = opts.noteMaxLength ?? DEFAULT_MCP_NOTE_MAX_LENGTH;
|
|
28
|
-
//
|
|
29
|
-
const
|
|
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);
|
|
30
65
|
if (!usable.length)
|
|
31
66
|
return '';
|
|
32
67
|
// 按展示名排序:即使补丁文件里的行序变化,段文本也保持逐字节稳定(前缀缓存契约)。
|
|
@@ -37,9 +72,9 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
37
72
|
});
|
|
38
73
|
const shown = sorted.slice(0, Math.max(0, maxEntries));
|
|
39
74
|
const lines = shown.map((r) => {
|
|
40
|
-
const count =
|
|
75
|
+
const count = countOf(r);
|
|
41
76
|
const head = '- **' + String(r.serverName ?? r.id) + '**(' + count + ' 个工具)';
|
|
42
|
-
const note =
|
|
77
|
+
const note = normalizeMcpNote(r.notes, noteMaxLength);
|
|
43
78
|
return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
|
|
44
79
|
});
|
|
45
80
|
const out = ['## MCP 服务器', '', ...lines];
|
|
@@ -84,7 +119,3 @@ export function createMcpStateCatalog(deps, opts = {}) {
|
|
|
84
119
|
warm: () => revalidate(),
|
|
85
120
|
};
|
|
86
121
|
}
|
|
87
|
-
/** 供 `createPromptSectionRegistrar` 使用的段声明。 */
|
|
88
|
-
export function mcpStateSectionOf(catalog, order) {
|
|
89
|
-
return { name: 'tool-management:mcp-state', order, text: () => catalog.text() };
|
|
90
|
-
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 退出 = 恢复快照 → 落盘自由模式;切换 = 先退后进。
|
|
4
4
|
// 失败语义(fail-closed):任一步失败即反向恢复运行时并写回旧切片;回滚不全会如实写进错误文本。
|
|
5
5
|
// v2:MCP 段两级(服务器勾选 + '*' / 工具明细),停用表存勾选集的补集,通配由 index.ts 的 guard/restrict 原生支持。
|
|
6
|
-
import { computeMcpPlan, computeSkillsPlan, normalizeArchive, snapshotRuntime,
|
|
6
|
+
import { computeMcpPlan, computeSkillsPlan, normalizeArchive, snapshotRuntime, mergeSnapshotSwitches, } from './archive.js';
|
|
7
7
|
const msg = (e) => String((e && e.message) || e);
|
|
8
8
|
export function createArchiveEngine(deps) {
|
|
9
9
|
let chain = Promise.resolve();
|
|
@@ -34,10 +34,20 @@ 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
|
-
//
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
await deps.
|
|
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
|
+
}
|
|
42
|
+
// 子智能体开关:先停「进入时被这次关掉的」那些(档案没勾 = 本场景不开),
|
|
43
|
+
// 再开回「进入时被这次启用过的」那批 —— 与进入时的顺序相反,两个方向都要还原。
|
|
44
|
+
const personasOff = snapshot.subagents ?? [];
|
|
45
|
+
if (personasOff.length) {
|
|
46
|
+
await deps.applySubagentSwitches(personasOff.map((n) => ({ name: n, enabled: false })));
|
|
47
|
+
}
|
|
48
|
+
const personasOn = snapshot.subagentsOn ?? [];
|
|
49
|
+
if (personasOn.length) {
|
|
50
|
+
await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
/**
|
|
@@ -61,6 +71,50 @@ export function createArchiveEngine(deps) {
|
|
|
61
71
|
const base = `${stage}失败:${msg(err)}`;
|
|
62
72
|
return { ok: false, error: problems.length ? `${base};回滚未完成(${problems.join(';')})` : `${base},已回滚` };
|
|
63
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 把**当前模式**的档案就地重新应用一次(改档案 → 立即生效)。
|
|
76
|
+
*
|
|
77
|
+
* 存在的理由(用户裁定 2026-09-16):场景内页面上的开关被禁用,改环境只能改档案;
|
|
78
|
+
* 若档案改完不立刻生效,用户就得「退出场景再进一次」才看得到结果。
|
|
79
|
+
*
|
|
80
|
+
* 口径与进入模式**完全一致**(四域同口径):勾选集 = 该场景下开着的东西,段未定义 =
|
|
81
|
+
* 一个都没勾 = 全部停用。返回这次要改的**上层行**(服务器级 / 来源级,带改动前的状态),
|
|
82
|
+
* 由调用方并进快照 —— 退出仍按「进场景前」精确还原(见 mergeSnapshotSwitches)。
|
|
83
|
+
* 人设域不在这里处理:它需要往快照里补记(谁被开 / 关过),由 index.ts 的
|
|
84
|
+
* `scene-archive-save` 包装统一做。
|
|
85
|
+
*/
|
|
86
|
+
async function reapplyActiveArchive(archive) {
|
|
87
|
+
const mcpPlan = computeMcpPlan((archive && archive.mcp) || {}, {
|
|
88
|
+
configuredServers: await deps.configuredServers(),
|
|
89
|
+
knownTools: await deps.serverKnownTools(),
|
|
90
|
+
serverStates: await deps.mcpServerStates(),
|
|
91
|
+
});
|
|
92
|
+
const skillsPlan = computeSkillsPlan((archive && archive.skills) || [], await deps.knownSkillKeys(), await deps.skillSourceStates());
|
|
93
|
+
if (mcpPlan.serverSwitches.length) {
|
|
94
|
+
await deps.applyMcpServerSwitches(mcpPlan.serverSwitches.map((s) => ({ id: s.id, level: s.level, enabled: s.enabled })));
|
|
95
|
+
}
|
|
96
|
+
if (skillsPlan.sourceSwitches.length) {
|
|
97
|
+
await deps.applySkillSourceSwitches(skillsPlan.sourceSwitches.map((s) => ({ root: s.root, enabled: s.enabled })));
|
|
98
|
+
}
|
|
99
|
+
await deps.applyMcpEntries(mcpPlan.entries);
|
|
100
|
+
await deps.applySkills(skillsPlan.target);
|
|
101
|
+
if (archive && archive.mcpNotes) {
|
|
102
|
+
// 与进入时同一口径:场景备注只作用于 mcp 段里实际勾选的服务器。
|
|
103
|
+
const selected = archive.mcp ? Object.keys(archive.mcp) : [];
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (const [server, note] of Object.entries(archive.mcpNotes)) {
|
|
106
|
+
if (selected.indexOf(server) < 0)
|
|
107
|
+
continue;
|
|
108
|
+
const id = await deps.mcpIdOfServer(server);
|
|
109
|
+
if (id !== undefined)
|
|
110
|
+
entries.push({ id, note });
|
|
111
|
+
}
|
|
112
|
+
// 不在本次覆盖里的旧场景备注行**不这里删**(退出时会按快照整体回原位)。
|
|
113
|
+
if (entries.length)
|
|
114
|
+
await deps.applyMcpNotes(entries);
|
|
115
|
+
}
|
|
116
|
+
return { mcpServers: mcpPlan.serverSwitches, skillSources: skillsPlan.sourceSwitches };
|
|
117
|
+
}
|
|
64
118
|
const ops = {
|
|
65
119
|
// 读:页面渲染模式与档案
|
|
66
120
|
'scene-mode-get': async () => {
|
|
@@ -89,6 +143,18 @@ export function createArchiveEngine(deps) {
|
|
|
89
143
|
if (hadKeys && Object.keys(archive.mcp).length === 0)
|
|
90
144
|
delete archive.mcp;
|
|
91
145
|
}
|
|
146
|
+
if (archive.mcpNotes) {
|
|
147
|
+
const configured = await deps.configuredServers();
|
|
148
|
+
for (const server of Object.keys(archive.mcpNotes)) {
|
|
149
|
+
if (configured.indexOf(server) < 0) {
|
|
150
|
+
stale.push('mcpNotes/' + server);
|
|
151
|
+
delete archive.mcpNotes[server];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// 空段 = 无覆盖(与未定义行为相同:进入场景不覆盖任何备注),删除以免歧义。
|
|
155
|
+
if (Object.keys(archive.mcpNotes).length === 0)
|
|
156
|
+
delete archive.mcpNotes;
|
|
157
|
+
}
|
|
92
158
|
if (archive.skills) {
|
|
93
159
|
const known = await deps.knownSkillKeys();
|
|
94
160
|
const hadKeys = archive.skills.length > 0;
|
|
@@ -116,7 +182,28 @@ export function createArchiveEngine(deps) {
|
|
|
116
182
|
else
|
|
117
183
|
slice.archives[scene] = archive;
|
|
118
184
|
await deps.saveSlice(slice);
|
|
119
|
-
|
|
185
|
+
// 改的正是**当前模式**的档案 → 就地重新应用(场景内只能改档案,改完必须生效)。
|
|
186
|
+
// 这次新改到的**上层行**(服务器级 / 来源级)并进快照:退出仍按「进场景前」还原,
|
|
187
|
+
// 进场景时改过的行 + 这次新改的行都要有记录(已记过的行保持原值,见 mergeSnapshotSwitches)。
|
|
188
|
+
// 应用失败不回滚档案:档案是用户的意图记录,运行时状态下次进场景会对齐;
|
|
189
|
+
// 如实把错误报出去,别假装生效了。
|
|
190
|
+
let applyError = null;
|
|
191
|
+
if (slice.mode.scene === scene) {
|
|
192
|
+
try {
|
|
193
|
+
const changed = await reapplyActiveArchive(slice.archives[scene]);
|
|
194
|
+
const snapshot = slice.mode.snapshot;
|
|
195
|
+
if (snapshot && (changed.mcpServers.length || changed.skillSources.length)) {
|
|
196
|
+
await deps.saveSlice({
|
|
197
|
+
...slice,
|
|
198
|
+
mode: { ...slice.mode, snapshot: mergeSnapshotSwitches(snapshot, changed.mcpServers, changed.skillSources) },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
applyError = msg(e);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, scene, archive: slice.archives[scene] ?? null, stale, ...(applyError ? { applyError } : {}) };
|
|
120
207
|
}),
|
|
121
208
|
// 写:进入/退出/切换当前模式
|
|
122
209
|
// scene=null 退出;目标场景没有 mcp/skills 段 = 结构化拒绝(设计 §2.4:此类场景不显示模式按钮)。
|
|
@@ -153,54 +240,87 @@ export function createArchiveEngine(deps) {
|
|
|
153
240
|
}
|
|
154
241
|
if (!target)
|
|
155
242
|
return { ok: true, mode: current, applied: null, stale: [] };
|
|
156
|
-
// ② 进入目标模式:校验 →
|
|
243
|
+
// ② 进入目标模式:校验 → 算计划 → 拍快照 → 先落盘 mode(中途失败也留可退快照)→ 应用 → 记忆收窄。
|
|
244
|
+
// 三个域(MCP / 技能 / 人设)都按「与档案勾选集完全一致」应用,未定义 = 全关;
|
|
245
|
+
// 备注段只在定义时覆盖。所以这里没有「纯记忆场景不用动运行时」这条分支 —— 恒拍快照。
|
|
157
246
|
const archive = slice.archives[target];
|
|
158
247
|
if (!(await deps.sceneExists(target)))
|
|
159
248
|
return { ok: false, error: `场景不存在: ${target}` };
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
249
|
+
// 子智能体段:人设域按**「置为与勾选集完全一致」**应用(与 MCP / 技能同一条语义)——
|
|
250
|
+
// 勾了的启用、没勾的(含整段未定义 = 一个都没勾)停用。此前只启用、从不关闭,
|
|
251
|
+
// 于是用户实测「场景没绑定任何人设,进场景后原先开着的人设照样开着」。
|
|
252
|
+
// 快照两个方向都记:被打开的(改动前关着)退出停回,被关掉的(改动前开着)退出开回。
|
|
253
|
+
const boundPersonas = archive && Array.isArray(archive.subagents) ? archive.subagents : [];
|
|
254
|
+
let unboundPersonas = [];
|
|
255
|
+
let personaRestore = [];
|
|
256
|
+
let personaRestoreOn = [];
|
|
257
|
+
try {
|
|
258
|
+
const known = await deps.knownPersonas();
|
|
259
|
+
unboundPersonas = [...known].filter((n) => boundPersonas.indexOf(n) < 0).sort();
|
|
260
|
+
personaRestore = await deps.disabledPersonas(boundPersonas);
|
|
261
|
+
personaRestoreOn = await deps.enabledPersonas(unboundPersonas);
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
return { ok: false, error: `读取人设开关状态失败(未改动任何东西):${msg(e)}` };
|
|
265
|
+
}
|
|
266
|
+
// **四域同口径**(用户实测三连,2026-09-16):档案的勾选集 = 该场景下开着的东西,
|
|
267
|
+
// **段未定义 = 一个都没勾 = 全部停用**。此前只有人设域按这条应用,MCP / 技能沿用旧口径
|
|
268
|
+
// 「未定义 = 该域不碰」——于是「场景档案没开 MCP 工具集 / 技能集」时,进场景后原本在跑的
|
|
269
|
+
// 服务器与技能照样开着(用户报的 ②③)。开关既然已收归档案(场景内页面不可改),
|
|
270
|
+
// 档案就必须说得清「这个场景要什么」——未定义只能读成「什么都不要」。
|
|
271
|
+
// 备注段是例外:未定义 = 不覆盖任何备注(没有东西"因为未定义而需要关掉")。
|
|
272
|
+
const mcpSpec = (archive && archive.mcp) || {};
|
|
273
|
+
const skillsSpec = (archive && archive.skills) || [];
|
|
166
274
|
// 先算计划、再拍快照:快照只记**将被改动**的服务器行 / 来源 / 人设(带改动前的状态),
|
|
167
275
|
// 退出时按记录精确恢复 —— 不动用户手动设置的其他行。
|
|
168
276
|
let mcpPlan = null;
|
|
169
277
|
let skillsPlan = null;
|
|
170
|
-
|
|
278
|
+
try {
|
|
279
|
+
mcpPlan = computeMcpPlan(mcpSpec, {
|
|
280
|
+
configuredServers: await deps.configuredServers(),
|
|
281
|
+
knownTools: await deps.serverKnownTools(),
|
|
282
|
+
serverStates: await deps.mcpServerStates(),
|
|
283
|
+
});
|
|
284
|
+
skillsPlan = computeSkillsPlan(skillsSpec, await deps.knownSkillKeys(), await deps.skillSourceStates());
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
return { ok: false, error: `读取运行时状态失败(未改动任何东西):${msg(e)}` };
|
|
288
|
+
}
|
|
289
|
+
// 场景备注(v0.8.1):把 `mcpNotes`(serverName → 场景备注)映射到 loader id,
|
|
290
|
+
// 记录**改动前**的备注(快照恢复用),应用时写进 notes.json 覆盖全局备注。
|
|
291
|
+
let noteTargets = null;
|
|
292
|
+
let noteBefore = [];
|
|
293
|
+
if (archive && archive.mcpNotes) {
|
|
171
294
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
295
|
+
const current = await deps.currentMcpNotes();
|
|
296
|
+
const targets = [];
|
|
297
|
+
const before = [];
|
|
298
|
+
// 只对**mcp 段里实际勾选**的服务器应用场景备注:未勾选的服务器该场景根本不用它,
|
|
299
|
+
// 覆盖它的全局备注属于误伤(2026-09-16 用户实测「没勾的服务器备注也变了」)。
|
|
300
|
+
const selectedServers = archive.mcp ? Object.keys(archive.mcp) : [];
|
|
301
|
+
for (const [server, note] of Object.entries(archive.mcpNotes)) {
|
|
302
|
+
if (selectedServers.indexOf(server) < 0)
|
|
303
|
+
continue;
|
|
304
|
+
const id = await deps.mcpIdOfServer(server);
|
|
305
|
+
if (id === undefined)
|
|
306
|
+
continue; // 保存时已 stale 过,这里再兜底
|
|
307
|
+
targets.push({ id, note });
|
|
308
|
+
before.push({ id, note: current[id] ?? null });
|
|
178
309
|
}
|
|
179
|
-
if (
|
|
180
|
-
|
|
310
|
+
if (targets.length) {
|
|
311
|
+
noteTargets = targets;
|
|
312
|
+
noteBefore = before;
|
|
181
313
|
}
|
|
182
314
|
}
|
|
183
315
|
catch (e) {
|
|
184
|
-
return { ok: false, error:
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// 子智能体段:进入时把档案勾选的人设**启用**(子智能体页同步亮起)。
|
|
188
|
-
// 快照只记「改动前停用」的那些 —— 退出时按名单停回,不动其他行。
|
|
189
|
-
const boundPersonas = archive && Array.isArray(archive.subagents) ? archive.subagents : [];
|
|
190
|
-
let personaRestore = [];
|
|
191
|
-
if (boundPersonas.length) {
|
|
192
|
-
try {
|
|
193
|
-
personaRestore = await deps.disabledPersonas(boundPersonas);
|
|
194
|
-
}
|
|
195
|
-
catch (e) {
|
|
196
|
-
return { ok: false, error: `读取人设开关状态失败(未改动任何东西):${msg(e)}` };
|
|
316
|
+
return { ok: false, error: `读取备注状态失败(未改动任何东西):${msg(e)}` };
|
|
197
317
|
}
|
|
198
318
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
319
|
+
// 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
|
|
320
|
+
// 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
|
|
321
|
+
const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(),
|
|
322
|
+
// 直接用计划带出的 `*Before`(改动前的状态),不在这里反推 —— 反推容易搞反方向。
|
|
323
|
+
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);
|
|
204
324
|
const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
|
|
205
325
|
try {
|
|
206
326
|
await deps.saveSlice({ ...slice, mode: entered.mode });
|
|
@@ -212,45 +332,50 @@ export function createArchiveEngine(deps) {
|
|
|
212
332
|
try {
|
|
213
333
|
// ① 先切**上层**(服务器级 / 来源级)——下层只有在上层开着时才有意义:
|
|
214
334
|
// 服务器没起来,它的工具不存在,工具级停用无从谈起;来源关着,技能级 enable 会被吞掉。
|
|
215
|
-
if (mcpPlan
|
|
335
|
+
if (mcpPlan.serverSwitches.length) {
|
|
216
336
|
await deps.applyMcpServerSwitches(mcpPlan.serverSwitches.map((s) => ({ id: s.id, level: s.level, enabled: s.enabled })));
|
|
217
337
|
}
|
|
218
|
-
if (skillsPlan
|
|
338
|
+
if (skillsPlan.sourceSwitches.length) {
|
|
219
339
|
await deps.applySkillSourceSwitches(skillsPlan.sourceSwitches.map((s) => ({ root: s.root, enabled: s.enabled })));
|
|
220
340
|
}
|
|
221
341
|
// ② 再写**下层**(工具级停用表 / 技能级策略)。
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
342
|
+
stale.push(...mcpPlan.stale);
|
|
343
|
+
await deps.applyMcpEntries(mcpPlan.entries);
|
|
344
|
+
stale.push(...skillsPlan.stale);
|
|
345
|
+
await deps.applySkills(skillsPlan.target);
|
|
346
|
+
// ③ 子智能体开关:**勾了的启用、未勾的停用**(子智能体页与目录段同步)。
|
|
347
|
+
// 先开后关:勾选名单不可能同时出现在两边,顺序只影响中间态的观感。
|
|
348
|
+
if (personaRestore.length) {
|
|
349
|
+
await deps.applySubagentSwitches(personaRestore.map((n) => ({ name: n, enabled: true })));
|
|
225
350
|
}
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
await deps.applySkills(skillsPlan.target);
|
|
351
|
+
if (personaRestoreOn.length) {
|
|
352
|
+
await deps.applySubagentSwitches(personaRestoreOn.map((n) => ({ name: n, enabled: false })));
|
|
229
353
|
}
|
|
230
|
-
//
|
|
231
|
-
if (
|
|
232
|
-
await deps.
|
|
354
|
+
// ④ 场景备注:覆盖全局备注(notes.json)。恢复走快照的 mcpNotes 原值。
|
|
355
|
+
if (noteTargets && noteTargets.length) {
|
|
356
|
+
await deps.applyMcpNotes(noteTargets.map((x) => ({ id: x.id, note: x.note })));
|
|
233
357
|
}
|
|
234
358
|
}
|
|
235
359
|
catch (e) {
|
|
236
|
-
|
|
237
|
-
return snapshot ? await rollback(slice, snapshot, '应用档案', e) : { ok: false, error: `应用档案失败:${String(e?.message || e)}` };
|
|
360
|
+
return await rollback(slice, snapshot, '应用档案', e);
|
|
238
361
|
}
|
|
239
362
|
// ③ 记忆启用集收窄为 {S}(`_shared` 由 resolveActiveScenes 恒常加入;退出不恢复)。
|
|
240
363
|
try {
|
|
241
364
|
await deps.saveSlice(entered);
|
|
242
365
|
}
|
|
243
366
|
catch (e) {
|
|
244
|
-
return
|
|
367
|
+
return await rollback(slice, snapshot, '记忆收窄落盘', e);
|
|
245
368
|
}
|
|
246
369
|
return {
|
|
247
370
|
ok: true,
|
|
248
371
|
mode: entered.mode,
|
|
249
|
-
|
|
372
|
+
// 三个域都是「按勾选集全量应用」(未定义 = 全关),所以都算已应用;
|
|
373
|
+
// 备注段未定义 = 不覆盖任何备注,如实报它是否定义。
|
|
374
|
+
applied: { mcp: true, skills: true, subagents: true, mcpNotes: !!(archive && archive.mcpNotes) },
|
|
250
375
|
// 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
|
|
251
376
|
switched: {
|
|
252
|
-
mcpServers: mcpPlan
|
|
253
|
-
skillSources: skillsPlan
|
|
377
|
+
mcpServers: mcpPlan.serverSwitches.length,
|
|
378
|
+
skillSources: skillsPlan.sourceSwitches.length,
|
|
254
379
|
},
|
|
255
380
|
stale,
|
|
256
381
|
narrowedTo: [target],
|
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)
|
|
@@ -138,19 +157,56 @@ export function computeSkillsPlan(skills, knownSkillKeys, sourceStates) {
|
|
|
138
157
|
}
|
|
139
158
|
return { target, stale, sourceSwitches };
|
|
140
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* 把「这次要改的上层行」并进快照 —— **已记录的行保持原值**(先记的才是进场景前的状态)。
|
|
162
|
+
*
|
|
163
|
+
* 为什么需要:进入模式时快照只记了**当时将要改动**的行;模式进行中用户改档案(改档案 = 立即生效)
|
|
164
|
+
* 又可能新改到别的服务器 / 来源级行。退出必须回到「进场景前」,所以这些新改的行也得有记录。
|
|
165
|
+
* 反之,若某行在进入时就记过,它的 `*Before` 才是进场景前的值 —— 这次的中间态值必须丢弃。
|
|
166
|
+
*/
|
|
167
|
+
export function mergeSnapshotSwitches(snapshot, mcpServers = [], skillSources = []) {
|
|
168
|
+
const servers = (snapshot.mcpServers ?? []).map((x) => ({ ...x }));
|
|
169
|
+
const seenServer = new Set(servers.map((x) => x.id + '\u0000' + x.level));
|
|
170
|
+
for (const s of mcpServers) {
|
|
171
|
+
const key = s.id + '\u0000' + s.level;
|
|
172
|
+
if (seenServer.has(key))
|
|
173
|
+
continue;
|
|
174
|
+
seenServer.add(key);
|
|
175
|
+
servers.push({ id: s.id, level: s.level, disabled: s.disabledBefore });
|
|
176
|
+
}
|
|
177
|
+
const sources = (snapshot.skillSources ?? []).map((x) => ({ ...x }));
|
|
178
|
+
const seenSource = new Set(sources.map((x) => x.root));
|
|
179
|
+
for (const s of skillSources) {
|
|
180
|
+
if (seenSource.has(s.root))
|
|
181
|
+
continue;
|
|
182
|
+
seenSource.add(s.root);
|
|
183
|
+
sources.push({ root: s.root, enabled: s.enabledBefore });
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...snapshot,
|
|
187
|
+
...(servers.length ? { mcpServers: servers } : {}),
|
|
188
|
+
...(sources.length ? { skillSources: sources } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
141
191
|
export function snapshotRuntime(mcpRaw, skills,
|
|
142
192
|
/** **将被档案改动**的服务器行,带改动前的 `disabled`。 */
|
|
143
193
|
mcpServers = [],
|
|
144
194
|
/** **将被档案改动**的来源,带改动前的 `enabled`。 */
|
|
145
195
|
skillSources = [],
|
|
146
196
|
/** **将被档案启用**的人设名(改动前停用的子集;退出时按此停回)。 */
|
|
147
|
-
subagents = []
|
|
197
|
+
subagents = [],
|
|
198
|
+
/** **将被档案停用**的人设名(改动前启用的子集;退出时按此重新打开)。 */
|
|
199
|
+
subagentsOn = [],
|
|
200
|
+
/** **将被档案改动**的备注行,带改动前的备注(null = 原本没有)。 */
|
|
201
|
+
mcpNotes = []) {
|
|
148
202
|
return {
|
|
149
203
|
mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
|
|
150
204
|
skills: { ...skills },
|
|
151
205
|
mcpServers: mcpServers.map((x) => ({ id: x.id, level: x.level, disabled: x.disabled })),
|
|
152
206
|
skillSources: skillSources.map((x) => ({ root: x.root, enabled: x.enabled })),
|
|
153
207
|
...(subagents.length ? { subagents: subagents.slice() } : {}),
|
|
208
|
+
...(subagentsOn.length ? { subagentsOn: subagentsOn.slice() } : {}),
|
|
209
|
+
...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
|
|
154
210
|
};
|
|
155
211
|
}
|
|
156
212
|
// ── 记忆勾选(v3)──────────────────────────────────────────────────────────
|