@restregular/dsh-skill-center 0.0.0-stage → 0.1.7
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/LICENSE +21 -0
- package/README.md +330 -2
- package/client/client.js +767 -0
- package/cordis.patch.yml +7 -0
- package/lib/index.js +691 -0
- package/package.json +55 -4
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-skill-center bundle patch: inserts the host plugin (skill catalog API over
|
|
2
|
+
# the loopback Web route /dsh-skill-center) and the client plugin (a 「技能中心」
|
|
3
|
+
# row in the sidebar's global panel list plus its central page).
|
|
4
|
+
# 纯 insert(id/name)可热挂载,免重启。
|
|
5
|
+
- insert:
|
|
6
|
+
- id: skill-center
|
|
7
|
+
name: 'dsh-skill-center'
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skill-center — Host half.
|
|
3
|
+
*
|
|
4
|
+
* 宿主半边只做两件事:
|
|
5
|
+
* 1. 把"当前会话能看到的技能"读出来(走 ctx.skills 注册表,作用域解析与
|
|
6
|
+
* @deepseek-ai/dsh-api-session-controller 的 skills Remote 同源);
|
|
7
|
+
* 2. 对**文件系统来源**的技能提供写操作:改 frontmatter 调用开关、新建、删除(移入 .trash)。
|
|
8
|
+
*
|
|
9
|
+
* 浏览器半边(client/client.js)通过同源回环路由 /dsh-skill-center/api 调用,
|
|
10
|
+
* 与本机其它第三方插件的做法一致(dsh-meme 的 /dsh-memes-api)。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-skill-center
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
statSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** Loader identity of the Host half. */
|
|
27
|
+
export const name = 'skill-center';
|
|
28
|
+
|
|
29
|
+
/** Services this plugin needs; absent `webServer` keeps it inert instead of throwing. */
|
|
30
|
+
export const inject = ['skills', 'webServer'];
|
|
31
|
+
|
|
32
|
+
/** Loopback route prefix owned by this plugin. */
|
|
33
|
+
const ROUTE = '/dsh-skill-center';
|
|
34
|
+
|
|
35
|
+
/** 技能名文法,与 @deepseek-ai/dsh-skill 的 SKILL_NAME 一致。 */
|
|
36
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
37
|
+
|
|
38
|
+
/** frontmatter 里与调用策略相关的键(frontmatter 侧规范名 + 需要清掉的遗留驼峰名)。 */
|
|
39
|
+
const INVOCATION_KEYS = new Set([
|
|
40
|
+
'disable-model-invocation',
|
|
41
|
+
'disableModelInvocation',
|
|
42
|
+
'user-invocable',
|
|
43
|
+
'userInvocable',
|
|
44
|
+
'modelInvocable',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** 默认根的标签与 rank(与 dsh-skill-filesystem 的根表一致)。 */
|
|
48
|
+
const ROOT_LABELS = {
|
|
49
|
+
'project-dsh': { label: '项目 .dsh/skills', rank: 100 },
|
|
50
|
+
'project-agents': { label: '项目 .agents/skills', rank: 200 },
|
|
51
|
+
'user-dsh': { label: '用户 DSH 技能', rank: 400 },
|
|
52
|
+
'user-agents': { label: '用户 Agents 技能', rank: 500 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// HTTP 小工具
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function sendJson(res, payload, status = 200) {
|
|
60
|
+
const body = JSON.stringify(payload);
|
|
61
|
+
res.writeHead(status, {
|
|
62
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
63
|
+
'Cache-Control': 'no-store',
|
|
64
|
+
});
|
|
65
|
+
res.end(body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readBody(req, maxBytes = 4 * 1024 * 1024) {
|
|
69
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let total = 0;
|
|
72
|
+
req.on('data', (chunk) => {
|
|
73
|
+
total += chunk.length;
|
|
74
|
+
if (total > maxBytes) {
|
|
75
|
+
rejectPromise(new Error('请求体过大'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
chunks.push(chunk);
|
|
79
|
+
});
|
|
80
|
+
req.on('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8')));
|
|
81
|
+
req.on('error', rejectPromise);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLoopbackRequest(req) {
|
|
86
|
+
const address = req.socket?.remoteAddress ?? '';
|
|
87
|
+
return address === '127.0.0.1'
|
|
88
|
+
|| address === '::1'
|
|
89
|
+
|| address === '::ffff:127.0.0.1';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function denyNonLoopback(req, res) {
|
|
93
|
+
if (isLoopbackRequest(req)) return false;
|
|
94
|
+
sendJson(res, { ok: false, error: '只接受本机(回环地址)请求' }, 403);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// frontmatter:只做字符串级改写,其余内容逐字保留
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 把一个 SKILL.md 文本切成 frontmatter 与正文。
|
|
104
|
+
* @param text - 文件全文。
|
|
105
|
+
* @returns 匹配结果,或 undefined(没有合法的 `---` 围栏)。
|
|
106
|
+
*/
|
|
107
|
+
function matchFrontmatter(text) {
|
|
108
|
+
return /^(\uFEFF?)---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u.exec(text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 改写 frontmatter 内的行,保留正文与行尾风格。
|
|
113
|
+
* @param text - 文件全文。
|
|
114
|
+
* @param mutate - 接收 frontmatter 行数组,返回新行数组。
|
|
115
|
+
* @returns 新全文,或 undefined(没有 frontmatter)。
|
|
116
|
+
*/
|
|
117
|
+
function rewriteFrontmatter(text, mutate) {
|
|
118
|
+
const match = matchFrontmatter(text);
|
|
119
|
+
if (match === undefined) return undefined;
|
|
120
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
121
|
+
const lines = match[2].split(/\r?\n/u);
|
|
122
|
+
const next = mutate(lines);
|
|
123
|
+
const head = `${match[1]}---${eol}${next.join(eol)}${eol}---`;
|
|
124
|
+
return head + text.slice(match[0].length);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 取一行 frontmatter 的键名。
|
|
129
|
+
* @param line - 一行文本。
|
|
130
|
+
* @returns 键名,或 undefined。
|
|
131
|
+
*/
|
|
132
|
+
function lineKey(line) {
|
|
133
|
+
const match = /^([A-Za-z0-9_.-]+)\s*:/u.exec(line);
|
|
134
|
+
return match?.[1];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 按调用策略重写 frontmatter:默认值不落键,非默认值写显式布尔。
|
|
139
|
+
* @param text - 文件全文。
|
|
140
|
+
* @param invocation - 目标调用策略。
|
|
141
|
+
* @returns 新全文。
|
|
142
|
+
* @throws 当文件没有 frontmatter 时。
|
|
143
|
+
*/
|
|
144
|
+
function applyInvocation(text, invocation) {
|
|
145
|
+
const next = rewriteFrontmatter(text, (lines) => {
|
|
146
|
+
const kept = lines.filter((line) => {
|
|
147
|
+
const key = lineKey(line);
|
|
148
|
+
return key === undefined || !INVOCATION_KEYS.has(key);
|
|
149
|
+
});
|
|
150
|
+
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop();
|
|
151
|
+
if (invocation.modelInvocable === false) kept.push('disable-model-invocation: true');
|
|
152
|
+
if (invocation.userInvocable === false) kept.push('user-invocable: false');
|
|
153
|
+
return kept;
|
|
154
|
+
});
|
|
155
|
+
if (next === undefined) throw new Error('该技能的 SKILL.md 没有 YAML frontmatter,无法改写');
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 读 frontmatter 里的标量字符串字段。
|
|
161
|
+
* @param text - 文件全文。
|
|
162
|
+
* @param key - 字段名。
|
|
163
|
+
* @returns 字段值,或 undefined。
|
|
164
|
+
*/
|
|
165
|
+
function frontmatterField(text, key) {
|
|
166
|
+
const match = matchFrontmatter(text);
|
|
167
|
+
if (match === undefined) return undefined;
|
|
168
|
+
const line = match[2]
|
|
169
|
+
.split(/\r?\n/u)
|
|
170
|
+
.find((candidate) => lineKey(candidate) === key);
|
|
171
|
+
if (line === undefined) return undefined;
|
|
172
|
+
const raw = line.slice(line.indexOf(':') + 1).trim();
|
|
173
|
+
if (raw === '') return undefined;
|
|
174
|
+
return raw.replace(/^["']/u, '').replace(/["']$/u, '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// 技能根目录
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function envPath(value) {
|
|
182
|
+
if (typeof value !== 'string' || value.trim() === '') return undefined;
|
|
183
|
+
return isAbsolute(value) ? value : resolve(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function dshHomeDir() {
|
|
187
|
+
return envPath(process.env.DSH_HOME) ?? join(homedir(), '.dsh');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function agentsHomeDir() {
|
|
191
|
+
return envPath(process.env.DSH_AGENTS_HOME) ?? join(homedir(), '.agents');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 项目根 = 最近的含 `.git` 的祖先,与 dsh-skill-filesystem 的 findProjectRoot 一致。
|
|
196
|
+
* @param cwd - 起始目录。
|
|
197
|
+
* @returns 项目根目录。
|
|
198
|
+
*/
|
|
199
|
+
function findProjectRoot(cwd) {
|
|
200
|
+
let current = resolve(cwd);
|
|
201
|
+
for (;;) {
|
|
202
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
203
|
+
const parent = dirname(current);
|
|
204
|
+
if (parent === current) return resolve(cwd);
|
|
205
|
+
current = parent;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 本插件可以新建/删除技能的根目录表(rank 升序)。
|
|
211
|
+
* @param cwd - 解析项目根的起始目录。
|
|
212
|
+
* @returns 根目录描述数组。
|
|
213
|
+
*/
|
|
214
|
+
function managedRoots(cwd) {
|
|
215
|
+
const projectRoot = findProjectRoot(cwd);
|
|
216
|
+
const roots = [
|
|
217
|
+
{ id: 'project-dsh', path: join(projectRoot, '.dsh', 'skills'), source: 'project-dsh' },
|
|
218
|
+
{ id: 'project-agents', path: join(projectRoot, '.agents', 'skills'), source: 'project-agents' },
|
|
219
|
+
{ id: 'user-dsh', path: join(dshHomeDir(), 'skills'), source: 'user-dsh' },
|
|
220
|
+
{ id: 'user-agents', path: join(agentsHomeDir(), 'skills'), source: 'user-agents' },
|
|
221
|
+
];
|
|
222
|
+
return roots.map((root) => ({
|
|
223
|
+
...root,
|
|
224
|
+
label: ROOT_LABELS[root.source]?.label ?? root.source,
|
|
225
|
+
rank: ROOT_LABELS[root.source]?.rank ?? 999,
|
|
226
|
+
exists: existsSync(root.path),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 判断一个路径是否落在某个受管根目录之下。
|
|
232
|
+
* @param path - 目标路径。
|
|
233
|
+
* @param roots - managedRoots 的结果。
|
|
234
|
+
* @returns 命中的根目录,或 undefined。
|
|
235
|
+
*/
|
|
236
|
+
function rootOfPath(path, roots) {
|
|
237
|
+
if (typeof path !== 'string' || path === '') return undefined;
|
|
238
|
+
const target = resolve(path);
|
|
239
|
+
return roots.find((root) => {
|
|
240
|
+
const base = resolve(root.path);
|
|
241
|
+
return target === base || target.startsWith(base.endsWith(sep) ? base : base + sep);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// 技能目录解析(与 shipped skills Remote 同源的三级兜底)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 读一个会话记录的 cwd 与预设,不激活冷 Agent。
|
|
251
|
+
* @param ctx - 宿主上下文。
|
|
252
|
+
* @param sessionId - 会话 id。
|
|
253
|
+
* @returns `{ cwd, agentPreset }`;读不到时字段为 undefined。
|
|
254
|
+
*/
|
|
255
|
+
async function observeSession(ctx, sessionId) {
|
|
256
|
+
const sessionQuery = ctx.get('sessionQuery');
|
|
257
|
+
if (sessionQuery === undefined || sessionId === undefined) return {};
|
|
258
|
+
let observation;
|
|
259
|
+
try {
|
|
260
|
+
observation = await sessionQuery.observeSession(sessionId);
|
|
261
|
+
} catch {
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
return {
|
|
266
|
+
cwd: observation?.header?.cwd,
|
|
267
|
+
agentPreset: observation?.projections?.values?.agentPreset ?? undefined,
|
|
268
|
+
};
|
|
269
|
+
} finally {
|
|
270
|
+
try {
|
|
271
|
+
await observation?.[Symbol.asyncDispose]?.();
|
|
272
|
+
} catch {
|
|
273
|
+
/* 观察句柄释放失败不影响读取结果 */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 解析"这次读取用哪个注册表、哪个作用域、哪个 cwd"。
|
|
280
|
+
*
|
|
281
|
+
* 三级兜底:活 Agent(最准) → 会话记录的预设作用域 → 全局注册表。
|
|
282
|
+
* 第一级决定了预设内挂载的 skill-filesystem 是否可见。
|
|
283
|
+
*
|
|
284
|
+
* @param ctx - 宿主上下文。
|
|
285
|
+
* @param request - `{ sessionId, cwd }`。
|
|
286
|
+
* @returns `{ registry, scope, cwd, tier, release }`。
|
|
287
|
+
*/
|
|
288
|
+
async function resolveView(ctx, request) {
|
|
289
|
+
const globalRegistry = ctx.get('skills');
|
|
290
|
+
if (globalRegistry === undefined) throw new Error('技能注册表不存在:当前组合没有挂载 @deepseek-ai/dsh-skill');
|
|
291
|
+
|
|
292
|
+
const agents = ctx.get('agents');
|
|
293
|
+
const presets = ctx.get('agentPresets');
|
|
294
|
+
const live = request.sessionId === undefined ? undefined : agents?.get?.(request.sessionId);
|
|
295
|
+
|
|
296
|
+
if (live !== undefined) {
|
|
297
|
+
const scoped = presets?.serviceFor?.(live, 'skills');
|
|
298
|
+
return {
|
|
299
|
+
registry: scoped ?? globalRegistry,
|
|
300
|
+
scope: live,
|
|
301
|
+
cwd: live?.session?.header?.cwd ?? request.cwd ?? process.cwd(),
|
|
302
|
+
tier: 'live-agent',
|
|
303
|
+
release: undefined,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const observed = await observeSession(ctx, request.sessionId);
|
|
308
|
+
const cwd = observed.cwd ?? request.cwd ?? process.cwd();
|
|
309
|
+
|
|
310
|
+
if (presets !== undefined && observed.agentPreset !== undefined) {
|
|
311
|
+
let lease;
|
|
312
|
+
try {
|
|
313
|
+
lease = await presets.acquireScope(observed.agentPreset);
|
|
314
|
+
} catch {
|
|
315
|
+
lease = undefined;
|
|
316
|
+
}
|
|
317
|
+
if (lease !== undefined) {
|
|
318
|
+
return {
|
|
319
|
+
registry: globalRegistry,
|
|
320
|
+
scope: lease.key,
|
|
321
|
+
cwd,
|
|
322
|
+
tier: 'preset-scope',
|
|
323
|
+
release: async () => {
|
|
324
|
+
try {
|
|
325
|
+
await lease[Symbol.asyncDispose]?.();
|
|
326
|
+
} catch {
|
|
327
|
+
/* 释放失败不影响读取结果 */
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
registry: globalRegistry,
|
|
336
|
+
scope: undefined,
|
|
337
|
+
cwd,
|
|
338
|
+
tier: observed.cwd === undefined && request.cwd === undefined ? 'process-cwd' : 'global',
|
|
339
|
+
release: undefined,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 读技能目录快照。
|
|
345
|
+
* @param ctx - 宿主上下文。
|
|
346
|
+
* @param request - `{ sessionId, cwd }`。
|
|
347
|
+
* @returns 归一化后的技能数组与元信息。
|
|
348
|
+
*/
|
|
349
|
+
async function readCatalog(ctx, request) {
|
|
350
|
+
const view = await resolveView(ctx, request);
|
|
351
|
+
try {
|
|
352
|
+
const snapshot = await view.registry.snapshot({ cwd: view.cwd, scope: view.scope });
|
|
353
|
+
const roots = managedRoots(view.cwd);
|
|
354
|
+
return {
|
|
355
|
+
cwd: view.cwd,
|
|
356
|
+
tier: view.tier,
|
|
357
|
+
complete: snapshot.complete !== false,
|
|
358
|
+
roots,
|
|
359
|
+
skills: snapshot.skills.map((skill) => describeSkill(skill, roots)),
|
|
360
|
+
};
|
|
361
|
+
} finally {
|
|
362
|
+
await view.release?.();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 把注册表摘要转成面板需要的形状。
|
|
368
|
+
* @param skill - 注册表 Summary。
|
|
369
|
+
* @param roots - 受管根目录。
|
|
370
|
+
* @returns 面板条目。
|
|
371
|
+
*/
|
|
372
|
+
function describeSkill(skill, roots) {
|
|
373
|
+
const path = typeof skill.path === 'string' ? skill.path : undefined;
|
|
374
|
+
const root = rootOfPath(path, roots);
|
|
375
|
+
const onDisk = path !== undefined && existsSync(path) && statSync(path).isFile();
|
|
376
|
+
let bytes;
|
|
377
|
+
if (onDisk) {
|
|
378
|
+
try {
|
|
379
|
+
bytes = statSync(path).size;
|
|
380
|
+
} catch {
|
|
381
|
+
bytes = undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
name: skill.name,
|
|
386
|
+
description: skill.description,
|
|
387
|
+
whenToUse: skill.whenToUse,
|
|
388
|
+
provider: skill.provider,
|
|
389
|
+
source: skill.source,
|
|
390
|
+
path,
|
|
391
|
+
directory: path === undefined ? undefined : dirname(path),
|
|
392
|
+
modelInvocable: skill.invocation?.modelInvocable !== false,
|
|
393
|
+
userInvocable: skill.invocation?.userInvocable !== false,
|
|
394
|
+
rootId: root?.id,
|
|
395
|
+
rootLabel: root?.label,
|
|
396
|
+
writable: root !== undefined && onDisk,
|
|
397
|
+
bytes,
|
|
398
|
+
resourceKind: skill.resourceBase?.kind,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 在受管根目录里定位一个技能的 SKILL.md,用于写操作。
|
|
404
|
+
* @param name - 技能名。
|
|
405
|
+
* @param roots - 受管根目录。
|
|
406
|
+
* @returns `{ root, dir, path, flat }`,或 undefined。
|
|
407
|
+
*/
|
|
408
|
+
function locateOnDisk(name, roots) {
|
|
409
|
+
for (const root of roots) {
|
|
410
|
+
if (!existsSync(root.path)) continue;
|
|
411
|
+
const bundle = join(root.path, name, 'SKILL.md');
|
|
412
|
+
if (existsSync(bundle) && statSync(bundle).isFile()) {
|
|
413
|
+
return { root, dir: join(root.path, name), path: bundle, flat: false };
|
|
414
|
+
}
|
|
415
|
+
const flat = join(root.path, `${name}.md`);
|
|
416
|
+
if (existsSync(flat) && statSync(flat).isFile()) {
|
|
417
|
+
return { root, dir: root.path, path: flat, flat: true };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// 操作
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 诊断:本插件的宿主/客户端两半是否都已挂载,以及技能根的解析结果。
|
|
429
|
+
*
|
|
430
|
+
* 客户端半侧是否进了浏览器模块图由 `clientModules` 的合成图决定,
|
|
431
|
+
* 这里直接把它读出来,省得靠页面表现猜。
|
|
432
|
+
*
|
|
433
|
+
* @param ctx - 宿主上下文。
|
|
434
|
+
* @param request - `{ sessionId, cwd }`。
|
|
435
|
+
* @returns 响应体。
|
|
436
|
+
*/
|
|
437
|
+
async function opDiagnose(ctx, request) {
|
|
438
|
+
const clientModules = ctx.get('clientModules');
|
|
439
|
+
const graph = clientModules?.graph?.();
|
|
440
|
+
const entries = Array.isArray(graph?.entries) ? graph.entries : [];
|
|
441
|
+
const mine = entries.find((entry) => entry.id === 'dsh-skill-center');
|
|
442
|
+
let catalog;
|
|
443
|
+
try {
|
|
444
|
+
const catalogRead = await readCatalog(ctx, request);
|
|
445
|
+
catalog = { cwd: catalogRead.cwd, tier: catalogRead.tier, total: catalogRead.skills.length };
|
|
446
|
+
} catch (error) {
|
|
447
|
+
catalog = { error: String(error?.message ?? error) };
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
host: { mounted: true, route: ROUTE },
|
|
452
|
+
client: {
|
|
453
|
+
inGraph: mine !== undefined,
|
|
454
|
+
graphRevision: graph?.rev,
|
|
455
|
+
graphEntryCount: entries.length,
|
|
456
|
+
url: mine?.url,
|
|
457
|
+
},
|
|
458
|
+
catalog,
|
|
459
|
+
roots: managedRoots(request.cwd ?? process.cwd()),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 列出当前会话可见的技能。
|
|
465
|
+
* @param ctx - 宿主上下文。
|
|
466
|
+
* @param request - `{ sessionId, cwd }`。
|
|
467
|
+
* @returns 响应体。
|
|
468
|
+
*/
|
|
469
|
+
async function opList(ctx, request) {
|
|
470
|
+
const catalog = await readCatalog(ctx, request);
|
|
471
|
+
const writable = catalog.skills.filter((skill) => skill.writable).length;
|
|
472
|
+
return {
|
|
473
|
+
ok: true,
|
|
474
|
+
encoding: 'utf8',
|
|
475
|
+
cwd: catalog.cwd,
|
|
476
|
+
tier: catalog.tier,
|
|
477
|
+
complete: catalog.complete,
|
|
478
|
+
roots: catalog.roots,
|
|
479
|
+
counts: {
|
|
480
|
+
total: catalog.skills.length,
|
|
481
|
+
writable,
|
|
482
|
+
embedded: catalog.skills.length - writable,
|
|
483
|
+
},
|
|
484
|
+
skills: catalog.skills,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 读一个技能的 SKILL.md 原文。
|
|
490
|
+
* @param ctx - 宿主上下文。
|
|
491
|
+
* @param request - `{ sessionId, cwd, name }`。
|
|
492
|
+
* @returns 响应体。
|
|
493
|
+
*/
|
|
494
|
+
async function opRead(ctx, request) {
|
|
495
|
+
const catalog = await readCatalog(ctx, request);
|
|
496
|
+
const skill = catalog.skills.find((candidate) => candidate.name === request.name);
|
|
497
|
+
if (skill === undefined) return { ok: false, error: `技能 "${request.name}" 不在当前技能目录中` };
|
|
498
|
+
const entry = locateOnDisk(request.name, catalog.roots);
|
|
499
|
+
if (entry === undefined) {
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
error: skill.rootId === undefined
|
|
503
|
+
? `技能 "${request.name}" 由插件内嵌提供(${skill.provider}),没有可读的本地文件`
|
|
504
|
+
: `技能 "${request.name}" 的文件不在可管理的技能根目录内`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const text = readFileSync(entry.path, 'utf8');
|
|
508
|
+
return {
|
|
509
|
+
ok: true,
|
|
510
|
+
name: request.name,
|
|
511
|
+
path: entry.path,
|
|
512
|
+
rootId: entry.root.id,
|
|
513
|
+
text,
|
|
514
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 改写一个技能的调用策略(模型可调用 / 用户可调用)。
|
|
520
|
+
* @param ctx - 宿主上下文。
|
|
521
|
+
* @param request - `{ sessionId, cwd, name, modelInvocable, userInvocable }`。
|
|
522
|
+
* @returns 响应体。
|
|
523
|
+
*/
|
|
524
|
+
async function opSetInvocation(ctx, request) {
|
|
525
|
+
const invocation = {
|
|
526
|
+
modelInvocable: request.modelInvocable !== false,
|
|
527
|
+
userInvocable: request.userInvocable !== false,
|
|
528
|
+
};
|
|
529
|
+
if (request.modelInvocable === undefined && request.userInvocable === undefined) {
|
|
530
|
+
return { ok: false, error: '至少要指定 modelInvocable 或 userInvocable' };
|
|
531
|
+
}
|
|
532
|
+
const catalog = await readCatalog(ctx, request);
|
|
533
|
+
const entry = locateOnDisk(request.name, catalog.roots);
|
|
534
|
+
if (entry === undefined) {
|
|
535
|
+
return { ok: false, error: `技能 "${request.name}" 没有可写的本地文件(插件内嵌技能无法改写)` };
|
|
536
|
+
}
|
|
537
|
+
const before = readFileSync(entry.path, 'utf8');
|
|
538
|
+
const after = applyInvocation(before, invocation);
|
|
539
|
+
if (after !== before) writeFileSync(entry.path, after, 'utf8');
|
|
540
|
+
return {
|
|
541
|
+
ok: true,
|
|
542
|
+
name: request.name,
|
|
543
|
+
path: entry.path,
|
|
544
|
+
changed: after !== before,
|
|
545
|
+
modelInvocable: invocation.modelInvocable,
|
|
546
|
+
userInvocable: invocation.userInvocable,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 新建一个技能目录包。
|
|
552
|
+
* @param ctx - 宿主上下文。
|
|
553
|
+
* @param request - `{ sessionId, cwd, name, description, body, rootId, modelInvocable, userInvocable }`。
|
|
554
|
+
* @returns 响应体。
|
|
555
|
+
*/
|
|
556
|
+
async function opCreate(ctx, request) {
|
|
557
|
+
const skillName = String(request.name ?? '').trim();
|
|
558
|
+
if (!SKILL_NAME.test(skillName)) {
|
|
559
|
+
return { ok: false, error: `技能名 "${skillName}" 不合法:只允许小写字母、数字与单个连字符(kebab-case)` };
|
|
560
|
+
}
|
|
561
|
+
const description = String(request.description ?? '').trim();
|
|
562
|
+
if (description === '') return { ok: false, error: 'description 必填' };
|
|
563
|
+
|
|
564
|
+
const catalog = await readCatalog(ctx, request);
|
|
565
|
+
if (catalog.skills.some((skill) => skill.name === skillName)) {
|
|
566
|
+
return { ok: false, error: `技能 "${skillName}" 已存在` };
|
|
567
|
+
}
|
|
568
|
+
const roots = catalog.roots.filter((root) => root.exists || root.id.startsWith('user-'));
|
|
569
|
+
const root = roots.find((candidate) => candidate.id === request.rootId)
|
|
570
|
+
?? roots.find((candidate) => candidate.id === 'user-agents')
|
|
571
|
+
?? roots[0];
|
|
572
|
+
if (root === undefined) return { ok: false, error: '没有可用的技能根目录' };
|
|
573
|
+
|
|
574
|
+
const dir = join(root.path, skillName);
|
|
575
|
+
if (existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` };
|
|
576
|
+
const invocation = {
|
|
577
|
+
modelInvocable: request.modelInvocable !== false,
|
|
578
|
+
userInvocable: request.userInvocable !== false,
|
|
579
|
+
};
|
|
580
|
+
const lines = ['---', `name: ${skillName}`, `description: ${JSON.stringify(description)}`];
|
|
581
|
+
if (invocation.modelInvocable === false) lines.push('disable-model-invocation: true');
|
|
582
|
+
if (invocation.userInvocable === false) lines.push('user-invocable: false');
|
|
583
|
+
lines.push('---', '');
|
|
584
|
+
const body = String(request.body ?? '').trim();
|
|
585
|
+
lines.push(body === '' ? `# ${skillName}` : body, '');
|
|
586
|
+
mkdirSync(dir, { recursive: true });
|
|
587
|
+
const path = join(dir, 'SKILL.md');
|
|
588
|
+
writeFileSync(path, lines.join('\n'), 'utf8');
|
|
589
|
+
return { ok: true, name: skillName, path, rootId: root.id };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* 删除一个技能:移动进同根的 `.trash/<时间戳>-<名字>`(可恢复,不硬删)。
|
|
594
|
+
* @param ctx - 宿主上下文。
|
|
595
|
+
* @param request - `{ sessionId, cwd, name }`。
|
|
596
|
+
* @returns 响应体。
|
|
597
|
+
*/
|
|
598
|
+
async function opDelete(ctx, request) {
|
|
599
|
+
const catalog = await readCatalog(ctx, request);
|
|
600
|
+
if (!catalog.skills.some((skill) => skill.name === request.name)) {
|
|
601
|
+
return { ok: false, error: `技能 "${request.name}" 不在当前技能目录中` };
|
|
602
|
+
}
|
|
603
|
+
const entry = locateOnDisk(request.name, catalog.roots);
|
|
604
|
+
if (entry === undefined) {
|
|
605
|
+
return { ok: false, error: `技能 "${request.name}" 没有可删除的本地文件(插件内嵌技能无法删除)` };
|
|
606
|
+
}
|
|
607
|
+
const trash = join(entry.root.path, '.trash');
|
|
608
|
+
mkdirSync(trash, { recursive: true });
|
|
609
|
+
const stamp = Date.now();
|
|
610
|
+
const target = entry.flat
|
|
611
|
+
? join(trash, `${stamp}-${request.name}.md`)
|
|
612
|
+
: join(trash, `${stamp}-${request.name}`);
|
|
613
|
+
renameSync(entry.dir, target);
|
|
614
|
+
return { ok: true, name: request.name, movedTo: target, rootId: entry.root.id };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 路由表:op → 处理器。 */
|
|
618
|
+
const OPERATIONS = {
|
|
619
|
+
ping: async () => ({ ok: true, plugin: 'dsh-skill-center', version: 1 }),
|
|
620
|
+
diagnose: opDiagnose,
|
|
621
|
+
list: opList,
|
|
622
|
+
read: opRead,
|
|
623
|
+
setInvocation: opSetInvocation,
|
|
624
|
+
create: opCreate,
|
|
625
|
+
delete: opDelete,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
// 插件体
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 挂载回环路由,向客户端暴露技能目录与管理操作。
|
|
634
|
+
* @param ctx - 宿主上下文(已保证 skills 与 webServer 就绪)。
|
|
635
|
+
*/
|
|
636
|
+
export function apply(ctx) {
|
|
637
|
+
const webServer = ctx.webServer ?? ctx.get('webServer');
|
|
638
|
+
if (webServer === undefined) return;
|
|
639
|
+
|
|
640
|
+
ctx.effect(() => webServer.register({
|
|
641
|
+
kind: 'prefix',
|
|
642
|
+
path: ROUTE,
|
|
643
|
+
async handler(req, res) {
|
|
644
|
+
if (denyNonLoopback(req, res)) return;
|
|
645
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
646
|
+
const pathname = url.pathname.startsWith(ROUTE) ? url.pathname.slice(ROUTE.length) : '';
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
if (req.method === 'GET') {
|
|
650
|
+
const op = pathname === '/api' || pathname === '' ? url.searchParams.get('op') ?? 'list' : pathname.replace(/^\//u, '');
|
|
651
|
+
const handler = OPERATIONS[op];
|
|
652
|
+
if (handler === undefined) {
|
|
653
|
+
sendJson(res, { ok: false, error: `未知操作 "${op}"` }, 404);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const payload = await handler(ctx, {
|
|
657
|
+
sessionId: url.searchParams.get('sessionId') ?? undefined,
|
|
658
|
+
cwd: url.searchParams.get('cwd') ?? undefined,
|
|
659
|
+
name: url.searchParams.get('name') ?? undefined,
|
|
660
|
+
});
|
|
661
|
+
sendJson(res, payload, payload.ok ? 200 : 400);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (req.method === 'POST') {
|
|
666
|
+
let body;
|
|
667
|
+
try {
|
|
668
|
+
body = JSON.parse(await readBody(req));
|
|
669
|
+
} catch {
|
|
670
|
+
sendJson(res, { ok: false, error: '无效的 JSON 请求体' }, 400);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const op = String(body?.op ?? pathname.replace(/^\//u, ''));
|
|
674
|
+
const handler = OPERATIONS[op];
|
|
675
|
+
if (handler === undefined) {
|
|
676
|
+
sendJson(res, { ok: false, error: `未知操作 "${op}"` }, 404);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const payload = await handler(ctx, body ?? {});
|
|
680
|
+
sendJson(res, payload, payload.ok ? 200 : 400);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
sendJson(res, { ok: false, error: '只支持 GET 与 POST' }, 405);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
ctx.logger?.warn?.(`skill-center: ${error?.stack ?? error}`);
|
|
687
|
+
sendJson(res, { ok: false, error: String(error?.message ?? error) }, 500);
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
}), 'skill-center: loopback routes');
|
|
691
|
+
}
|