dsh-plugin-tool-management 0.7.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.
- package/CHANGELOG.md +48 -0
- package/README.md +16 -5
- package/README_EN.md +16 -5
- 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/docs/update.md +17 -0
- package/lib/agents-md/service.js +41 -3
- package/lib/client.js +820 -375
- package/lib/compat/preset-reach.js +1 -1
- package/lib/compat/probe.js +8 -0
- package/lib/index.js +433 -10
- package/lib/mcp/state-section.js +90 -0
- package/lib/prompt-sections.js +148 -0
- package/lib/rules/archive-engine.js +99 -29
- package/lib/rules/archive.js +56 -28
- package/lib/rules/service.js +259 -39
- package/lib/skills/core.js +4 -1
- package/lib/subagents/catalog.js +89 -0
- package/lib/subagents/service.js +189 -20
- package/lib/subagents/tools.js +10 -3
- package/package.json +97 -97
- package/lib/rules/provider.js +0 -121
|
@@ -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,
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
await deps.
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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 {
|
|
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']) };
|
package/lib/rules/archive.js
CHANGED
|
@@ -94,20 +94,63 @@ export function computeMcpPlan(mcp, input) {
|
|
|
94
94
|
if (known.indexOf(t) < 0)
|
|
95
95
|
stale.push('mcp/' + server + '/' + t);
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
// 服务器级双向切换:勾选集 = 该场景下的**服务器启停配置**。
|
|
98
|
+
// 勾了 → 启用;没勾 → 停用。只推入状态需要变化的行。
|
|
99
|
+
const serverSwitches = [];
|
|
100
|
+
for (const state of input.serverStates || []) {
|
|
101
|
+
if (!configured.has(state.serverName))
|
|
102
|
+
continue;
|
|
103
|
+
const enabled = mcp[state.serverName] !== undefined;
|
|
104
|
+
if (state.disabled === !enabled)
|
|
105
|
+
continue; // 已经是目标状态
|
|
106
|
+
serverSwitches.push({ id: state.id, serverName: state.serverName, level: state.level, enabled, disabledBefore: state.disabled });
|
|
107
|
+
}
|
|
108
|
+
return { entries, wildcards, stale, serverSwitches };
|
|
98
109
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
110
|
+
/**
|
|
111
|
+
* 技能段应用计划:已知技能全集 → 勾选集决定启停。
|
|
112
|
+
*
|
|
113
|
+
* `sourceStates` 传了才会产出 `sourceSwitches`(来源级双向切换);不传 = 只做技能级。
|
|
114
|
+
*/
|
|
115
|
+
export function computeSkillsPlan(skills, knownSkillKeys, sourceStates) {
|
|
101
116
|
const target = {};
|
|
102
117
|
for (const key of knownSkillKeys)
|
|
103
118
|
target[key] = skills.indexOf(key) >= 0;
|
|
104
119
|
const stale = skills.filter((k) => !knownSkillKeys.has(k)).map((k) => 'skills/' + k);
|
|
105
|
-
|
|
120
|
+
const sourceSwitches = [];
|
|
121
|
+
if (sourceStates) {
|
|
122
|
+
// 该来源下有没有被勾选的技能(只看已知技能,避免 stale 项把来源"点亮")。
|
|
123
|
+
const checked = new Set(skills);
|
|
124
|
+
const onRoots = new Set();
|
|
125
|
+
for (const key of knownSkillKeys) {
|
|
126
|
+
if (!checked.has(key))
|
|
127
|
+
continue;
|
|
128
|
+
const i = key.indexOf('/');
|
|
129
|
+
if (i > 0)
|
|
130
|
+
onRoots.add(key.slice(0, i));
|
|
131
|
+
}
|
|
132
|
+
for (const state of sourceStates) {
|
|
133
|
+
const enabled = onRoots.has(state.root);
|
|
134
|
+
if (state.enabled === enabled)
|
|
135
|
+
continue;
|
|
136
|
+
sourceSwitches.push({ root: state.root, enabled, enabledBefore: state.enabled });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { target, stale, sourceSwitches };
|
|
106
140
|
}
|
|
107
|
-
export function snapshotRuntime(mcpRaw, skills
|
|
141
|
+
export function snapshotRuntime(mcpRaw, skills,
|
|
142
|
+
/** **将被档案改动**的服务器行,带改动前的 `disabled`。 */
|
|
143
|
+
mcpServers = [],
|
|
144
|
+
/** **将被档案改动**的来源,带改动前的 `enabled`。 */
|
|
145
|
+
skillSources = [],
|
|
146
|
+
/** **将被档案启用**的人设名(改动前停用的子集;退出时按此停回)。 */
|
|
147
|
+
subagents = []) {
|
|
108
148
|
return {
|
|
109
149
|
mcp: Object.fromEntries(Object.entries(mcpRaw).map(([k, v]) => [k, v.slice()])),
|
|
110
150
|
skills: { ...skills },
|
|
151
|
+
mcpServers: mcpServers.map((x) => ({ id: x.id, level: x.level, disabled: x.disabled })),
|
|
152
|
+
skillSources: skillSources.map((x) => ({ root: x.root, enabled: x.enabled })),
|
|
153
|
+
...(subagents.length ? { subagents: subagents.slice() } : {}),
|
|
111
154
|
};
|
|
112
155
|
}
|
|
113
156
|
// ── 记忆勾选(v3)──────────────────────────────────────────────────────────
|
|
@@ -115,26 +158,11 @@ export function snapshotRuntime(mcpRaw, skills) {
|
|
|
115
158
|
// 记忆段与其余三段有一处关键差别:MCP / 技能是**运行时启停**(改了要落表、进/退模式要回滚),
|
|
116
159
|
// 记忆只是**投影**(正文进 system prompt)。所以记忆段不产生"应用计划"、不进模式快照,
|
|
117
160
|
// 而是在渲染时被查询——勾选后下一个请求即生效,退出模式无需回滚任何东西。
|
|
118
|
-
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
* 这里只为把两种"不注入"的原因分开,便于体检报告区分)。
|
|
127
|
-
*/
|
|
128
|
-
export function memoryAllowed(archives, scene, id) {
|
|
129
|
-
const archive = archives ? archives[scene] : undefined;
|
|
130
|
-
if (!archive || archive.memories === undefined)
|
|
131
|
-
return true;
|
|
132
|
-
return archive.memories.indexOf(id) >= 0;
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* 记忆段的 stale 上报(档案保存时调用):段里引用了已经不存在的记忆 id。
|
|
136
|
-
* 记忆段不写任何运行时状态,因此除 stale 外没有别的计划产物。
|
|
137
|
-
*/
|
|
138
|
-
export function computeMemoriesPlan(memories, knownMemoryIds) {
|
|
139
|
-
return { stale: memories.filter((id) => !knownMemoryIds.has(id)).map((id) => 'memories/' + id) };
|
|
140
|
-
}
|
|
161
|
+
// P5(2026-09-15)已删除 `memoryAllowed` 与 `computeMemoriesPlan`:
|
|
162
|
+
// 记忆的注入条件改为**单一真相源** `rules[*].enabled`,场景档案不再持有 `memories` 段。
|
|
163
|
+
// 原因是「两个开关串联 + 两步操作」——用户必须先建段、再逐条勾,而且记忆页与档案页
|
|
164
|
+
// 各显示一半状态,看起来永远对不上。现在档案弹窗里的记忆勾选直接读写 `enabled`,
|
|
165
|
+
// 与记忆页同一个写入口,天然一致。
|
|
166
|
+
//
|
|
167
|
+
// 老档案兼容:`archives[*].memories` 字段被**忽略**,不做自动迁移。影响可控 ——
|
|
168
|
+
// 段内记忆的 `enabled` 通常本就是 true,忽略后行为不变。
|