dsh-plugin-tool-management 0.6.0 → 0.8.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +117 -221
  3. package/README_EN.md +116 -220
  4. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  5. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  6. package/docs/images/2MCP.png +0 -0
  7. package/docs/images/2MCP_en.png +0 -0
  8. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  10. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  12. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  14. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  16. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  18. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  20. package/docs/update.md +56 -38
  21. package/lib/agents-md/service.js +84 -5
  22. package/lib/client.js +1488 -475
  23. package/lib/compat/preset-reach.js +425 -0
  24. package/lib/compat/probe.js +8 -0
  25. package/lib/http-fence.js +21 -0
  26. package/lib/hub.js +160 -2
  27. package/lib/index.js +687 -47
  28. package/lib/mcp/override-blocks.js +195 -0
  29. package/lib/mcp/state-section.js +90 -0
  30. package/lib/prompt-sections.js +148 -0
  31. package/lib/rules/archive-engine.js +99 -29
  32. package/lib/rules/archive.js +56 -28
  33. package/lib/rules/service.js +332 -35
  34. package/lib/skills/core.js +15 -1
  35. package/lib/skills/service.js +3 -0
  36. package/lib/subagents/catalog.js +89 -0
  37. package/lib/subagents/service.js +384 -41
  38. package/lib/subagents/tools.js +18 -5
  39. package/package.json +97 -96
  40. package/screenshots.json +10 -10
  41. package/docs/Changelog.md +0 -144
  42. package/lib/rules/provider.js +0 -121
@@ -0,0 +1,195 @@
1
+ // 补丁文件(cordis.patch.yml)里的「启停覆盖块」识别与收敛计划。
2
+ //
3
+ // 同一个 loader id 的启停状态有两种写法:insert 行自带的 `disabled:`,以及顶层
4
+ // `- id: xxx` + `disabled:` 的独立覆盖块。补丁按顺序应用、**last-wins**
5
+ // (见 @deepseek-ai/dsh-app-boot 的 applyEntryPatches:`target[key] = value`),
6
+ // 所以覆盖块可以无限追加而每次都不报错 —— 代价是文件越用越长,生效值只能靠
7
+ // "最后一条声明了 disabled 的块"推断。
8
+ //
9
+ // 这里只回答一个问题:要收敛的话,删哪些行区间**且生效值保证不变**。
10
+ //
11
+ // ⚠ 这个模块第一版出过一次事故:调用方把 `parseRows()` 合并覆盖块之后的**生效值**
12
+ // 当基准传进来,而生效值恒等于 decider 的值 —— 于是"decider 的值 == 基准"永远成立,
13
+ // 每个 decider 都被当成 no-op 删掉,6 台 MCP 全部被启用。为了从结构上杜绝这种传错,
14
+ // 现在的接口是:`planOverrideCompaction(content, otherFileContents)` —— 基准只能由
15
+ // 本模块自己从 **insert 行**读(`insertBaseRows`),调用方只能再补"另一份补丁文件的
16
+ // 内容",不能传任何现成的状态值进来。
17
+ /**
18
+ * 顶层块扫描:每个 `- ` 开头的行都是新块的开始,块体延伸到下一个 `- ` 行之前。
19
+ *
20
+ * `end` 剔除块尾的注释/空行 —— 那些行属于"块与块之间",其中就包括下一个块自己的
21
+ * `# dsh-…:disable:<id>` 标记注释。带上它们的话,删一个块会把下一个块的注释一起带走。
22
+ */
23
+ export function scanBlockRanges(lines) {
24
+ const out = [];
25
+ let current = null;
26
+ const flush = () => {
27
+ if (!current)
28
+ return;
29
+ let end = current.start + current.lines.length;
30
+ while (end > current.start + 1 && (lines[end - 1].trim() === '' || /^\s*#/.test(lines[end - 1])))
31
+ end--;
32
+ out.push({ start: current.start, end, text: current.lines.slice(0, end - current.start).join('\n') });
33
+ current = null;
34
+ };
35
+ for (let i = 0; i < lines.length; i++) {
36
+ if (/^- /.test(lines[i])) {
37
+ flush();
38
+ current = { start: i, lines: [lines[i]] };
39
+ }
40
+ else if (current)
41
+ current.lines.push(lines[i]);
42
+ }
43
+ flush();
44
+ return out;
45
+ }
46
+ /**
47
+ * 读一个覆盖块三件事:id、disabled、是不是"纯块"。
48
+ *
49
+ * 只看**顶层/一层缩进**的锚定写法(`- id:`、`name:`、`disabled:`):生成器写出来的
50
+ * 就是这几行,而 config 里的同名键缩进更深 —— 万一手工把 `disabled` 藏进 config,
51
+ * 这里会判成"非纯块"(保留不删),保守方向是对的。
52
+ *
53
+ * 不筛 `name:` —— 补丁是按 **loader id** 生效的,别的模块名写同一个 id 时改的仍是
54
+ * 那个条目;真正决定"能不能删"的是 pure 与 decider 两条规则,不是名字。
55
+ */
56
+ export function readToggleEntry(lines) {
57
+ let id;
58
+ let disabled;
59
+ let pure = true;
60
+ for (const raw of lines) {
61
+ const line = raw.replace(/\s+$/, '');
62
+ if (!line.trim() || line.trimStart().startsWith('#'))
63
+ continue;
64
+ const head = line.match(/^- id:\s*(\S+)\s*$/);
65
+ if (head) {
66
+ id = head[1];
67
+ continue;
68
+ }
69
+ if (/^\s{2,}name:\s*\S+\s*$/.test(line))
70
+ continue;
71
+ const flag = line.match(/^\s{2,}disabled:\s*(true|false)\s*$/);
72
+ if (flag) {
73
+ disabled = flag[1] === 'true';
74
+ continue;
75
+ }
76
+ pure = false;
77
+ }
78
+ return { id, disabled, pure };
79
+ }
80
+ /** 覆盖块自己的标记注释(紧邻上方的 `# …:disable|enable:<id>`),删块时一起删。 */
81
+ export function markerLinesAbove(lines, start) {
82
+ let i = start;
83
+ while (i > 0 && /^# (?:dsh-plugin-tool-management|dsh-mcp-manager):(?:disable|enable):/.test(lines[i - 1]))
84
+ i--;
85
+ return i;
86
+ }
87
+ /** 文件里所有顶层启停覆盖块(按出现顺序)。 */
88
+ export function scanOverrideBlocks(content) {
89
+ const lines = content.split(/\r?\n/);
90
+ const out = [];
91
+ for (const block of scanBlockRanges(lines)) {
92
+ if (/^- insert:/.test(lines[block.start]))
93
+ continue;
94
+ const entry = readToggleEntry(lines.slice(block.start, block.end));
95
+ if (!entry.id)
96
+ continue;
97
+ out.push({ ...block, id: entry.id, disabled: entry.disabled, pure: entry.pure });
98
+ }
99
+ return out;
100
+ }
101
+ /**
102
+ * **insert 行自带的** 启停基准(`- insert:` 里每个子条目的 `disabled`,缺失 = 启用)。
103
+ *
104
+ * 这是收敛判定的唯一合法基准来源:绝不能用 `parseRows()` 那种"合并覆盖块之后的生效值",
105
+ * 因为生效值就等于最后一条覆盖块的值,"覆盖块的值 == 基准"会恒成立,decider 会被全删。
106
+ */
107
+ export function insertBaseRows(content) {
108
+ const lines = content.split(/\r?\n/);
109
+ const out = [];
110
+ for (const block of scanBlockRanges(lines)) {
111
+ if (!/^- insert:/.test(lines[block.start]))
112
+ continue;
113
+ let id;
114
+ let disabled;
115
+ const flush = () => { if (id)
116
+ out.push({ id, disabled }); };
117
+ for (const line of block.text.split('\n')) {
118
+ const child = line.match(/^ {4}- id:\s*(\S+)\s*$/);
119
+ if (child) {
120
+ flush();
121
+ id = child[1];
122
+ disabled = undefined;
123
+ continue;
124
+ }
125
+ if (!id)
126
+ continue;
127
+ const flag = line.match(/^\s+disabled:\s*(true|false)\s*$/);
128
+ if (flag)
129
+ disabled = flag[1] === 'true';
130
+ }
131
+ flush();
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * 收敛计划:算出"删掉也不改变生效状态"的覆盖块行区间。
137
+ *
138
+ * 为什么除最后一条外都能删:补丁按顺序应用,同一 id 的多个覆盖块 last-wins,真正
139
+ * 决定生效值的只有**最后一个声明了 disabled 的块**(decider)。于是:
140
+ * - decider 之前的纯启停块 → 删(被 decider 盖住);
141
+ * - decider 本身 → 仅当它与 insert 行的 disabled 相同(纯 no-op)**且基准已知**时删;
142
+ * - decider 之后的块 → 留着(它们不声明 disabled,可能改的是别的东西);
143
+ * - 带 config 的非纯块 → 一律留着(可能承载手工覆盖内容)。
144
+ *
145
+ * "基准未知"为什么要单列:覆盖块可以打在**补丁文件之外**定义的条目上(profile 的
146
+ * cordis.yml 原生行,或另一份补丁文件的 insert 行)。此时"删掉这条 disabled: false"
147
+ * 完全可能把它翻回原生状态的 disabled: true —— 所以基准未知时只删被 decider 盖住的
148
+ * 那些块,绝不删 decider 自己。
149
+ *
150
+ * @param content - 补丁文件全文。
151
+ * @param otherFileContents - 同一 id 的 insert 行**可能坐在里面**的其它补丁文件全文
152
+ * (项目级与全局两份互查)。只用于补基准,绝不用来推断本文件要删什么。
153
+ * @returns `ranges` 为要删除的行区间;`ids` 为逐 id 统计。
154
+ */
155
+ export function planOverrideCompaction(content, otherFileContents = []) {
156
+ const lines = content.split(/\r?\n/);
157
+ const byId = new Map();
158
+ for (const block of scanOverrideBlocks(content)) {
159
+ const list = byId.get(block.id);
160
+ if (list)
161
+ list.push(block);
162
+ else
163
+ byId.set(block.id, [block]);
164
+ }
165
+ const baseOf = new Map();
166
+ for (const row of insertBaseRows(content))
167
+ baseOf.set(row.id, row.disabled === true);
168
+ for (const other of otherFileContents) {
169
+ for (const row of insertBaseRows(other))
170
+ if (!baseOf.has(row.id))
171
+ baseOf.set(row.id, row.disabled === true);
172
+ }
173
+ const ranges = [];
174
+ const ids = {};
175
+ for (const [id, list] of byId) {
176
+ const baseKnown = baseOf.has(id);
177
+ const base = baseOf.get(id) === true;
178
+ let decider = -1;
179
+ for (let i = 0; i < list.length; i++)
180
+ if (list[i].disabled !== undefined)
181
+ decider = i;
182
+ const doomed = [];
183
+ if (decider >= 0) {
184
+ for (let i = 0; i < decider; i++)
185
+ if (list[i].pure)
186
+ doomed.push(list[i]);
187
+ if (baseKnown && list[decider].pure && list[decider].disabled === base)
188
+ doomed.push(list[decider]);
189
+ }
190
+ ids[id] = { total: list.length, dropped: doomed.length };
191
+ for (const block of doomed)
192
+ ranges.push([markerLinesAbove(lines, block.start), block.end]);
193
+ }
194
+ return { ranges, ids };
195
+ }
@@ -0,0 +1,90 @@
1
+ /** 段里最多列几台 server;超出部分只报数量。 */
2
+ export const DEFAULT_MCP_MAX_ENTRIES = 50;
3
+ /** 备注截断长度。备注是自由文本,界面侧另有 maxLength(500) 兜底,段侧再截一刀。 */
4
+ export const DEFAULT_MCP_NOTE_MAX_LENGTH = 200;
5
+ /**
6
+ * 备注的固定前缀。
7
+ *
8
+ * 备注是自由文本,注入后以**系统提示词的身份**生效。若用户写了指令式内容
9
+ * (如「忽略以上全部规则」),会被当作系统级指令执行。威胁模型上是**自伤**而非被攻击
10
+ * (内容由用户自己写、仅本机可写),但加固定前缀能降低被读成指令的概率。
11
+ */
12
+ export const MCP_NOTE_PREFIX = '用户备注:';
13
+ /** 备注压成单行(换行 → 空格)并截断。 */
14
+ function normalizeNote(value, maxLength) {
15
+ const flat = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
16
+ if (!flat)
17
+ return '';
18
+ return flat.length <= maxLength ? flat : `${flat.slice(0, maxLength - 3)}...`;
19
+ }
20
+ /**
21
+ * 渲染 MCP 状态段(纯函数,便于单独推理)。
22
+ *
23
+ * 返回 `''` 表示不注入 —— 没有可用 server 的用户零 token 成本。
24
+ */
25
+ export function renderMcpStateSection(rows, opts = {}) {
26
+ const maxEntries = opts.maxEntries ?? DEFAULT_MCP_MAX_ENTRIES;
27
+ const noteMaxLength = opts.noteMaxLength ?? DEFAULT_MCP_NOTE_MAX_LENGTH;
28
+ // 「当前真正可用」= 已启用 且 有工具。任一不满足都不进段。
29
+ const usable = rows.filter((r) => !r.disabled && Number(r.toolCount) > 0);
30
+ if (!usable.length)
31
+ return '';
32
+ // 按展示名排序:即使补丁文件里的行序变化,段文本也保持逐字节稳定(前缀缓存契约)。
33
+ const sorted = [...usable].sort((a, b) => {
34
+ const x = String(a.serverName ?? '');
35
+ const y = String(b.serverName ?? '');
36
+ return x < y ? -1 : x > y ? 1 : 0;
37
+ });
38
+ const shown = sorted.slice(0, Math.max(0, maxEntries));
39
+ const lines = shown.map((r) => {
40
+ const count = Number(r.toolCount) || 0;
41
+ const head = '- **' + String(r.serverName ?? r.id) + '**(' + count + ' 个工具)';
42
+ const note = normalizeNote(r.notes, noteMaxLength);
43
+ return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
44
+ });
45
+ const out = ['## MCP 服务器', '', ...lines];
46
+ const hidden = sorted.length - shown.length;
47
+ if (hidden > 0)
48
+ out.push('', `(另有 ${hidden} 台未列出。)`);
49
+ return out.join('\n');
50
+ }
51
+ /** 创建 SWR 的 MCP 状态目录。计算失败时保留上一次的值,不把段清空。 */
52
+ export function createMcpStateCatalog(deps, opts = {}) {
53
+ const ttlMs = opts.ttlMs ?? 3000;
54
+ const now = opts.now ?? (() => Date.now());
55
+ let value = '';
56
+ let loadedAt = Number.NEGATIVE_INFINITY;
57
+ let inflight = null;
58
+ const recompute = async () => {
59
+ try {
60
+ const result = await deps.rows();
61
+ if (result && result.ok !== false) {
62
+ value = renderMcpStateSection(result.rows ?? [], opts);
63
+ }
64
+ }
65
+ catch { /* 保留上一次的值;首次失败则维持 '' */ }
66
+ loadedAt = now();
67
+ };
68
+ const revalidate = () => {
69
+ if (inflight)
70
+ return inflight;
71
+ inflight = recompute().finally(() => { inflight = null; });
72
+ return inflight;
73
+ };
74
+ return {
75
+ text: () => {
76
+ if (now() - loadedAt > ttlMs)
77
+ void revalidate();
78
+ return value;
79
+ },
80
+ refresh: () => {
81
+ loadedAt = Number.NEGATIVE_INFINITY;
82
+ return revalidate();
83
+ },
84
+ warm: () => revalidate(),
85
+ };
86
+ }
87
+ /** 供 `createPromptSectionRegistrar` 使用的段声明。 */
88
+ export function mcpStateSectionOf(catalog, order) {
89
+ return { name: 'tool-management:mcp-state', order, text: () => catalog.text() };
90
+ }
@@ -0,0 +1,148 @@
1
+ // dsh-plugin-tool-management —— systemPrompt 段注册(通用骨架)。
2
+ //
3
+ // 本模块是「把插件状态投影为 system prompt 段(自动在场)」的**唯一实现**。
4
+ // 场景记忆段(src/rules/service.ts)、子智能体人设段、MCP 状态段
5
+ // (src/index.ts)都经它注册,避免每个域各抄一份 scope 遍历逻辑。
6
+ //
7
+ // 官方接口语义(@deepseek-ai/dsh-system-prompt):
8
+ // - `section({ name, order, text })` 在**调用者的 scope** 注册;scoped 段遮蔽同名全局段。
9
+ // - `text` 可以是**每次装配时求值的 provider 函数** → 切换启用状态后**下一个请求即生效**,
10
+ // 无需重开会话、无需重载插件。
11
+ // - `renderPrompt` 会**删除空段** → 无内容时返回 `''`,不产生空标题、零 token 成本。
12
+ //
13
+ // 为什么每个段都注册两处(全局 + per-agent):
14
+ // 全局注册覆盖所有 agent(含子 agent);per-agent 注册保证即使插件 ctx 自带 scope
15
+ // (全局段对某些 agent 不可见)也能命中 —— 两者同名,scoped 遮蔽全局,不会重复注入。
16
+ //
17
+ // 缓存契约(§5.2):段文本必须**会话级恒定**(只由持久化配置与文件内容决定),
18
+ // 因此前缀逐字节稳定 → 前缀缓存可命中;**禁止**在段里放时间戳/计数/相对时间。
19
+ // 异步数据源(人设目录、MCP 清单)走 stale-while-revalidate:`text()` 同步返回缓存值,
20
+ // 由调用方在写操作后触发重算,绝不在 `text()` 里发起 IO。
21
+ const GLOBAL_KEY = '@global';
22
+ /** 复合 key 的分隔符:NUL 不会出现在 agent id 或段名里。 */
23
+ const KEY_SEP = '\u0000';
24
+ /** 取 scope 上的 systemPrompt 服务(Cordis 的 ctx.get 可能抛错,全部吞掉)。 */
25
+ function systemPromptOf(scope) {
26
+ if (!scope)
27
+ return undefined;
28
+ try {
29
+ if (typeof scope.get === 'function')
30
+ return scope.get('systemPrompt');
31
+ return scope.systemPrompt;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /**
38
+ * 注册一组 systemPrompt 段:全局 scope + 每个活动 agent 的 scope。
39
+ *
40
+ * 任一 scope / 任一段注册失败都只降级为"该段不注入",不影响其余段与其余 scope。
41
+ */
42
+ export function createPromptSectionRegistrar(ctx, sections) {
43
+ const registrations = new Map();
44
+ const install = (scope, scopeKey) => {
45
+ if (!scope)
46
+ return;
47
+ const systemPrompt = systemPromptOf(scope);
48
+ if (!systemPrompt || typeof systemPrompt.section !== 'function')
49
+ return;
50
+ for (const spec of sections) {
51
+ const key = scopeKey + KEY_SEP + spec.name;
52
+ if (registrations.has(key))
53
+ continue;
54
+ try {
55
+ const dispose = systemPrompt.section({
56
+ name: spec.name,
57
+ order: spec.order,
58
+ // 每次装配重读 → 切换启用状态后下一个请求即生效。
59
+ // 包一层箭头函数:spec.text 自身若被替换(热更新)也能跟上。
60
+ text: () => spec.text(),
61
+ });
62
+ registrations.set(key, { scope, dispose: typeof dispose === 'function' ? dispose : () => { } });
63
+ }
64
+ catch (e) {
65
+ // 同层重名注册会抛错:降级为"该段不注入",不影响其余 scope 与其余段。
66
+ console.error(`[dsh-plugin-tool-management] ${spec.name} section registration failed:`, String((e && e.message) || e));
67
+ }
68
+ }
69
+ };
70
+ const uninstall = (scopeKey) => {
71
+ const prefix = scopeKey + KEY_SEP;
72
+ for (const key of [...registrations.keys()]) {
73
+ if (!key.startsWith(prefix))
74
+ continue;
75
+ const entry = registrations.get(key);
76
+ registrations.delete(key);
77
+ try {
78
+ entry?.dispose();
79
+ }
80
+ catch { /* ignore */ }
81
+ }
82
+ };
83
+ // ① 全局:插件 ctx 的 scope(覆盖所有 agent)
84
+ install(ctx, GLOBAL_KEY);
85
+ // ② per-agent:与既有 install/uninstall 生命周期一致(agent/created、agent/disposed)
86
+ const on = ctx && ctx.on;
87
+ let stopCreated;
88
+ let stopDisposed;
89
+ if (typeof on === 'function') {
90
+ stopCreated = on('agent/created', (payload) => {
91
+ const agent = payload && payload.agent;
92
+ if (agent && agent.id != null)
93
+ install(agent.ctx, String(agent.id));
94
+ });
95
+ stopDisposed = on('agent/disposed', (payload) => {
96
+ const agent = payload && payload.agent;
97
+ if (agent && agent.id != null)
98
+ uninstall(String(agent.id));
99
+ });
100
+ }
101
+ // 已存在的 agent(插件热加载场景):补注册。
102
+ try {
103
+ const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined;
104
+ if (agents && typeof agents.list === 'function') {
105
+ for (const agent of agents.list()) {
106
+ if (agent && agent.id != null)
107
+ install(agent.ctx, String(agent.id));
108
+ }
109
+ }
110
+ }
111
+ catch { /* agents 服务不可用 → 仅靠事件 */ }
112
+ return {
113
+ invalidate: () => {
114
+ for (const entry of registrations.values()) {
115
+ try {
116
+ entry.scope?.emit?.('system-prompt/change');
117
+ }
118
+ catch { /* 宿主无该事件 → 忽略 */ }
119
+ }
120
+ },
121
+ dispose: () => {
122
+ if (typeof stopCreated === 'function')
123
+ stopCreated();
124
+ if (typeof stopDisposed === 'function')
125
+ stopDisposed();
126
+ for (const key of [...registrations.keys()]) {
127
+ const entry = registrations.get(key);
128
+ registrations.delete(key);
129
+ try {
130
+ entry?.dispose();
131
+ }
132
+ catch { /* ignore */ }
133
+ }
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * 官方 `SECTION_ORDERS` 里 2901–4999 是空档,本插件的段全部落在该区间。
139
+ * 数值本身无外部含义,只决定同一次装配里的相对顺序(小在前)。
140
+ */
141
+ export const SECTION_ORDER = {
142
+ /** 场景记忆(rules 服务)。 */
143
+ sceneMemory: 3000,
144
+ /** 子智能体人设目录。 */
145
+ subagents: 3010,
146
+ /** MCP 服务器状态与用户备注。 */
147
+ mcpState: 3020,
148
+ };
@@ -3,7 +3,7 @@
3
3
  // 退出 = 恢复快照 → 落盘自由模式;切换 = 先退后进。
4
4
  // 失败语义(fail-closed):任一步失败即反向恢复运行时并写回旧切片;回滚不全会如实写进错误文本。
5
5
  // v2:MCP 段两级(服务器勾选 + '*' / 工具明细),停用表存勾选集的补集,通配由 index.ts 的 guard/restrict 原生支持。
6
- import { computeMcpPlan, computeMemoriesPlan, computeSkillsPlan, normalizeArchive, snapshotRuntime, hasSection, } from './archive.js';
6
+ import { computeMcpPlan, computeSkillsPlan, normalizeArchive, snapshotRuntime, hasSection, } 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();
@@ -20,9 +20,25 @@ export function createArchiveEngine(deps) {
20
20
  * 技能只写快照列出的键(这是既有批量通道的语义):模式期间新增的技能保持现状。
21
21
  */
22
22
  async function restoreSnapshot(snapshot) {
23
+ // 顺序与进入时**相反**:先恢复服务器级 / 来源级,再恢复工具级 / 技能级 ——
24
+ // 来源还关着的时候写技能级策略会被吞掉(`skills/core.js` 的 sourceEnabled 判定)。
25
+ // 老 snapshot 没有这两栏 → `?? []`,按旧行为只恢复下层。
26
+ const servers = snapshot.mcpServers ?? [];
27
+ if (servers.length) {
28
+ await deps.applyMcpServerSwitches(servers.map((x) => ({ id: x.id, level: x.level, enabled: !x.disabled })));
29
+ }
30
+ const sources = snapshot.skillSources ?? [];
31
+ if (sources.length) {
32
+ await deps.applySkillSourceSwitches(sources.map((x) => ({ root: x.root, enabled: x.enabled })));
33
+ }
23
34
  const mcp = Object.fromEntries(Object.entries(snapshot.mcp).map(([k, v]) => [k, v.slice()]));
24
35
  await deps.applyMcpEntries(mcp);
25
36
  await deps.applySkills({ ...snapshot.skills });
37
+ // 子智能体开关:只停「进入时被这次启用过的」那些(老 snapshot 没有这栏 → [],按旧行为跳过)。
38
+ const personas = snapshot.subagents ?? [];
39
+ if (personas.length) {
40
+ await deps.applySubagentSwitches(personas.map((n) => ({ name: n, enabled: false })));
41
+ }
26
42
  }
27
43
  /**
28
44
  * 失败回滚:运行时还原 + 切片写回;每步失败都记下来,绝不谎报「已回滚」。
@@ -89,17 +105,11 @@ export function createArchiveEngine(deps) {
89
105
  if (hadKeys && archive.subagents.length === 0)
90
106
  delete archive.subagents;
91
107
  }
92
- if (archive.memories) {
93
- // 记忆段只影响投影(正文是否进 system prompt),不写任何运行时状态;
94
- // 这里只把已不存在的记忆 id 剔掉并上报,避免档案里留下指向空气的勾选。
95
- const known = await deps.knownMemoryIds();
96
- const hadKeys = archive.memories.length > 0;
97
- const { stale: memStale } = computeMemoriesPlan(archive.memories, known);
98
- stale.push(...memStale);
99
- archive.memories = archive.memories.filter((id) => known.has(id));
100
- if (hadKeys && archive.memories.length === 0)
101
- delete archive.memories;
102
- }
108
+ // 记忆段(P5 已废弃):不再写入、不再校验。记忆的开关是 `rules[*].enabled` 单一真相源,
109
+ // 老档案里残留的 `memories` 字段被忽略(不迁移、不删除,回滚代码时仍可读)。
110
+ // 保存档案时顺手把残留字段清掉,避免新旧语义并存造成误读。
111
+ if (archive.memories !== undefined)
112
+ delete archive.memories;
103
113
  const slice = await deps.loadSlice();
104
114
  if (Object.keys(archive).length === 0)
105
115
  delete slice.archives[scene];
@@ -147,10 +157,50 @@ export function createArchiveEngine(deps) {
147
157
  const archive = slice.archives[target];
148
158
  if (!(await deps.sceneExists(target)))
149
159
  return { ok: false, error: `场景不存在: ${target}` };
150
- if (!archive || (!hasSection(archive, 'mcp') && !hasSection(archive, 'skills'))) {
151
- return { ok: false, error: `场景「${target}」的档案没有 MCP / 技能段(仅记忆或仅子智能体的场景不需要进入模式)` };
160
+ // 无档案段(纯记忆 / 纯人设场景)**也要能进入**:不应用档案、不建快照,但
161
+ // `mode.scene` 与 `active` 照常写入。原因有二:
162
+ // ① 场景开关(P6)是唯一入口,纯记忆场景点开关不能报错;
163
+ // ② 顶部「当前模式」横幅的渲染条件是 `mode.scene`,不写就永远不显示 ——
164
+ // 用户裁定:无档案场景开开关也要显示横幅,副标题走 `scenes.mode.noProfile`。
165
+ const appliesArchive = !!archive && (hasSection(archive, 'mcp') || hasSection(archive, 'skills') || hasSection(archive, 'subagents'));
166
+ // 先算计划、再拍快照:快照只记**将被改动**的服务器行 / 来源 / 人设(带改动前的状态),
167
+ // 退出时按记录精确恢复 —— 不动用户手动设置的其他行。
168
+ let mcpPlan = null;
169
+ let skillsPlan = null;
170
+ if (appliesArchive && archive) {
171
+ try {
172
+ if (archive.mcp) {
173
+ mcpPlan = computeMcpPlan(archive.mcp, {
174
+ configuredServers: await deps.configuredServers(),
175
+ knownTools: await deps.serverKnownTools(),
176
+ serverStates: await deps.mcpServerStates(),
177
+ });
178
+ }
179
+ if (archive.skills) {
180
+ skillsPlan = computeSkillsPlan(archive.skills, await deps.knownSkillKeys(), await deps.skillSourceStates());
181
+ }
182
+ }
183
+ catch (e) {
184
+ return { ok: false, error: `读取运行时状态失败(未改动任何东西):${msg(e)}` };
185
+ }
152
186
  }
153
- const snapshot = snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills());
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)}` };
197
+ }
198
+ }
199
+ const snapshot = appliesArchive
200
+ ? snapshotRuntime(await deps.currentMcpRaw(), await deps.currentSkills(),
201
+ // 直接用计划带出的 `*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)
203
+ : null;
154
204
  const entered = { ...slice, mode: { scene: target, snapshot }, active: [target] };
155
205
  try {
156
206
  await deps.saveSlice({ ...slice, mode: entered.mode });
@@ -160,31 +210,51 @@ export function createArchiveEngine(deps) {
160
210
  }
161
211
  const stale = [];
162
212
  try {
163
- if (archive.mcp) {
164
- const plan = computeMcpPlan(archive.mcp, {
165
- configuredServers: await deps.configuredServers(),
166
- knownTools: await deps.serverKnownTools(),
167
- });
168
- stale.push(...plan.stale);
169
- await deps.applyMcpEntries(plan.entries);
213
+ // ① 先切**上层**(服务器级 / 来源级)——下层只有在上层开着时才有意义:
214
+ // 服务器没起来,它的工具不存在,工具级停用无从谈起;来源关着,技能级 enable 会被吞掉。
215
+ if (mcpPlan && mcpPlan.serverSwitches.length) {
216
+ await deps.applyMcpServerSwitches(mcpPlan.serverSwitches.map((s) => ({ id: s.id, level: s.level, enabled: s.enabled })));
217
+ }
218
+ if (skillsPlan && skillsPlan.sourceSwitches.length) {
219
+ await deps.applySkillSourceSwitches(skillsPlan.sourceSwitches.map((s) => ({ root: s.root, enabled: s.enabled })));
220
+ }
221
+ // ② 再写**下层**(工具级停用表 / 技能级策略)。
222
+ if (mcpPlan) {
223
+ stale.push(...mcpPlan.stale);
224
+ await deps.applyMcpEntries(mcpPlan.entries);
225
+ }
226
+ if (skillsPlan) {
227
+ stale.push(...skillsPlan.stale);
228
+ await deps.applySkills(skillsPlan.target);
170
229
  }
171
- if (archive.skills) {
172
- const plan = computeSkillsPlan(archive.skills, await deps.knownSkillKeys());
173
- stale.push(...plan.stale);
174
- await deps.applySkills(plan.target);
230
+ // ③ 子智能体开关:把档案勾选的人设启用(子智能体页与目录段同步亮起)。
231
+ if (boundPersonas.length) {
232
+ await deps.applySubagentSwitches(boundPersonas.map((n) => ({ name: n, enabled: true })));
175
233
  }
176
234
  }
177
235
  catch (e) {
178
- return await rollback(slice, snapshot, '应用档案', e);
236
+ // 无档案段时没有运行时改动可回滚(snapshot 为 null)。
237
+ return snapshot ? await rollback(slice, snapshot, '应用档案', e) : { ok: false, error: `应用档案失败:${String(e?.message || e)}` };
179
238
  }
180
239
  // ③ 记忆启用集收窄为 {S}(`_shared` 由 resolveActiveScenes 恒常加入;退出不恢复)。
181
240
  try {
182
241
  await deps.saveSlice(entered);
183
242
  }
184
243
  catch (e) {
185
- return await rollback(slice, snapshot, '记忆收窄落盘', e);
244
+ return snapshot ? await rollback(slice, snapshot, '记忆收窄落盘', e) : { ok: false, error: `记忆收窄落盘失败:${String(e?.message || e)}` };
186
245
  }
187
- return { ok: true, mode: entered.mode, applied: { mcp: !!archive.mcp, skills: !!archive.skills }, stale, narrowedTo: [target] };
246
+ return {
247
+ ok: true,
248
+ mode: entered.mode,
249
+ applied: { mcp: !!(archive && archive.mcp), skills: !!(archive && archive.skills), subagents: !!(archive && archive.subagents) },
250
+ // 上层实际切换了几个(0 = 本来就已经是目标状态,界面不必提示"已停用 N 台")。
251
+ switched: {
252
+ mcpServers: mcpPlan ? mcpPlan.serverSwitches.length : 0,
253
+ skillSources: skillsPlan ? skillsPlan.sourceSwitches.length : 0,
254
+ },
255
+ stale,
256
+ narrowedTo: [target],
257
+ };
188
258
  }),
189
259
  };
190
260
  return { ops, writeOps: new Set(['scene-archive-save', 'scene-mode-set']) };