dsh-plugin-tool-management 0.9.0 → 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.
- package/CHANGELOG.md +323 -245
- package/README.md +212 -201
- package/README_EN.md +206 -199
- 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 +171 -111
- package/lib/client.js +6220 -5451
- package/lib/compat/probe.js +0 -5
- package/lib/context-inject.js +468 -39
- package/lib/history/bridge.js +7 -0
- package/lib/history/tombstone.js +1 -2
- package/lib/hub.js +3 -1
- package/lib/index.js +735 -95
- package/lib/mcp/override-blocks.js +10 -6
- package/lib/mcp/state-section.js +64 -21
- package/lib/rules/archive-engine.js +93 -18
- package/lib/rules/archive.js +12 -7
- package/lib/rules/service.js +195 -44
- package/lib/scene-prompt-sync.js +4 -12
- package/lib/skills/catalog.js +6 -9
- package/lib/skills/core.js +5 -4
- package/lib/skills/service.js +56 -2
- package/lib/subagents/catalog.js +31 -15
- package/lib/subagents/service.js +307 -34
- package/lib/subagents/tools.js +29 -4
- package/package.json +105 -102
- package/lib/history/projcache.js +0 -335
|
@@ -46,9 +46,9 @@ export function scanBlockRanges(lines) {
|
|
|
46
46
|
/**
|
|
47
47
|
* 读一个覆盖块三件事:id、disabled、是不是"纯块"。
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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 (
|
|
69
|
+
if (/^ {2}name:\s*\S+\s*$/.test(line))
|
|
70
70
|
continue;
|
|
71
|
-
const flag = line.match(
|
|
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(
|
|
131
|
+
const flag = line.match(/^ {6}disabled:\s*(true|false)\s*$/);
|
|
128
132
|
if (flag)
|
|
129
133
|
disabled = flag[1] === 'true';
|
|
130
134
|
}
|
package/lib/mcp/state-section.js
CHANGED
|
@@ -9,18 +9,33 @@
|
|
|
9
9
|
// 这类信息在任何工具 schema 里都拿不到:工具只暴露 `mcp__<server>__<tool>` 的名字与
|
|
10
10
|
// 参数,永远不会说「A 挂了改用 B」。所以「列出 server」只是载体。
|
|
11
11
|
//
|
|
12
|
-
// 范围(2026-09-15
|
|
13
|
-
// -
|
|
14
|
-
//
|
|
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
|
-
// -
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
const
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
81
|
-
|
|
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
|
}
|
|
@@ -39,15 +87,26 @@ export function createArchiveEngine(deps) {
|
|
|
39
87
|
if (notes.length) {
|
|
40
88
|
await deps.applyMcpNotes(notes.map((x) => ({ id: x.id, note: x.note })));
|
|
41
89
|
}
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
// 子智能体开关(v0.9.1):**全量映射优先**——退出即精确还原「进场景前」的开与关。
|
|
91
|
+
// 为什么不能只靠下面那两个部分名单:场景内页面开关已开放(未锁定即可改、改动同步进档案),
|
|
92
|
+
// 「现在开着」不再只来自档案勾选,部分名单答不出「这个人是场景开的还是用户开的」。
|
|
93
|
+
// 表里**记过的名字**逐个还原;场景中新建的人设不在表里 → 不动它(与技能域同口径)。
|
|
94
|
+
// 老 snapshot 没有这一栏 → 退回两个方向的部分名单(旧行为:先停被开的,再开回被关的)。
|
|
95
|
+
const personaStates = snapshot.subagentsAll;
|
|
96
|
+
if (personaStates && typeof personaStates === 'object') {
|
|
97
|
+
const switches = Object.entries(personaStates).map(([name, on]) => ({ name, enabled: on === true }));
|
|
98
|
+
if (switches.length)
|
|
99
|
+
await deps.applySubagentSwitches(switches);
|
|
47
100
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
101
|
+
else {
|
|
102
|
+
const personasOff = snapshot.subagents ?? [];
|
|
103
|
+
if (personasOff.length) {
|
|
104
|
+
await deps.applySubagentSwitches(personasOff.map((n) => ({ name: n, enabled: false })));
|
|
105
|
+
}
|
|
106
|
+
const personasOn = snapshot.subagentsOn ?? [];
|
|
107
|
+
if (personasOn.length) {
|
|
108
|
+
await deps.applySubagentSwitches(personasOn.map((n) => ({ name: n, enabled: true })));
|
|
109
|
+
}
|
|
51
110
|
}
|
|
52
111
|
}
|
|
53
112
|
/**
|
|
@@ -156,7 +215,11 @@ export function createArchiveEngine(deps) {
|
|
|
156
215
|
delete archive.mcpNotes;
|
|
157
216
|
}
|
|
158
217
|
if (archive.skills) {
|
|
159
|
-
|
|
218
|
+
// 校验用**可勾选**集合(不是「已知全集」):被同名技能覆盖的副本与结构不完整的技能
|
|
219
|
+
// 永远不可能生效,勾了也是假的 —— 留着就是「档案说开着、技能页说没启动」(用户实测:
|
|
220
|
+
// 启动一个目录后档案里多出被覆盖的那个技能)。这里按同一口径丢弃并如实报告,
|
|
221
|
+
// 顺带把历史残留清掉。
|
|
222
|
+
const known = await deps.selectableSkillKeys();
|
|
160
223
|
const hadKeys = archive.skills.length > 0;
|
|
161
224
|
stale.push(...archive.skills.filter((k) => !known.has(k)).map((k) => 'skills/' + k));
|
|
162
225
|
archive.skills = archive.skills.filter((k) => known.has(k));
|
|
@@ -254,11 +317,16 @@ export function createArchiveEngine(deps) {
|
|
|
254
317
|
let unboundPersonas = [];
|
|
255
318
|
let personaRestore = [];
|
|
256
319
|
let personaRestoreOn = [];
|
|
320
|
+
// 全量开关映射(v0.9.1):退出即精确还原「进场景前」。半个名单做不到这件事 ——
|
|
321
|
+
// 场景里手动开过的人设,「改动前是关还是开」只在这张全量表里。
|
|
322
|
+
let personaStates = {};
|
|
257
323
|
try {
|
|
258
324
|
const known = await deps.knownPersonas();
|
|
259
325
|
unboundPersonas = [...known].filter((n) => boundPersonas.indexOf(n) < 0).sort();
|
|
260
326
|
personaRestore = await deps.disabledPersonas(boundPersonas);
|
|
261
327
|
personaRestoreOn = await deps.enabledPersonas(unboundPersonas);
|
|
328
|
+
const on = new Set(await deps.enabledPersonas([...known]));
|
|
329
|
+
personaStates = Object.fromEntries([...known].map((n) => [n, on.has(n)]));
|
|
262
330
|
}
|
|
263
331
|
catch (e) {
|
|
264
332
|
return { ok: false, error: `读取人设开关状态失败(未改动任何东西):${msg(e)}` };
|
|
@@ -271,17 +339,24 @@ export function createArchiveEngine(deps) {
|
|
|
271
339
|
// 备注段是例外:未定义 = 不覆盖任何备注(没有东西"因为未定义而需要关掉")。
|
|
272
340
|
const mcpSpec = (archive && archive.mcp) || {};
|
|
273
341
|
const skillsSpec = (archive && archive.skills) || [];
|
|
274
|
-
//
|
|
275
|
-
//
|
|
342
|
+
// 先读现状、再算计划:现状既喂给计划(算「哪些行需要改」),也**全量**写进快照。
|
|
343
|
+
// 快照记的是**每一行**服务器 / 来源的进场景前状态,不是只记「本次计划要改的行」——
|
|
344
|
+
// 场景期间用户可以在页面上改开关(未锁定时可用,改动同步进档案),只记计划行的话
|
|
345
|
+
// 这些改动就没有原值可回:用户报的「场景里关掉 A 目录,退出后 A 和它下面的技能都没开回来」,
|
|
346
|
+
// 就是 A 在进场景时没被档案改动(档案里勾着 A 下面的技能)而未进快照。
|
|
276
347
|
let mcpPlan = null;
|
|
277
348
|
let skillsPlan = null;
|
|
349
|
+
let mcpServerStates = [];
|
|
350
|
+
let skillSources = [];
|
|
278
351
|
try {
|
|
352
|
+
mcpServerStates = await deps.mcpServerStates();
|
|
353
|
+
skillSources = await deps.skillSourceStates();
|
|
279
354
|
mcpPlan = computeMcpPlan(mcpSpec, {
|
|
280
355
|
configuredServers: await deps.configuredServers(),
|
|
281
356
|
knownTools: await deps.serverKnownTools(),
|
|
282
|
-
serverStates:
|
|
357
|
+
serverStates: mcpServerStates,
|
|
283
358
|
});
|
|
284
|
-
skillsPlan = computeSkillsPlan(skillsSpec, await deps.knownSkillKeys(),
|
|
359
|
+
skillsPlan = computeSkillsPlan(skillsSpec, await deps.knownSkillKeys(), skillSources);
|
|
285
360
|
}
|
|
286
361
|
catch (e) {
|
|
287
362
|
return { ok: false, error: `读取运行时状态失败(未改动任何东西):${msg(e)}` };
|
|
@@ -318,9 +393,9 @@ export function createArchiveEngine(deps) {
|
|
|
318
393
|
}
|
|
319
394
|
// 快照**恒拍**:三个域都按「与勾选集完全一致」应用(未定义 = 全关),所以任何场景
|
|
320
395
|
// 进入都可能改动运行时(哪怕只是停掉几台服务器 / 几个技能),退出都得能精确还原。
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
|
|
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);
|
|
324
399
|
const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
|
|
325
400
|
try {
|
|
326
401
|
await deps.saveSlice({ ...slice, mode: entered.mode });
|
package/lib/rules/archive.js
CHANGED
|
@@ -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,16 +190,19 @@ export function mergeSnapshotSwitches(snapshot, mcpServers = [], skillSources =
|
|
|
189
190
|
};
|
|
190
191
|
}
|
|
191
192
|
export function snapshotRuntime(mcpRaw, skills,
|
|
192
|
-
/**
|
|
193
|
-
mcpServers = [],
|
|
194
|
-
/** **将被档案改动**的来源,带改动前的 `enabled`。 */
|
|
195
|
-
skillSources = [],
|
|
193
|
+
/** 上层两行:进场景前**每一行**的原值(全量;退出按「现状 ≠ 原值」回写)。 */
|
|
194
|
+
mcpServers = [], skillSources = [],
|
|
196
195
|
/** **将被档案启用**的人设名(改动前停用的子集;退出时按此停回)。 */
|
|
197
196
|
subagents = [],
|
|
198
197
|
/** **将被档案停用**的人设名(改动前启用的子集;退出时按此重新打开)。 */
|
|
199
198
|
subagentsOn = [],
|
|
200
199
|
/** **将被档案改动**的备注行,带改动前的备注(null = 原本没有)。 */
|
|
201
|
-
mcpNotes = []
|
|
200
|
+
mcpNotes = [],
|
|
201
|
+
/**
|
|
202
|
+
* 进入模式时**全部**人设的开关状态(名字 → 是否开着)。给了就写进 `subagentsAll`:
|
|
203
|
+
* 退出时按它精确还原(场景里手动开过的人设也会被还原回进场景前的状态)。
|
|
204
|
+
*/
|
|
205
|
+
subagentStates = null) {
|
|
202
206
|
return {
|
|
203
207
|
mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
|
|
204
208
|
skills: { ...skills },
|
|
@@ -206,6 +210,7 @@ mcpNotes = []) {
|
|
|
206
210
|
skillSources: skillSources.map((x) => ({ root: x.root, enabled: x.enabled })),
|
|
207
211
|
...(subagents.length ? { subagents: subagents.slice() } : {}),
|
|
208
212
|
...(subagentsOn.length ? { subagentsOn: subagentsOn.slice() } : {}),
|
|
213
|
+
...(subagentStates ? { subagentsAll: { ...subagentStates } } : {}),
|
|
209
214
|
...(mcpNotes.length ? { mcpNotes: mcpNotes.map((x) => ({ id: x.id, note: x.note })) } : {}),
|
|
210
215
|
};
|
|
211
216
|
}
|