dsh-plugin-tool-management 0.14.0 → 0.15.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 +560 -519
- 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 +391 -365
- package/lib/audit-log.js +157 -0
- package/lib/client.js +3800 -2504
- package/lib/compat/patch-dialect.js +3 -1
- package/lib/compat/preset-reach.js +37 -6
- package/lib/compat/probe.js +149 -7
- package/lib/context-inject.js +5 -3
- package/lib/index.js +236 -30
- package/lib/mcp/loader-token.js +53 -28
- package/lib/mcp/manager.js +146 -7
- package/lib/memories/archive-engine.js +70 -2
- package/lib/memories/archive.js +8 -1
- package/lib/memories/index-io.js +6 -0
- package/lib/op-registry.js +18 -4
- package/lib/ops/compat.js +109 -35
- package/lib/ops/scene-records.js +34 -0
- package/lib/ops/scene-sync.js +12 -1
- package/lib/ops/sessions.js +23 -8
- package/lib/ops/snapshot.js +596 -0
- package/lib/scenes/candidates.js +10 -5
- package/lib/sessions/bridge.js +5 -0
- package/lib/sessions/workspace.js +17 -5
- package/lib/skills/core.js +12 -3
- package/lib/tools/deps.js +1 -1
- package/lib/tools/table.js +5 -1
- package/package.json +112 -108
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
// 整机迁移快照(0.15.0 C2)—— 一键带走全部配置,换机器 / 重装后一键放回来。
|
|
2
|
+
//
|
|
3
|
+
// 与另外两条出口的定位区分(用户裁定):
|
|
4
|
+
// · `mcpm-export` 只带 MCP 一个域;
|
|
5
|
+
// · 「兼容」页的 patch 备份是**本机故障恢复**(同机、含明文、按层级轮转);
|
|
6
|
+
// · 这里是**跨机迁移**:一个目录,人能在上面看、能改、能版本管理。
|
|
7
|
+
//
|
|
8
|
+
// 两条设计约束:
|
|
9
|
+
// ① 不新写任何语义。导入侧全部**经现有 op**(`mcpm-import` / `rules-import` /
|
|
10
|
+
// `subagent-import` / `agentsmd-import` / `tool-table` / `inject-settings` /
|
|
11
|
+
// `scene-archive-save` …),于是校验、写锁、门禁、缓存与目录重算都自动跟着原域走。
|
|
12
|
+
// `invokeOp` 传进来的就是 handlers 表那一份 —— 场景锁定期间各域自己拒绝,这里不再判一套。
|
|
13
|
+
// ② 默认**打码**。MCP 的 env / headers 里是明文凭据,一份要被拷去 U 盘 / 网盘 / 另一台机器的
|
|
14
|
+
// 导出物不该顺手把它们带走;要完整导出必须显式传 `includeSecrets:true`(界面二次确认)。
|
|
15
|
+
// 打码值导回来由 `secret-guard` 拿目标机的旧真值顶替(`mcpm-import` 既有的行为)。
|
|
16
|
+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
|
+
import { unzipSync, zipSync } from 'fflate';
|
|
19
|
+
const SNAP_PREFIX = 'dsh-tool-management-snapshot-';
|
|
20
|
+
/** 快照目录里各部分的名字(导入侧按这些名字找东西,别改)。 */
|
|
21
|
+
const F_MANIFEST = 'manifest.json';
|
|
22
|
+
const F_MCP = 'mcp.json';
|
|
23
|
+
const F_SETTINGS = 'settings.json';
|
|
24
|
+
const F_SCENES = 'scenes.json';
|
|
25
|
+
const D_MEMORIES = 'memories';
|
|
26
|
+
const D_SUBAGENTS = 'subagents';
|
|
27
|
+
const D_PROMPTS = 'prompts';
|
|
28
|
+
const D_SKILLS = 'skills';
|
|
29
|
+
/** 三个"文件域":整个目录打进一份 zip,键是相对路径。 */
|
|
30
|
+
const FILE_DOMAINS = [D_MEMORIES, D_SUBAGENTS, D_PROMPTS];
|
|
31
|
+
const ALL_DOMAINS = ['mcp', D_MEMORIES, D_SUBAGENTS, D_PROMPTS, D_SKILLS, 'settings', 'scenes'];
|
|
32
|
+
const MASKED = '••••••';
|
|
33
|
+
function stamp() {
|
|
34
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
35
|
+
}
|
|
36
|
+
/** 与 `bundle-export` 同一条判据:目标目录必须是绝对路径(相对路径会落到进程 CWD,那是猜)。 */
|
|
37
|
+
function absoluteDir(raw) {
|
|
38
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
39
|
+
if (!s)
|
|
40
|
+
return null;
|
|
41
|
+
return /^([A-Za-z]:[\\/]|\\\\|\/)/.test(s) ? resolve(s) : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 递归收一个目录里的所有文件(**相对该目录根**的路径 → 绝对路径)。
|
|
45
|
+
* 目录不存在 = 空(这个域可能压根没用过)。
|
|
46
|
+
* 只收普通文件、不跟随符号链接(`isFile()` 对 link 为 false)—— 迁移物里不该混进指向别处的链接。
|
|
47
|
+
*/
|
|
48
|
+
async function walk(root) {
|
|
49
|
+
const out = [];
|
|
50
|
+
async function step(dir, prefix, depth) {
|
|
51
|
+
if (depth > 12)
|
|
52
|
+
return;
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of (entries || [])) {
|
|
61
|
+
const abs = join(dir, e.name);
|
|
62
|
+
const rel = prefix ? prefix + '/' + e.name : e.name;
|
|
63
|
+
if (e.isDirectory()) {
|
|
64
|
+
await step(abs, rel, depth + 1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (e.isFile())
|
|
68
|
+
out.push({ rel, abs });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
if ((await stat(root)).isDirectory())
|
|
73
|
+
await step(root, '', 0);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 各 op 的返回形态不一(rows / personas / presets / scenes…),这里只为「条数与重名统计」取一份名字清单。
|
|
82
|
+
* 取不到就是 0 —— 预览数字偏保守(少报重名)不影响导入本身,导入一律交给各域自己的 op。
|
|
83
|
+
*/
|
|
84
|
+
function namesOf(res, prefer) {
|
|
85
|
+
if (!res || res.ok === false)
|
|
86
|
+
return [];
|
|
87
|
+
const bags = [res.data, res].filter(Boolean);
|
|
88
|
+
for (const bag of bags) {
|
|
89
|
+
for (const key of ['rows', 'personas', 'presets', 'scenes', 'skills', 'list', 'entries', 'items']) {
|
|
90
|
+
const arr = bag[key];
|
|
91
|
+
if (!Array.isArray(arr))
|
|
92
|
+
continue;
|
|
93
|
+
const names = [];
|
|
94
|
+
for (const item of arr) {
|
|
95
|
+
if (typeof item === 'string') {
|
|
96
|
+
names.push(item);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const field of prefer) {
|
|
100
|
+
const v = item && item[field];
|
|
101
|
+
if (typeof v === 'string' && v) {
|
|
102
|
+
names.push(v);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (names.length)
|
|
108
|
+
return names;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
/** 场景清单(带 label / description / 提示词绑定):`rules-list` 的 scenes 那一栏,读不到就是空。 */
|
|
114
|
+
function sceneRecordsOf(res) {
|
|
115
|
+
const bags = [res && res.data, res].filter(Boolean);
|
|
116
|
+
for (const bag of bags) {
|
|
117
|
+
const arr = bag?.scenes;
|
|
118
|
+
if (Array.isArray(arr)) {
|
|
119
|
+
return arr
|
|
120
|
+
.map((x) => (typeof x === 'string' ? { name: x } : {
|
|
121
|
+
name: String((x && (x.name || x.scene)) || ''),
|
|
122
|
+
label: x && typeof x.label === 'string' ? x.label : undefined,
|
|
123
|
+
description: x && typeof x.description === 'string' ? x.description : undefined,
|
|
124
|
+
prompt: x && typeof x.prompt === 'string' && x.prompt ? x.prompt : undefined,
|
|
125
|
+
}))
|
|
126
|
+
.filter((x) => x.name && x.name !== '_shared');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
function maskKv(map) {
|
|
132
|
+
const out = {};
|
|
133
|
+
if (map && typeof map === 'object' && !Array.isArray(map)) {
|
|
134
|
+
for (const k of Object.keys(map))
|
|
135
|
+
out[k] = MASKED;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
export function createSnapshotOps(deps) {
|
|
140
|
+
/** 导出:产出一个**目录**(不是 zip)—— 用户要能直接看见里面带了什么、也方便手工挑着带。 */
|
|
141
|
+
async function snapshotExport(args) {
|
|
142
|
+
const outDir = absoluteDir(args && args.outDir);
|
|
143
|
+
if (!outDir)
|
|
144
|
+
return { ok: false, error: '导出目录必须是绝对路径' };
|
|
145
|
+
const includeSecrets = args && args.includeSecrets === true;
|
|
146
|
+
await deps.ensurePaths();
|
|
147
|
+
const dir = join(outDir, SNAP_PREFIX + stamp());
|
|
148
|
+
try {
|
|
149
|
+
await mkdir(dir, { recursive: true });
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return { ok: false, error: '创建快照目录失败: ' + deps.message(e) };
|
|
153
|
+
}
|
|
154
|
+
const domains = {};
|
|
155
|
+
const notes = [];
|
|
156
|
+
// ── MCP:现有 op 的 JSON,默认打码 ─────────────────────────────────────────
|
|
157
|
+
const mcpRes = await deps.invokeOp('mcpm-export', {});
|
|
158
|
+
let mcpRows = [];
|
|
159
|
+
if (mcpRes && mcpRes.ok) {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(String(mcpRes.json || '{}'));
|
|
162
|
+
mcpRows = Array.isArray(parsed.rows) ? parsed.rows : [];
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
notes.push('MCP 导出解析失败: ' + deps.message(e));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
notes.push('MCP 没读到:' + String((mcpRes && mcpRes.error) || 'op 未返回内容'));
|
|
170
|
+
const mcpOut = mcpRows.map((row) => {
|
|
171
|
+
if (includeSecrets)
|
|
172
|
+
return row;
|
|
173
|
+
const copy = Object.assign({}, row);
|
|
174
|
+
if (copy.env)
|
|
175
|
+
copy.env = maskKv(copy.env);
|
|
176
|
+
if (copy.headers)
|
|
177
|
+
copy.headers = maskKv(copy.headers);
|
|
178
|
+
if (typeof copy.url === 'string')
|
|
179
|
+
copy.url = copy.url.replace(/([?&](?:key|token|secret|password)=)[^&]*/gi, '$1' + MASKED);
|
|
180
|
+
return copy;
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
await writeFile(join(dir, F_MCP), JSON.stringify({ exportedAt: new Date().toISOString(), masked: !includeSecrets, rows: mcpOut }, null, 2), 'utf8');
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
return { ok: false, error: '写 mcp.json 失败: ' + deps.message(e) };
|
|
187
|
+
}
|
|
188
|
+
domains.mcp = mcpOut.length;
|
|
189
|
+
// ── 文件域:记忆 / 子智能体 / 提示词,各打进一份 zip(键 = 相对路径)──────────
|
|
190
|
+
const roots = {
|
|
191
|
+
[D_MEMORIES]: deps.hubPath(D_MEMORIES),
|
|
192
|
+
[D_SUBAGENTS]: deps.hubPath(D_SUBAGENTS),
|
|
193
|
+
[D_PROMPTS]: deps.promptsDir(),
|
|
194
|
+
};
|
|
195
|
+
for (const kind of FILE_DOMAINS) {
|
|
196
|
+
const files = await walk(roots[kind]);
|
|
197
|
+
if (files.length) {
|
|
198
|
+
const bundle = {};
|
|
199
|
+
for (const f of files) {
|
|
200
|
+
try {
|
|
201
|
+
bundle[f.rel] = new Uint8Array(await readFile(f.abs));
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
notes.push(kind + ' 跳过 ' + f.rel + ':' + deps.message(e));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
await writeFile(join(dir, kind + '.zip'), Buffer.from(zipSync(bundle)));
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
notes.push(kind + ' 打包失败:' + deps.message(e));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
domains[kind] = files.length;
|
|
215
|
+
}
|
|
216
|
+
// ── 技能:源目录在外部(不在 hub),交给既有的 `bundle-export` 走它自己的收集路径 ──
|
|
217
|
+
const skillState = await deps.invokeOp('skill-state', {});
|
|
218
|
+
const skillKeys = namesOf(skillState, ['key']).filter((k) => k.indexOf('/') > 0);
|
|
219
|
+
if (!skillKeys.length)
|
|
220
|
+
notes.push('技能这次没导出:没从 skill-state 读到「<来源>/<名字>」形态的清单');
|
|
221
|
+
if (skillKeys.length) {
|
|
222
|
+
const bundled = await deps.invokeOp('bundle-export', { kind: 'skills', names: skillKeys, outDir: join(dir, D_SKILLS) });
|
|
223
|
+
if (bundled && bundled.ok)
|
|
224
|
+
domains.skills = Number(bundled.written || 0);
|
|
225
|
+
else
|
|
226
|
+
notes.push('技能打包失败:' + String((bundled && bundled.error) || 'bundle-export 未返回内容'));
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
domains.skills = 0;
|
|
230
|
+
// ── 侧车配置:三份设置都从各自的读 op 拿,不抄结构 ────────────────────────────
|
|
231
|
+
const [toolTable, inject, sceneSettings] = await Promise.all([
|
|
232
|
+
deps.invokeOp('tool-table', {}),
|
|
233
|
+
deps.invokeOp('inject-settings', {}),
|
|
234
|
+
deps.invokeOp('scene-settings', {}),
|
|
235
|
+
]);
|
|
236
|
+
await writeFile(join(dir, F_SETTINGS), JSON.stringify({
|
|
237
|
+
exportedAt: new Date().toISOString(),
|
|
238
|
+
toolTable: toolTable && toolTable.ok ? { hidden: toolTable.hidden || [], presets: toolTable.presets || [] } : null,
|
|
239
|
+
inject: inject && inject.ok ? (inject.settings || null) : null,
|
|
240
|
+
sceneSettings: sceneSettings && sceneSettings.ok ? (sceneSettings.settings || null) : null,
|
|
241
|
+
}, null, 2), 'utf8');
|
|
242
|
+
domains.settings = 1;
|
|
243
|
+
// ── 场景:档案(含 0.15.0 C1 的工具表绑定)+ 场景记录(label / 描述 / 提示词绑定)──
|
|
244
|
+
const modeRes = await deps.invokeOp('scene-mode-get', {});
|
|
245
|
+
const rulesRes = await deps.invokeOp('rules-list', {});
|
|
246
|
+
const archives = (modeRes && modeRes.ok && modeRes.archives) || {};
|
|
247
|
+
await writeFile(join(dir, F_SCENES), JSON.stringify({
|
|
248
|
+
exportedAt: new Date().toISOString(),
|
|
249
|
+
archives,
|
|
250
|
+
records: sceneRecordsOf(rulesRes),
|
|
251
|
+
}, null, 2), 'utf8');
|
|
252
|
+
domains.scenes = Object.keys(archives).length;
|
|
253
|
+
const manifest = {
|
|
254
|
+
pluginVersion: deps.pluginVersion(),
|
|
255
|
+
exportedAt: new Date().toISOString(),
|
|
256
|
+
masked: !includeSecrets,
|
|
257
|
+
domains,
|
|
258
|
+
notes,
|
|
259
|
+
};
|
|
260
|
+
await writeFile(join(dir, F_MANIFEST), JSON.stringify(manifest, null, 2), 'utf8');
|
|
261
|
+
return { ok: true, dir, manifest };
|
|
262
|
+
}
|
|
263
|
+
/** 读快照目录:清单 + 实际存在的部件路径。缺哪个域就是没导出过,不当错误。 */
|
|
264
|
+
async function snapshotRead(dir) {
|
|
265
|
+
let manifestRaw = '';
|
|
266
|
+
try {
|
|
267
|
+
manifestRaw = await readFile(join(dir, F_MANIFEST), 'utf8');
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return { error: '这个目录里没有 manifest.json —— 不是本插件导出的快照目录' };
|
|
271
|
+
}
|
|
272
|
+
let manifest;
|
|
273
|
+
try {
|
|
274
|
+
manifest = JSON.parse(manifestRaw);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
return { error: 'manifest.json 读不动:' + deps.message(e) };
|
|
278
|
+
}
|
|
279
|
+
if (!manifest || typeof manifest !== 'object')
|
|
280
|
+
return { error: 'manifest.json 形状不对' };
|
|
281
|
+
const files = {};
|
|
282
|
+
const parts = [F_MCP, F_SETTINGS, F_SCENES].concat(FILE_DOMAINS.map((d) => d + '.zip'));
|
|
283
|
+
for (const p of parts) {
|
|
284
|
+
try {
|
|
285
|
+
if ((await stat(join(dir, p))).isFile())
|
|
286
|
+
files[p] = join(dir, p);
|
|
287
|
+
}
|
|
288
|
+
catch { /* 这一件没有 */ }
|
|
289
|
+
}
|
|
290
|
+
return { manifest, files };
|
|
291
|
+
}
|
|
292
|
+
async function snapshotPreview(args) {
|
|
293
|
+
const dir = absoluteDir(args && args.dir);
|
|
294
|
+
if (!dir)
|
|
295
|
+
return { ok: false, error: '快照目录必须是绝对路径' };
|
|
296
|
+
const read = await snapshotRead(dir);
|
|
297
|
+
if ('error' in read)
|
|
298
|
+
return { ok: false, error: read.error };
|
|
299
|
+
const { manifest, files } = read;
|
|
300
|
+
const zipKey = (d) => d + '.zip';
|
|
301
|
+
const current = {
|
|
302
|
+
mcp: namesOf(await deps.invokeOp('mcpm-list', {}), ['serverName']),
|
|
303
|
+
[D_SUBAGENTS]: namesOf(await deps.invokeOp('subagent-list', {}), ['name']),
|
|
304
|
+
[D_PROMPTS]: namesOf(await deps.invokeOp('agentsmd-list', {}), ['id']),
|
|
305
|
+
[D_MEMORIES]: namesOf(await deps.invokeOp('rules-list', {}), ['id']),
|
|
306
|
+
scenes: sceneRecordsOf(await deps.invokeOp('rules-list', {})).map((r) => r.name),
|
|
307
|
+
};
|
|
308
|
+
const notes = [];
|
|
309
|
+
const domains = [];
|
|
310
|
+
if (files[F_MCP]) {
|
|
311
|
+
let rows = [];
|
|
312
|
+
try {
|
|
313
|
+
rows = (JSON.parse(await readFile(files[F_MCP], 'utf8')).rows) || [];
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
notes.push('mcp.json 读不动:' + deps.message(e));
|
|
317
|
+
}
|
|
318
|
+
domains.push({ domain: 'mcp', count: rows.length, clashes: rows.filter((r) => current.mcp.indexOf(String(r.serverName || '')) >= 0).length });
|
|
319
|
+
}
|
|
320
|
+
for (const kind of FILE_DOMAINS) {
|
|
321
|
+
if (files[zipKey(kind)]) {
|
|
322
|
+
try {
|
|
323
|
+
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(files[zipKey(kind)]))));
|
|
324
|
+
// 重名按"顶层条目名"比:人设是扁平文件(`<名>.md`),扩展名不算名字的一部分。
|
|
325
|
+
const stripExt = kind === D_SUBAGENTS ? (n) => n.replace(/\.[^.]+$/, '') : (n) => n;
|
|
326
|
+
const top = [...new Set(entries.map((p) => stripExt(p.split('/')[0])))];
|
|
327
|
+
domains.push({
|
|
328
|
+
domain: kind,
|
|
329
|
+
count: entries.length,
|
|
330
|
+
clashes: top.filter((n) => current[kind].indexOf(n) >= 0).length,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
notes.push(kind + ' 的 zip 读不动:' + deps.message(e));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else
|
|
338
|
+
domains.push({ domain: kind, count: 0, clashes: 0 });
|
|
339
|
+
}
|
|
340
|
+
if (files[F_SCENES]) {
|
|
341
|
+
try {
|
|
342
|
+
const parsed = JSON.parse(await readFile(files[F_SCENES], 'utf8'));
|
|
343
|
+
const names = Object.keys(parsed.archives || {});
|
|
344
|
+
domains.push({ domain: 'scenes', count: names.length, clashes: names.filter((n) => current.scenes.indexOf(n) >= 0).length });
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
notes.push('scenes.json 读不动:' + deps.message(e));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (files[F_SETTINGS])
|
|
351
|
+
domains.push({ domain: 'settings', count: 1, clashes: 1 });
|
|
352
|
+
if (manifest.domains && Number(manifest.domains.skills) > 0) {
|
|
353
|
+
let zips = [];
|
|
354
|
+
try {
|
|
355
|
+
zips = (await readdir(join(dir, D_SKILLS))).filter((n) => /\.zip$/i.test(n));
|
|
356
|
+
}
|
|
357
|
+
catch { /* 没带技能 */ }
|
|
358
|
+
domains.push({ domain: D_SKILLS, count: zips.length, clashes: 0 });
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
dir,
|
|
363
|
+
pluginVersion: String(manifest.pluginVersion || ''),
|
|
364
|
+
exportedAt: String(manifest.exportedAt || ''),
|
|
365
|
+
masked: manifest.masked !== false,
|
|
366
|
+
domains,
|
|
367
|
+
notes: (manifest.notes || []).concat(notes),
|
|
368
|
+
missingDomains: ALL_DOMAINS.filter((d) => !domains.some((x) => x.domain === d && x.count > 0)),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 导入:逐域调**该域自己的 import op**,逐域回报成功 / 跳过。
|
|
373
|
+
* 绝不一步写:界面必须先 `snapshot-preview`,用户勾过域、选过冲突策略才走到这里。
|
|
374
|
+
*/
|
|
375
|
+
async function snapshotImport(args) {
|
|
376
|
+
const dir = absoluteDir(args && args.dir);
|
|
377
|
+
if (!dir)
|
|
378
|
+
return { ok: false, error: '快照目录必须是绝对路径' };
|
|
379
|
+
const domains = Array.isArray(args && args.domains) ? args.domains.map((x) => String(x)) : [];
|
|
380
|
+
if (!domains.length)
|
|
381
|
+
return { ok: false, error: '没有勾选任何域' };
|
|
382
|
+
const overwrite = (args && args.conflict) === 'overwrite';
|
|
383
|
+
const read = await snapshotRead(dir);
|
|
384
|
+
if ('error' in read)
|
|
385
|
+
return { ok: false, error: read.error };
|
|
386
|
+
const { files } = read;
|
|
387
|
+
const report = [];
|
|
388
|
+
/** 一个域失败只记在这一行上:迁移的意义正是「哪域没过来」看得清,不是整批回滚。 */
|
|
389
|
+
async function run(domain, task) {
|
|
390
|
+
if (domains.indexOf(domain) < 0)
|
|
391
|
+
return;
|
|
392
|
+
try {
|
|
393
|
+
const r = await task();
|
|
394
|
+
report.push({ domain, imported: r.imported, skipped: r.skipped });
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
report.push({ domain, imported: 0, skipped: 0, error: deps.message(e) });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
await run('mcp', async () => {
|
|
401
|
+
if (!files[F_MCP])
|
|
402
|
+
return { imported: 0, skipped: 0 };
|
|
403
|
+
const raw = await readFile(files[F_MCP], 'utf8');
|
|
404
|
+
const res = await deps.invokeOp('mcpm-import', overwrite ? { json: raw, conflict: 'overwrite' } : { json: raw });
|
|
405
|
+
if (!res || res.ok === false)
|
|
406
|
+
throw new Error(String((res && res.error) || 'mcpm-import 失败'));
|
|
407
|
+
return { imported: (res.added || []).length + (res.overwritten || []).length, skipped: (res.skipped || []).length };
|
|
408
|
+
});
|
|
409
|
+
await run(D_SUBAGENTS, async () => {
|
|
410
|
+
const zip = files[D_SUBAGENTS + '.zip'];
|
|
411
|
+
if (!zip)
|
|
412
|
+
return { imported: 0, skipped: 0 };
|
|
413
|
+
const entries = unzipSync(new Uint8Array(await readFile(zip)));
|
|
414
|
+
const filesArg = [];
|
|
415
|
+
let skipped = 0;
|
|
416
|
+
for (const [rel, bytes] of Object.entries(entries)) {
|
|
417
|
+
// 人设是扁平的 `<名>.md`:嵌套路径不是 subagent-import 认的形状,如实跳过。
|
|
418
|
+
if (rel.indexOf('/') >= 0 || !/\.md$/i.test(rel)) {
|
|
419
|
+
skipped += 1;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
filesArg.push({ name: rel, data: Buffer.from(bytes).toString('base64') });
|
|
423
|
+
}
|
|
424
|
+
if (!filesArg.length)
|
|
425
|
+
return { imported: 0, skipped };
|
|
426
|
+
const res = await deps.invokeOp('subagent-import', { files: filesArg });
|
|
427
|
+
if (!res || res.ok === false)
|
|
428
|
+
throw new Error(String((res && res.error) || 'subagent-import 失败'));
|
|
429
|
+
return { imported: (res.imported || []).length, skipped: skipped + (res.skipped || []).length };
|
|
430
|
+
});
|
|
431
|
+
await run(D_PROMPTS, async () => {
|
|
432
|
+
const zip = files[D_PROMPTS + '.zip'];
|
|
433
|
+
if (!zip)
|
|
434
|
+
return { imported: 0, skipped: 0 };
|
|
435
|
+
const entries = unzipSync(new Uint8Array(await readFile(zip)));
|
|
436
|
+
// 提示词的落点是 `<id>/AGENTS.md`(+ 可选 `<id>/meta.json`),而 `agentsmd-import` 一次一份。
|
|
437
|
+
const bodies = {};
|
|
438
|
+
const metas = {};
|
|
439
|
+
let skipped = 0;
|
|
440
|
+
for (const [rel, bytes] of Object.entries(entries)) {
|
|
441
|
+
const parts = rel.split('/');
|
|
442
|
+
if (parts.length !== 2) {
|
|
443
|
+
skipped += 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const text = Buffer.from(bytes).toString('utf8');
|
|
447
|
+
if (parts[1] === 'AGENTS.md')
|
|
448
|
+
bodies[parts[0]] = text;
|
|
449
|
+
else if (parts[1] === 'meta.json') {
|
|
450
|
+
try {
|
|
451
|
+
metas[parts[0]] = JSON.parse(text);
|
|
452
|
+
}
|
|
453
|
+
catch { /* 描述丢了不影响正文导入 */ }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
let imported = 0;
|
|
457
|
+
for (const id of Object.keys(bodies)) {
|
|
458
|
+
const desc = String((metas[id] && metas[id].description) || '');
|
|
459
|
+
const res = await deps.invokeOp('agentsmd-import', { id, content: bodies[id], description: desc });
|
|
460
|
+
if (res && res.ok) {
|
|
461
|
+
imported += 1;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
// 同名预设 `agentsmd-import` 一律拒(它不覆盖)。覆盖模式下改走 upsert 那条 update 路径。
|
|
465
|
+
if (!overwrite) {
|
|
466
|
+
skipped += 1;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const upd = await deps.invokeOp('agentsmd-update', { id, nextId: id, content: bodies[id], description: desc });
|
|
470
|
+
if (upd && upd.ok)
|
|
471
|
+
imported += 1;
|
|
472
|
+
else
|
|
473
|
+
skipped += 1;
|
|
474
|
+
}
|
|
475
|
+
return { imported, skipped };
|
|
476
|
+
});
|
|
477
|
+
await run(D_MEMORIES, async () => {
|
|
478
|
+
const zip = files[D_MEMORIES + '.zip'];
|
|
479
|
+
if (!zip)
|
|
480
|
+
return { imported: 0, skipped: 0 };
|
|
481
|
+
const entries = unzipSync(new Uint8Array(await readFile(zip)));
|
|
482
|
+
// zip 的相对路径就是 `<场景>/<文件>`:按场景分组,每组各调一次 rules-import。
|
|
483
|
+
const byScene = {};
|
|
484
|
+
let skipped = 0;
|
|
485
|
+
for (const [rel, bytes] of Object.entries(entries)) {
|
|
486
|
+
const i = rel.indexOf('/');
|
|
487
|
+
if (i <= 0) {
|
|
488
|
+
skipped += 1;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const scene = rel.slice(0, i);
|
|
492
|
+
const name = rel.slice(i + 1);
|
|
493
|
+
// 附件目录(`<场景>/<名>/附件…`)不是 rules-import 的形状:如实跳过,不假装带过来了。
|
|
494
|
+
if (name.indexOf('/') >= 0 || !name) {
|
|
495
|
+
skipped += 1;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
;
|
|
499
|
+
(byScene[scene] || (byScene[scene] = [])).push({ name, data: Buffer.from(bytes).toString('base64') });
|
|
500
|
+
}
|
|
501
|
+
let imported = 0;
|
|
502
|
+
for (const scene of Object.keys(byScene)) {
|
|
503
|
+
const res = await deps.invokeOp('rules-import', { scene, files: byScene[scene] });
|
|
504
|
+
if (!res || res.ok === false) {
|
|
505
|
+
skipped += byScene[scene].length;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const data = res.data || res;
|
|
509
|
+
imported += (data.imported || []).length;
|
|
510
|
+
skipped += (data.skipped || []).length;
|
|
511
|
+
}
|
|
512
|
+
return { imported, skipped };
|
|
513
|
+
});
|
|
514
|
+
await run('scenes', async () => {
|
|
515
|
+
if (!files[F_SCENES])
|
|
516
|
+
return { imported: 0, skipped: 0 };
|
|
517
|
+
const parsed = JSON.parse(await readFile(files[F_SCENES], 'utf8'));
|
|
518
|
+
const archives = parsed.archives || {};
|
|
519
|
+
const records = {};
|
|
520
|
+
for (const r of (parsed.records || []))
|
|
521
|
+
if (r && r.name)
|
|
522
|
+
records[String(r.name)] = r;
|
|
523
|
+
let imported = 0;
|
|
524
|
+
let skipped = 0;
|
|
525
|
+
for (const scene of Object.keys(archives)) {
|
|
526
|
+
const rec = records[scene] || {};
|
|
527
|
+
const withPrompt = { name: scene, label: rec.label, description: rec.description, ...(rec.prompt ? { prompt: rec.prompt } : {}) };
|
|
528
|
+
let created = await deps.invokeOp('rules-create-scene', withPrompt);
|
|
529
|
+
if (!created || created.ok === false) {
|
|
530
|
+
// 提示词预设可能还没过来(或名字变了):那就先不带绑定建场景,档案照存。
|
|
531
|
+
if (rec.prompt)
|
|
532
|
+
created = await deps.invokeOp('rules-create-scene', { name: scene, label: rec.label, description: rec.description });
|
|
533
|
+
}
|
|
534
|
+
if ((!created || created.ok === false) && !/已存在|exist/i.test(String((created && created.error) || ''))) {
|
|
535
|
+
skipped += 1;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const saved = await deps.invokeOp('scene-archive-save', { scene, archive: archives[scene] });
|
|
539
|
+
if (saved && saved.ok)
|
|
540
|
+
imported += 1;
|
|
541
|
+
else
|
|
542
|
+
skipped += 1;
|
|
543
|
+
}
|
|
544
|
+
return { imported, skipped };
|
|
545
|
+
});
|
|
546
|
+
await run('settings', async () => {
|
|
547
|
+
if (!files[F_SETTINGS])
|
|
548
|
+
return { imported: 0, skipped: 0 };
|
|
549
|
+
const s = JSON.parse(await readFile(files[F_SETTINGS], 'utf8'));
|
|
550
|
+
let done = 0;
|
|
551
|
+
let skipped = 0;
|
|
552
|
+
const write = async (op, args, has) => {
|
|
553
|
+
if (!has) {
|
|
554
|
+
skipped += 1;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const r = await deps.invokeOp(op, args);
|
|
558
|
+
if (r && r.ok !== false)
|
|
559
|
+
done += 1;
|
|
560
|
+
else
|
|
561
|
+
skipped += 1;
|
|
562
|
+
};
|
|
563
|
+
await write('tool-table', { set: true, hidden: (s.toolTable && s.toolTable.hidden) || [] }, !!s.toolTable);
|
|
564
|
+
await write('inject-settings', { set: true, ...(s.inject || {}) }, !!s.inject);
|
|
565
|
+
await write('scene-settings', { set: true, ...(s.sceneSettings || {}) }, !!s.sceneSettings);
|
|
566
|
+
return { imported: done, skipped };
|
|
567
|
+
});
|
|
568
|
+
await run(D_SKILLS, async () => {
|
|
569
|
+
const skillDir = join(dir, D_SKILLS);
|
|
570
|
+
let zips = [];
|
|
571
|
+
try {
|
|
572
|
+
zips = (await readdir(skillDir)).filter((n) => /\.zip$/i.test(n)).map((n) => join(skillDir, n));
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return { imported: 0, skipped: 0 };
|
|
576
|
+
}
|
|
577
|
+
let imported = 0;
|
|
578
|
+
let skipped = 0;
|
|
579
|
+
for (const zip of zips) {
|
|
580
|
+
const res = await deps.invokeOp('skill-import', overwrite ? { source: zip, conflict: 'overwrite' } : { source: zip });
|
|
581
|
+
if (!res || res.ok === false)
|
|
582
|
+
throw new Error(String((res && res.error) || 'skill-import 失败'));
|
|
583
|
+
const data = res.data || {};
|
|
584
|
+
imported += (data.imported || []).length;
|
|
585
|
+
skipped += (data.skipped || []).length + (data.failed || []).length;
|
|
586
|
+
}
|
|
587
|
+
return { imported, skipped };
|
|
588
|
+
});
|
|
589
|
+
return { ok: true, dir, conflict: overwrite ? 'overwrite' : 'skip', report };
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
'snapshot-export': snapshotExport,
|
|
593
|
+
'snapshot-preview': snapshotPreview,
|
|
594
|
+
'snapshot-import': snapshotImport,
|
|
595
|
+
};
|
|
596
|
+
}
|
package/lib/scenes/candidates.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// 依赖全部经 deps 显式传入。注意 \`toolKeyParts\` 留在 index.ts:场景档案引擎也在用它,
|
|
15
15
|
// 放进本文件会让两个域互相引用。
|
|
16
16
|
import { presetRosterOf } from '../compat/preset-reach.js';
|
|
17
|
-
import { injectionFactsOf, readCompositionFacts } from '../compat/preset-reach.js';
|
|
17
|
+
import { injectionFactsOf, readCompositionFacts, readCompositionText } from '../compat/preset-reach.js';
|
|
18
18
|
/**
|
|
19
19
|
* 问 adapter 的等待上限。官方签名 `resolveModelInfo(provider, model, signal?)` 的 signal 是
|
|
20
20
|
* "optional cancellation for adapter-owned asynchronous lookup",所以给一个上限 —— 界面那一格
|
|
@@ -110,7 +110,9 @@ export function createCandidates(deps) {
|
|
|
110
110
|
const presetFactsCache = new Map();
|
|
111
111
|
async function presetFactsForAgent(agent) {
|
|
112
112
|
const roster = presetRoster();
|
|
113
|
-
if (!roster || typeof roster.composedPreset !== 'function'
|
|
113
|
+
if (!roster || typeof roster.composedPreset !== 'function')
|
|
114
|
+
return undefined;
|
|
115
|
+
if (typeof roster.read !== 'function' && typeof roster.readDocument !== 'function')
|
|
114
116
|
return undefined;
|
|
115
117
|
let presetId = '';
|
|
116
118
|
try {
|
|
@@ -125,7 +127,7 @@ export function createCandidates(deps) {
|
|
|
125
127
|
if (hit && Date.now() - hit.at < PRESET_FACTS_TTL_MS)
|
|
126
128
|
return hit.value;
|
|
127
129
|
try {
|
|
128
|
-
const text =
|
|
130
|
+
const text = await readCompositionText(roster, presetId);
|
|
129
131
|
const value = injectionFactsOf(readCompositionFacts(text));
|
|
130
132
|
presetFactsCache.set(presetId, { at: Date.now(), value });
|
|
131
133
|
return value;
|
|
@@ -216,7 +218,10 @@ export function createCandidates(deps) {
|
|
|
216
218
|
presets.push({
|
|
217
219
|
id,
|
|
218
220
|
name: String((p && (p.name || p.id)) || id),
|
|
219
|
-
|
|
221
|
+
// 官方 registry 的预设**没有** trust 字段 —— 缺省必须是 ''(未知)而不是 'user':
|
|
222
|
+
// 客户端只对显式 user 隐藏内置模式的词典名,缺省 'user' 会让四个内置模式永远
|
|
223
|
+
// 显示原始 id(2026-09-28 用户截图)。
|
|
224
|
+
trust: String((p && p.trust) || ''),
|
|
220
225
|
broken: typeof (p && p.broken) === 'string',
|
|
221
226
|
tools: names,
|
|
222
227
|
});
|
|
@@ -287,7 +292,7 @@ export function createCandidates(deps) {
|
|
|
287
292
|
try {
|
|
288
293
|
const roster = await agentPresets.list();
|
|
289
294
|
return (roster || [])
|
|
290
|
-
.map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || '
|
|
295
|
+
.map((p) => ({ id: String((p && p.id) || ''), name: String((p && (p.name || p.id)) || ''), trust: String((p && p.trust) || '') }))
|
|
291
296
|
.filter((p) => p.id !== '');
|
|
292
297
|
}
|
|
293
298
|
catch {
|
package/lib/sessions/bridge.js
CHANGED
|
@@ -62,6 +62,8 @@ function acquireCacheGuard(cache) {
|
|
|
62
62
|
kind: "delete",
|
|
63
63
|
fallback: "inform-only",
|
|
64
64
|
detail: `投影缓存适配未完成(requireTable 抛错):${String(error?.message ?? error)};宿主对象未被改动。`,
|
|
65
|
+
detailKey: "projection-cache-adapter.setup",
|
|
66
|
+
params: { reason: String(error?.message ?? error) },
|
|
65
67
|
});
|
|
66
68
|
throw new CapabilityRefusalError("delete", [{
|
|
67
69
|
id: "projection.table-delete",
|
|
@@ -218,6 +220,9 @@ function acquireCacheGuard(cache) {
|
|
|
218
220
|
detail: reason === "taken-over"
|
|
219
221
|
? "宿主缓存的 put/write 已被第三方包装接管:本插件的删除屏障这一轮已退役(缓存行删除少了墓碑拦截),下一次删除会重新安装。"
|
|
220
222
|
: "投影缓存适配在首次使用时抛错(宿主形状可能变了):已恢复宿主原方法,下一次删除会重新安装并再探一次。",
|
|
223
|
+
detailKey: reason === "taken-over"
|
|
224
|
+
? "projection-cache-adapter.taken-over"
|
|
225
|
+
: "projection-cache-adapter.first-use",
|
|
221
226
|
});
|
|
222
227
|
}
|
|
223
228
|
cacheGuards.set(cache, self);
|