@aalis/plugin-skills 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1065 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createStorageGateway } from '@aalis/plugin-storage-api';
3
+ import { useToolService } from '@aalis/plugin-tools-api';
4
+ import { useWebuiService } from '@aalis/plugin-webui-api';
5
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
6
+ import '@aalis/plugin-agent-api';
7
+ import '@aalis/plugin-tools-api';
8
+ // ──────────── 插件元数据 ────────────
9
+ export const name = '@aalis/plugin-skills';
10
+ export const displayName = '技能系统 (Agent Skills)';
11
+ export const subsystem = 'skills';
12
+ export const provides = ['skills'];
13
+ export const configSchema = {
14
+ skillsUri: {
15
+ type: 'string',
16
+ label: '技能存储 URI',
17
+ default: 'data:/skills',
18
+ description: '技能文件夹 storage URI(默认 data:/skills)。每个 skill 为一个子目录,含 SKILL.md。',
19
+ },
20
+ maxSkillBytes: {
21
+ type: 'number',
22
+ label: '单 skill 最大字节数',
23
+ default: 200_000,
24
+ description: 'SKILL.md 单文件最大字节数,避免一次加载过大内容污染上下文。',
25
+ },
26
+ maxSkills: {
27
+ type: 'number',
28
+ label: '技能数量上限',
29
+ default: 200,
30
+ description: '扫描时最多加载的 skill 数量。',
31
+ },
32
+ discoveryEnabled: {
33
+ type: 'boolean',
34
+ label: '启用 Discovery 注入',
35
+ default: true,
36
+ description: '启动时把所有 skill 的 name+description 列表注入 system prompt,方便 agent 按需 load_skill。',
37
+ },
38
+ triggersEnabled: {
39
+ type: 'boolean',
40
+ label: '启用 triggers 自动激活',
41
+ default: true,
42
+ description: '匹配 SKILL.md frontmatter 中的 triggers regex 时自动加载该 skill。',
43
+ },
44
+ };
45
+ export const defaultConfig = {
46
+ skillsUri: 'data:/skills',
47
+ maxSkillBytes: 200_000,
48
+ maxSkills: 200,
49
+ discoveryEnabled: true,
50
+ triggersEnabled: true,
51
+ };
52
+ // ──────────── WebUI 页面 ────────────
53
+ const webuiPages = [
54
+ {
55
+ key: 'skills',
56
+ label: '技能库 (Skills)',
57
+ icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>',
58
+ order: 56,
59
+ content: [
60
+ {
61
+ type: 'table',
62
+ label: '技能列表',
63
+ source: 'listSkills',
64
+ columns: [
65
+ { key: 'name', label: '名称', nowrap: true },
66
+ { key: 'description', label: '描述', minWidth: 220, maxWidth: 360, render: 'expandable-text' },
67
+ { key: 'triggers', label: '自动触发', nowrap: true },
68
+ { key: 'fileCount', label: '资源', nowrap: true, minWidth: 160 },
69
+ { key: 'dir', label: '路径', nowrap: true },
70
+ ],
71
+ actions: [
72
+ { label: '查看', method: 'getSkill' },
73
+ { label: '删除', method: 'deleteSkill', confirm: '确定删除该技能?', danger: true },
74
+ ],
75
+ refresh: 60,
76
+ },
77
+ {
78
+ type: 'stat',
79
+ label: '技能总数',
80
+ source: 'getStats',
81
+ icon: 'skills',
82
+ },
83
+ ],
84
+ },
85
+ ];
86
+ export const actions = {
87
+ async listSkills(ctx) {
88
+ const svc = ctx.getService('skills');
89
+ if (!svc)
90
+ return [];
91
+ return svc.listSkills().map(s => ({
92
+ name: s.name,
93
+ description: s.description,
94
+ triggers: s.triggers?.join(', ') || '',
95
+ fileCount: `脚本 ${s.scripts.length} / 引用 ${s.references.length} / 资源 ${s.assets.length}`,
96
+ dir: s.uri,
97
+ }));
98
+ },
99
+ async getSkill(ctx, args) {
100
+ const svc = ctx.getService('skills');
101
+ const s = svc?.getSkill(args.name);
102
+ if (!s)
103
+ return { error: '技能不存在' };
104
+ return {
105
+ name: s.name,
106
+ description: s.description,
107
+ triggers: s.triggers,
108
+ license: s.license,
109
+ dir: s.uri,
110
+ scripts: s.scripts,
111
+ references: s.references,
112
+ assets: s.assets,
113
+ raw: s.raw,
114
+ };
115
+ },
116
+ async deleteSkill(ctx, args) {
117
+ const svc = ctx.getService('skills');
118
+ return (await svc?.deleteSkill(args.name)) ? { ok: true } : { error: '技能不存在' };
119
+ },
120
+ async getStats(ctx) {
121
+ const svc = ctx.getService('skills');
122
+ return { value: svc?.listSkills().length ?? 0 };
123
+ },
124
+ };
125
+ // ──────────── frontmatter 解析 ────────────
126
+ /** 解析 SKILL.md:分离 YAML frontmatter 与 markdown body。
127
+ * frontmatter 必须以 `---\n` 起头(第一行),以下一行 `---` 结束。 */
128
+ function parseSkillMd(text) {
129
+ if (!text.startsWith('---'))
130
+ return { fm: null, body: text };
131
+ // 找到第二个 `---` 行
132
+ const lines = text.split(/\r?\n/);
133
+ if (lines[0].trim() !== '---')
134
+ return { fm: null, body: text };
135
+ let endIdx = -1;
136
+ for (let i = 1; i < lines.length; i++) {
137
+ if (lines[i].trim() === '---') {
138
+ endIdx = i;
139
+ break;
140
+ }
141
+ }
142
+ if (endIdx < 0)
143
+ return { fm: null, body: text };
144
+ const fmText = lines.slice(1, endIdx).join('\n');
145
+ const body = lines
146
+ .slice(endIdx + 1)
147
+ .join('\n')
148
+ .replace(/^\n+/, '');
149
+ try {
150
+ const parsed = parseYaml(fmText);
151
+ if (!parsed || typeof parsed !== 'object')
152
+ return { fm: null, body: text };
153
+ return { fm: parsed, body };
154
+ }
155
+ catch {
156
+ return { fm: null, body: text };
157
+ }
158
+ }
159
+ /** 生成 SKILL.md 文本(frontmatter + body) */
160
+ function buildSkillMd(fm, body) {
161
+ // 过滤 undefined
162
+ const clean = {};
163
+ for (const [k, v] of Object.entries(fm)) {
164
+ if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)) {
165
+ clean[k] = v;
166
+ }
167
+ }
168
+ const fmText = stringifyYaml(clean, { lineWidth: 0 }).trimEnd();
169
+ return `---\n${fmText}\n---\n\n${body.replace(/^\n+/, '')}`;
170
+ }
171
+ // ──────────── 辅助 ────────────
172
+ function resolveConfig(raw) {
173
+ // 向后兼容:旧字段 skillsDir(如 'data/skills') → storage URI 'data:/skills'
174
+ let skillsUri = raw.skillsUri ?? defaultConfig.skillsUri;
175
+ if (!raw.skillsUri && typeof raw.skillsDir === 'string') {
176
+ const s = raw.skillsDir.trim().replace(/^\.?\/+/, '');
177
+ const slashIdx = s.indexOf('/');
178
+ if (slashIdx > 0) {
179
+ skillsUri = `${s.slice(0, slashIdx)}:/${s.slice(slashIdx + 1)}`;
180
+ }
181
+ else if (s) {
182
+ skillsUri = `${s}:/`;
183
+ }
184
+ }
185
+ return {
186
+ skillsUri,
187
+ maxSkillBytes: raw.maxSkillBytes ?? defaultConfig.maxSkillBytes,
188
+ maxSkills: raw.maxSkills ?? defaultConfig.maxSkills,
189
+ discoveryEnabled: raw.discoveryEnabled ?? defaultConfig.discoveryEnabled,
190
+ triggersEnabled: raw.triggersEnabled ?? defaultConfig.triggersEnabled,
191
+ };
192
+ }
193
+ function sanitizeFolderName(name) {
194
+ // 文件夹名仅允许 ASCII 字母数字 / -_,其他字符替换为 _;中文保留为 _
195
+ const cleaned = name
196
+ .replace(/[^a-zA-Z0-9_\-\u4e00-\u9fff]/g, '_')
197
+ .replace(/_+/g, '_')
198
+ .replace(/^_|_$/g, '')
199
+ .slice(0, 100);
200
+ return cleaned || 'skill';
201
+ }
202
+ async function safeListFiles(storage, uri) {
203
+ const out = [];
204
+ const walk = async (subUri, prefix) => {
205
+ let result;
206
+ try {
207
+ result = await storage.list(subUri);
208
+ }
209
+ catch {
210
+ return;
211
+ }
212
+ for (const entry of result.entries) {
213
+ if (entry.isDirectory)
214
+ await walk(entry.uri, prefix ? `${prefix}/${entry.name}` : entry.name);
215
+ else
216
+ out.push(prefix ? `${prefix}/${entry.name}` : entry.name);
217
+ }
218
+ };
219
+ await walk(uri, '');
220
+ return out.sort();
221
+ }
222
+ /** join storage URI 下子路径:abc:/x + y → abc:/x/y */
223
+ function joinUri(base, sub) {
224
+ const s = sub.replace(/^\/+/, '');
225
+ if (base.endsWith('/'))
226
+ return base + s;
227
+ return `${base}/${s}`;
228
+ }
229
+ /**
230
+ * 校验 skill 内附属文件的相对路径:
231
+ * - 不能是 SKILL.md(由专用 frontmatter/body 写入)
232
+ * - 不能为空、不能以 `/` 开头
233
+ * - 不能包含 `..` 段
234
+ * - 不能是 Windows 绝对路径(如 `C:\\`)
235
+ * 返回规范化后的相对路径(统一用 `/`)。
236
+ */
237
+ function validateSkillRelPath(relPath) {
238
+ if (!relPath || typeof relPath !== 'string') {
239
+ throw new Error('relPath 必填且需为字符串');
240
+ }
241
+ const trimmed = relPath.trim().replace(/\\/g, '/');
242
+ if (!trimmed)
243
+ throw new Error('relPath 不能为空');
244
+ if (trimmed.startsWith('/'))
245
+ throw new Error(`relPath 不能以 / 开头: ${relPath}`);
246
+ if (/^[a-zA-Z]:/.test(trimmed))
247
+ throw new Error(`relPath 不能为绝对路径: ${relPath}`);
248
+ const segs = trimmed.split('/');
249
+ for (const seg of segs) {
250
+ if (seg === '' || seg === '.' || seg === '..') {
251
+ throw new Error(`relPath 含非法段 "${seg}": ${relPath}`);
252
+ }
253
+ }
254
+ if (segs[segs.length - 1].toUpperCase() === 'SKILL.MD') {
255
+ throw new Error('SKILL.md 请通过 description/body/frontmatter 字段更新,不要作为附属文件写入');
256
+ }
257
+ return trimmed;
258
+ }
259
+ // ──────────── 插件入口 ────────────
260
+ export function apply(ctx, rawConfig) {
261
+ const config = resolveConfig(rawConfig);
262
+ const logger = ctx.logger.child('skills');
263
+ // 通过 storage gateway 访问 skills 目录;不直接耦合 fs。
264
+ const storage = createStorageGateway(ctx);
265
+ const skillsUri = config.skillsUri;
266
+ // WebUI 页面
267
+ const webui = useWebuiService(ctx);
268
+ for (const page of webuiPages)
269
+ webui.registerPage(page);
270
+ // ── 加载所有 skill ──
271
+ /** key = skill.name */
272
+ const skillsCache = new Map();
273
+ async function loadSkillFromDir(dirUri) {
274
+ const skillMdUri = joinUri(dirUri, 'SKILL.md');
275
+ let raw;
276
+ try {
277
+ const st = await storage.stat(skillMdUri);
278
+ if (st.size > config.maxSkillBytes) {
279
+ logger.warn(`SKILL.md 超出大小限制 ${st.size}B > ${config.maxSkillBytes}B: ${dirUri}`);
280
+ return null;
281
+ }
282
+ raw = (await storage.readFile(skillMdUri, 'utf-8'));
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ try {
288
+ const { fm, body } = parseSkillMd(raw);
289
+ if (!fm || typeof fm.name !== 'string' || typeof fm.description !== 'string') {
290
+ logger.warn(`SKILL.md frontmatter 缺少 name/description: ${dirUri}`);
291
+ return null;
292
+ }
293
+ const triggers = Array.isArray(fm.triggers) ? fm.triggers.filter(t => typeof t === 'string') : undefined;
294
+ return {
295
+ name: fm.name,
296
+ description: fm.description,
297
+ body,
298
+ triggers,
299
+ license: typeof fm.license === 'string' ? fm.license : undefined,
300
+ uri: dirUri,
301
+ scripts: await safeListFiles(storage, joinUri(dirUri, 'scripts')),
302
+ references: await safeListFiles(storage, joinUri(dirUri, 'references')),
303
+ assets: await safeListFiles(storage, joinUri(dirUri, 'assets')),
304
+ raw,
305
+ };
306
+ }
307
+ catch (err) {
308
+ logger.warn(`加载 SKILL.md 失败: ${dirUri} - ${err}`);
309
+ return null;
310
+ }
311
+ }
312
+ async function rescanSkills() {
313
+ skillsCache.clear();
314
+ let count = 0;
315
+ const walk = async (uri, depth) => {
316
+ if (count >= config.maxSkills || depth > 4)
317
+ return;
318
+ let result;
319
+ try {
320
+ result = await storage.list(uri);
321
+ }
322
+ catch {
323
+ return;
324
+ }
325
+ for (const entry of result.entries) {
326
+ if (count >= config.maxSkills)
327
+ return;
328
+ if (entry.name.startsWith('.'))
329
+ continue;
330
+ if (!entry.isDirectory)
331
+ continue;
332
+ const skill = await loadSkillFromDir(entry.uri);
333
+ if (skill) {
334
+ if (skillsCache.has(skill.name)) {
335
+ logger.warn(`重复 skill 名称 "${skill.name}",跳过 ${entry.uri}(已存在于 ${skillsCache.get(skill.name)?.uri})`);
336
+ continue;
337
+ }
338
+ skillsCache.set(skill.name, skill);
339
+ count++;
340
+ }
341
+ else {
342
+ // 没有 SKILL.md 的目录递归向下找
343
+ await walk(entry.uri, depth + 1);
344
+ }
345
+ }
346
+ };
347
+ await walk(skillsUri, 0);
348
+ }
349
+ // ── 编译 triggers regex(缓存) ──
350
+ const compiledTriggers = new Map();
351
+ function getTriggersFor(skill) {
352
+ if (!skill.triggers || skill.triggers.length === 0)
353
+ return [];
354
+ const cached = compiledTriggers.get(skill.name);
355
+ if (cached)
356
+ return cached;
357
+ const list = [];
358
+ for (const t of skill.triggers) {
359
+ try {
360
+ list.push(new RegExp(t, 'i'));
361
+ }
362
+ catch (err) {
363
+ logger.warn(`skill "${skill.name}" trigger regex 编译失败 "${t}": ${err}`);
364
+ }
365
+ }
366
+ compiledTriggers.set(skill.name, list);
367
+ return list;
368
+ }
369
+ // ── session 加载状态 ──
370
+ /** sessionId → Set<skillName>,已加载 = 下次 LLM 调用注入 body */
371
+ const sessionLoaded = new Map();
372
+ function ensureSessionSet(sessionId) {
373
+ let s = sessionLoaded.get(sessionId);
374
+ if (!s) {
375
+ s = new Set();
376
+ sessionLoaded.set(sessionId, s);
377
+ }
378
+ return s;
379
+ }
380
+ // ── 获取 persona 允许的 skill 白名单 ──
381
+ function getAllowedSkills() {
382
+ const all = [...skillsCache.values()];
383
+ const persona = ctx.getService('persona');
384
+ const whitelist = persona?.getPersonaSkills?.();
385
+ if (whitelist === undefined)
386
+ return all;
387
+ const set = new Set(whitelist);
388
+ return all.filter(s => set.has(s.name));
389
+ }
390
+ // ── 注入源标记 ──
391
+ const DISCOVERY_SOURCE = 'skills-discovery';
392
+ const ACTIVATION_SOURCE_PREFIX = 'skills-activation:';
393
+ // ── Discovery: agent:llm:before 注入可用 skill 列表 ──
394
+ if (config.discoveryEnabled) {
395
+ ctx.middleware('agent:llm:before', async (data, next) => {
396
+ // 防重:同一 messages 数组中已有 discovery system 块就跳过
397
+ const hasDiscovery = data.messages.some(m => m.role === 'system' && m.metadata?.injector === DISCOVERY_SOURCE);
398
+ const visible = getAllowedSkills();
399
+ if (!hasDiscovery && visible.length > 0) {
400
+ const lines = visible.map(s => `- ${s.name}: ${s.description}`);
401
+ const block = `📚 可用技能(共 ${visible.length},按需调用 load_skill(name="...") 加载完整指令):\n${lines.join('\n')}\n\n` +
402
+ '当你识别到当前任务匹配某个技能时,先调用 load_skill 获取详细操作步骤,再继续执行。';
403
+ const idx = data.messages.findIndex(m => m.role !== 'system');
404
+ const insertIdx = idx === -1 ? data.messages.length : idx;
405
+ data.messages.splice(insertIdx, 0, {
406
+ role: 'system',
407
+ content: block,
408
+ metadata: { injector: DISCOVERY_SOURCE },
409
+ });
410
+ }
411
+ // 注入 session 已激活的 skill body(每个 skill 一个 system 块,按需)
412
+ if (data.sessionId) {
413
+ const loaded = sessionLoaded.get(data.sessionId);
414
+ if (loaded && loaded.size > 0) {
415
+ for (const skillName of loaded) {
416
+ const sourceTag = ACTIVATION_SOURCE_PREFIX + skillName;
417
+ const already = data.messages.some(m => m.role === 'system' && m.metadata?.injector === sourceTag);
418
+ if (already)
419
+ continue;
420
+ const skill = skillsCache.get(skillName);
421
+ if (!skill)
422
+ continue;
423
+ const resourceLines = [];
424
+ if (skill.scripts.length > 0)
425
+ resourceLines.push(`- scripts/: ${skill.scripts.join(', ')}(可用 code_runner 等工具执行)`);
426
+ if (skill.references.length > 0)
427
+ resourceLines.push(`- references/: ${skill.references.join(', ')}(按需读取该文件获取详细参考)`);
428
+ if (skill.assets.length > 0)
429
+ resourceLines.push(`- assets/: ${skill.assets.join(', ')}(模板/资源)`);
430
+ const resources = resourceLines.length > 0 ? `\n\n附属资源(位于 ${skill.uri}):\n${resourceLines.join('\n')}` : '';
431
+ const block = `═══ Skill 已激活: ${skill.name} ═══\n${skill.body}${resources}\n═════════════════════════════`;
432
+ const idx = data.messages.findIndex(m => m.role !== 'system');
433
+ const insertIdx = idx === -1 ? data.messages.length : idx;
434
+ data.messages.splice(insertIdx, 0, {
435
+ role: 'system',
436
+ content: block,
437
+ metadata: { injector: sourceTag },
438
+ });
439
+ }
440
+ }
441
+ }
442
+ await next();
443
+ });
444
+ }
445
+ // ── 自动激活:agent:input:before 监听 user message 文本 ──
446
+ if (config.triggersEnabled) {
447
+ ctx.middleware('agent:input:before', async (data, next) => {
448
+ const sessionId = data.message?.sessionId;
449
+ const text = data.message?.content;
450
+ if (sessionId && typeof text === 'string' && text.length > 0) {
451
+ const visible = getAllowedSkills();
452
+ const session = ensureSessionSet(sessionId);
453
+ for (const skill of visible) {
454
+ if (session.has(skill.name))
455
+ continue;
456
+ const regexes = getTriggersFor(skill);
457
+ for (const re of regexes) {
458
+ if (re.test(text)) {
459
+ session.add(skill.name);
460
+ logger.info(`skill "${skill.name}" 已自动激活 (session=${sessionId}, regex=${re})`);
461
+ break;
462
+ }
463
+ }
464
+ }
465
+ }
466
+ await next();
467
+ });
468
+ }
469
+ // ── 服务实现 ──
470
+ const service = {
471
+ listSkills() {
472
+ return [...skillsCache.values()];
473
+ },
474
+ getSkill(skillName) {
475
+ return skillsCache.get(skillName);
476
+ },
477
+ async createSkill(input) {
478
+ if (skillsCache.size >= config.maxSkills) {
479
+ throw new Error(`技能数量已达上限 (${config.maxSkills})`);
480
+ }
481
+ if (!input.name || !input.description) {
482
+ throw new Error('name 和 description 必填');
483
+ }
484
+ if (skillsCache.has(input.name)) {
485
+ throw new Error(`技能 "${input.name}" 已存在`);
486
+ }
487
+ const folderName = sanitizeFolderName(input.name);
488
+ const dirUri = joinUri(skillsUri, folderName);
489
+ // 检查目标目录是否已存在(通过 stat 探测)
490
+ try {
491
+ await storage.stat(dirUri);
492
+ throw new Error(`目标目录已存在: ${dirUri}`);
493
+ }
494
+ catch (err) {
495
+ if (err instanceof Error && err.message.startsWith('目标目录已存在'))
496
+ throw err;
497
+ // ENOENT / not found → 可以创建
498
+ }
499
+ const fm = {
500
+ ...(input.frontmatter ?? {}),
501
+ name: input.name,
502
+ description: input.description,
503
+ ...(input.triggers && input.triggers.length > 0 ? { triggers: input.triggers } : {}),
504
+ ...(input.license ? { license: input.license } : {}),
505
+ };
506
+ const md = buildSkillMd(fm, input.body ?? '');
507
+ if (md.length > config.maxSkillBytes) {
508
+ throw new Error(`SKILL.md 超出大小限制 ${md.length}B > ${config.maxSkillBytes}B`);
509
+ }
510
+ // writeFile 会自动创建父目录
511
+ await storage.writeFile(joinUri(dirUri, 'SKILL.md'), md);
512
+ // 写入附属文件
513
+ if (input.files && input.files.length > 0) {
514
+ for (const f of input.files) {
515
+ const rel = validateSkillRelPath(f.relPath);
516
+ const byteLen = typeof f.content === 'string' ? Buffer.byteLength(f.content, 'utf-8') : f.content.byteLength;
517
+ if (byteLen > config.maxSkillBytes) {
518
+ throw new Error(`附属文件 ${rel} 超出大小限制 ${byteLen}B > ${config.maxSkillBytes}B`);
519
+ }
520
+ await storage.writeFile(joinUri(dirUri, rel), typeof f.content === 'string' ? f.content : Buffer.from(f.content));
521
+ }
522
+ }
523
+ const loaded = await loadSkillFromDir(dirUri);
524
+ if (loaded)
525
+ skillsCache.set(loaded.name, loaded);
526
+ logger.info(`技能已创建: ${input.name} (${dirUri})${input.files && input.files.length > 0 ? ` + ${input.files.length} 个附属文件` : ''}`);
527
+ },
528
+ async updateSkill(skillName, updates) {
529
+ const existing = skillsCache.get(skillName);
530
+ if (!existing)
531
+ return false;
532
+ const { fm: oldFm } = parseSkillMd(existing.raw);
533
+ const fm = {
534
+ ...(updates.frontmatter ?? {}),
535
+ ...(oldFm ?? {
536
+ name: existing.name,
537
+ description: existing.description,
538
+ ...(existing.triggers ? { triggers: existing.triggers } : {}),
539
+ ...(existing.license ? { license: existing.license } : {}),
540
+ }),
541
+ };
542
+ if (updates.description !== undefined)
543
+ fm.description = updates.description;
544
+ if (updates.triggers !== undefined)
545
+ fm.triggers = updates.triggers;
546
+ if (updates.license !== undefined)
547
+ fm.license = updates.license;
548
+ const body = updates.body !== undefined ? updates.body : existing.body;
549
+ const md = buildSkillMd(fm, body);
550
+ if (md.length > config.maxSkillBytes) {
551
+ throw new Error(`SKILL.md 超出大小限制 ${md.length}B > ${config.maxSkillBytes}B`);
552
+ }
553
+ await storage.writeFile(joinUri(existing.uri, 'SKILL.md'), md);
554
+ if (updates.files && updates.files.length > 0) {
555
+ for (const f of updates.files) {
556
+ const rel = validateSkillRelPath(f.relPath);
557
+ const byteLen = typeof f.content === 'string' ? Buffer.byteLength(f.content, 'utf-8') : f.content.byteLength;
558
+ if (byteLen > config.maxSkillBytes) {
559
+ throw new Error(`附属文件 ${rel} 超出大小限制 ${byteLen}B > ${config.maxSkillBytes}B`);
560
+ }
561
+ await storage.writeFile(joinUri(existing.uri, rel), typeof f.content === 'string' ? f.content : Buffer.from(f.content));
562
+ }
563
+ }
564
+ const reloaded = await loadSkillFromDir(existing.uri);
565
+ if (reloaded) {
566
+ skillsCache.set(reloaded.name, reloaded);
567
+ compiledTriggers.delete(reloaded.name);
568
+ }
569
+ logger.info(`技能已更新: ${skillName}`);
570
+ return true;
571
+ },
572
+ async deleteSkill(skillName) {
573
+ const existing = skillsCache.get(skillName);
574
+ if (!existing)
575
+ return false;
576
+ try {
577
+ await storage.delete(existing.uri);
578
+ }
579
+ catch (err) {
580
+ logger.warn(`删除技能目录失败 ${existing.uri}: ${err}`);
581
+ }
582
+ skillsCache.delete(skillName);
583
+ compiledTriggers.delete(skillName);
584
+ for (const set of sessionLoaded.values())
585
+ set.delete(skillName);
586
+ logger.info(`技能已删除: ${skillName}`);
587
+ return true;
588
+ },
589
+ async addSkillFile(skillName, file) {
590
+ const existing = skillsCache.get(skillName);
591
+ if (!existing)
592
+ return false;
593
+ const rel = validateSkillRelPath(file.relPath);
594
+ const byteLen = typeof file.content === 'string' ? Buffer.byteLength(file.content, 'utf-8') : file.content.byteLength;
595
+ if (byteLen > config.maxSkillBytes) {
596
+ throw new Error(`附属文件 ${rel} 超出大小限制 ${byteLen}B > ${config.maxSkillBytes}B`);
597
+ }
598
+ await storage.writeFile(joinUri(existing.uri, rel), typeof file.content === 'string' ? file.content : Buffer.from(file.content));
599
+ const reloaded = await loadSkillFromDir(existing.uri);
600
+ if (reloaded)
601
+ skillsCache.set(reloaded.name, reloaded);
602
+ logger.info(`技能 ${skillName} 已写入附属文件: ${rel}`);
603
+ return true;
604
+ },
605
+ async removeSkillFile(skillName, relPath) {
606
+ const existing = skillsCache.get(skillName);
607
+ if (!existing)
608
+ return false;
609
+ const rel = validateSkillRelPath(relPath);
610
+ try {
611
+ await storage.delete(joinUri(existing.uri, rel));
612
+ }
613
+ catch (err) {
614
+ logger.warn(`删除附属文件失败 ${rel}: ${err}`);
615
+ return false;
616
+ }
617
+ const reloaded = await loadSkillFromDir(existing.uri);
618
+ if (reloaded)
619
+ skillsCache.set(reloaded.name, reloaded);
620
+ logger.info(`技能 ${skillName} 已删除附属文件: ${rel}`);
621
+ return true;
622
+ },
623
+ async listSkillFiles(skillName) {
624
+ const existing = skillsCache.get(skillName);
625
+ if (!existing)
626
+ return [];
627
+ const all = await safeListFiles(storage, existing.uri);
628
+ return all.filter(p => p !== 'SKILL.md');
629
+ },
630
+ async readSkillFile(skillName, relPath) {
631
+ const existing = skillsCache.get(skillName);
632
+ if (!existing)
633
+ return null;
634
+ const rel = validateSkillRelPath(relPath);
635
+ try {
636
+ const raw = (await storage.readFile(joinUri(existing.uri, rel)));
637
+ return Buffer.from(raw).toString('utf-8');
638
+ }
639
+ catch {
640
+ return null;
641
+ }
642
+ },
643
+ async rescan() {
644
+ await rescanSkills();
645
+ compiledTriggers.clear();
646
+ },
647
+ loadSkillForSession(sessionId, skillName) {
648
+ if (!skillsCache.has(skillName))
649
+ return false;
650
+ ensureSessionSet(sessionId).add(skillName);
651
+ return true;
652
+ },
653
+ getLoadedSkills(sessionId) {
654
+ const s = sessionLoaded.get(sessionId);
655
+ return s ? [...s] : [];
656
+ },
657
+ };
658
+ ctx.provide('skills', service);
659
+ // ── 注册工具分组与工具 ──
660
+ const tools = useToolService(ctx);
661
+ tools.registerGroup({
662
+ name: 'skills',
663
+ label: '技能管理',
664
+ description: '查询、加载、创建、更新和删除可复用的 Agent Skills(兼容 Anthropic SKILL.md 标准)',
665
+ });
666
+ // 1. load_skill —— 核心:把指定 skill 的 SKILL.md body 注入下一轮上下文
667
+ tools.register({
668
+ groups: ['skills'],
669
+ definition: {
670
+ type: 'function',
671
+ function: {
672
+ name: 'load_skill',
673
+ description: '加载指定 skill 的完整 SKILL.md 内容到当前会话上下文。加载后下一次模型调用会自动看到该 skill 的详细指令与附属资源清单(scripts/references/assets)。当 system prompt 中的"可用技能列表"里某条 description 匹配当前任务时调用本工具。',
674
+ parameters: {
675
+ type: 'object',
676
+ properties: {
677
+ name: { type: 'string', description: '要加载的 skill 名称(必须是已存在的 skill)' },
678
+ },
679
+ required: ['name'],
680
+ },
681
+ },
682
+ },
683
+ handler: async (args, callCtx) => {
684
+ const skillName = String(args.name ?? '').trim();
685
+ if (!skillName)
686
+ return JSON.stringify({ error: 'name 不能为空' });
687
+ let skill = skillsCache.get(skillName);
688
+ // 若缓存未命中,先 lazy rescan 一次再试
689
+ if (!skill) {
690
+ await rescanSkills();
691
+ skill = skillsCache.get(skillName);
692
+ }
693
+ if (!skill)
694
+ return JSON.stringify({ error: `skill "${skillName}" 不存在` });
695
+ const sessionId = callCtx?.sessionId;
696
+ if (!sessionId) {
697
+ // 无 sessionId 直接返回 body
698
+ return JSON.stringify({
699
+ ok: true,
700
+ name: skill.name,
701
+ description: skill.description,
702
+ body: skill.body,
703
+ scripts: skill.scripts,
704
+ references: skill.references,
705
+ assets: skill.assets,
706
+ });
707
+ }
708
+ ensureSessionSet(sessionId).add(skill.name);
709
+ return JSON.stringify({
710
+ ok: true,
711
+ message: `skill "${skill.name}" 已激活;详细指令将在下一次模型调用时注入上下文。`,
712
+ scripts: skill.scripts,
713
+ references: skill.references,
714
+ assets: skill.assets,
715
+ });
716
+ },
717
+ });
718
+ // 2. list_skills —— 按关键词/triggers 状态筛选
719
+ tools.register({
720
+ groups: ['skills'],
721
+ definition: {
722
+ type: 'function',
723
+ function: {
724
+ name: 'list_skills',
725
+ description: '列出可用 skills(受角色卡白名单过滤后),可按关键词模糊匹配 name/description。翻页:下次调用传 offset = 上次 offset + limit,直到 has_more=false。',
726
+ parameters: {
727
+ type: 'object',
728
+ properties: {
729
+ keyword: { type: 'string', description: '可选:name/description 子串模糊匹配(不区分大小写)' },
730
+ limit: { type: 'number', description: '本页最多返回条数,默认 30' },
731
+ offset: { type: 'number', description: '跳过前 N 条用于翻页,默认 0' },
732
+ },
733
+ },
734
+ },
735
+ },
736
+ handler: async (args) => {
737
+ // 若缓存为空,先做一次 lazy rescan(覆盖"服务启动后才放入 skill"的场景)
738
+ if (skillsCache.size === 0)
739
+ await rescanSkills();
740
+ const visible = getAllowedSkills();
741
+ const keyword = typeof args.keyword === 'string' ? args.keyword.trim().toLowerCase() : '';
742
+ const filtered = visible.filter(s => {
743
+ if (!keyword)
744
+ return true;
745
+ return `${s.name} ${s.description}`.toLowerCase().includes(keyword);
746
+ });
747
+ const limit = Math.max(1, Math.floor(Number(args.limit) || 30));
748
+ const offset = Math.max(0, Math.floor(Number(args.offset) || 0));
749
+ const page = filtered.slice(offset, offset + limit);
750
+ return JSON.stringify({
751
+ total: visible.length,
752
+ matched: filtered.length,
753
+ limit,
754
+ offset,
755
+ returned: page.length,
756
+ has_more: offset + page.length < filtered.length,
757
+ skills: page.map(s => ({
758
+ name: s.name,
759
+ description: s.description,
760
+ hasTriggers: !!(s.triggers && s.triggers.length > 0),
761
+ scripts: s.scripts.length,
762
+ references: s.references.length,
763
+ assets: s.assets.length,
764
+ })),
765
+ });
766
+ },
767
+ });
768
+ // 3. skill_create
769
+ tools.register({
770
+ groups: ['skills'],
771
+ definition: {
772
+ type: 'function',
773
+ function: {
774
+ name: 'skill_create',
775
+ description: '创建一个新的 Agent Skill(生成 data/skills/<name>/SKILL.md 文件夹)。' +
776
+ 'name 与 description 必填;description 应包含"何时使用"以提高自动激活准确率。' +
777
+ '可选 files 数组用于一次性写入 scripts/、references/、assets/、LICENSE.txt 等附属资源,' +
778
+ '符合 Anthropic Agent Skills 完整目录结构。',
779
+ parameters: {
780
+ type: 'object',
781
+ properties: {
782
+ name: { type: 'string', description: 'skill 唯一名称' },
783
+ description: {
784
+ type: 'string',
785
+ description: 'skill 描述(含何时使用)。这是 LLM 决定是否激活该 skill 的主要依据。',
786
+ },
787
+ body: { type: 'string', description: 'SKILL.md 正文(markdown 指令)' },
788
+ triggers: {
789
+ type: 'array',
790
+ description: '可选:regex 字符串列表;匹配到用户消息时自动激活该 skill',
791
+ },
792
+ license: { type: 'string', description: '可选:许可证说明' },
793
+ frontmatter: {
794
+ type: 'object',
795
+ description: '可选:额外 YAML frontmatter 字段(例如 compatibility)。name/description 由专用字段覆盖。',
796
+ },
797
+ files: {
798
+ type: 'array',
799
+ description: '可选:附属文件列表,每项 { relPath, content }。relPath 相对 skill 根目录,' +
800
+ '禁止 SKILL.md / 绝对路径 / `..`。典型布局:scripts/run.sh、references/api.md、assets/template.json、LICENSE.txt。',
801
+ items: {
802
+ type: 'object',
803
+ properties: {
804
+ relPath: { type: 'string', description: '相对 skill 根的路径,如 scripts/run.sh' },
805
+ content: { type: 'string', description: '文件文本内容' },
806
+ },
807
+ required: ['relPath', 'content'],
808
+ },
809
+ },
810
+ },
811
+ required: ['name', 'description'],
812
+ },
813
+ },
814
+ },
815
+ handler: async (args) => {
816
+ try {
817
+ await service.createSkill({
818
+ name: args.name,
819
+ description: args.description,
820
+ body: args.body,
821
+ triggers: args.triggers,
822
+ license: args.license,
823
+ frontmatter: args.frontmatter,
824
+ files: args.files,
825
+ });
826
+ return JSON.stringify({ ok: true, message: `技能 "${args.name}" 已创建` });
827
+ }
828
+ catch (err) {
829
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
830
+ }
831
+ },
832
+ });
833
+ // 4. skill_update
834
+ tools.register({
835
+ groups: ['skills'],
836
+ definition: {
837
+ type: 'function',
838
+ function: {
839
+ name: 'skill_update',
840
+ description: '更新一个已有 skill 的 description / body / triggers / license / frontmatter / files。',
841
+ parameters: {
842
+ type: 'object',
843
+ properties: {
844
+ name: { type: 'string', description: '要更新的 skill 名称' },
845
+ description: { type: 'string', description: '新的描述' },
846
+ body: { type: 'string', description: '新的 SKILL.md 正文' },
847
+ triggers: { type: 'array', description: '新的 triggers regex 列表' },
848
+ license: { type: 'string', description: '新的 license' },
849
+ frontmatter: { type: 'object', description: '额外 frontmatter 字段(覆盖同名旧字段)' },
850
+ files: {
851
+ type: 'array',
852
+ description: '要写入/覆盖的附属文件,每项 { relPath, content }。同名直接覆盖,不存在则新增。',
853
+ items: {
854
+ type: 'object',
855
+ properties: {
856
+ relPath: { type: 'string' },
857
+ content: { type: 'string' },
858
+ },
859
+ required: ['relPath', 'content'],
860
+ },
861
+ },
862
+ },
863
+ required: ['name'],
864
+ },
865
+ },
866
+ },
867
+ handler: async (args) => {
868
+ try {
869
+ const ok = await service.updateSkill(args.name, {
870
+ description: args.description,
871
+ body: args.body,
872
+ triggers: args.triggers,
873
+ license: args.license,
874
+ frontmatter: args.frontmatter,
875
+ files: args.files,
876
+ });
877
+ return JSON.stringify({ ok, message: ok ? '已更新' : 'skill 不存在' });
878
+ }
879
+ catch (err) {
880
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
881
+ }
882
+ },
883
+ });
884
+ // 5. skill_delete
885
+ tools.register({
886
+ groups: ['skills'],
887
+ definition: {
888
+ type: 'function',
889
+ function: {
890
+ name: 'skill_delete',
891
+ description: '删除一个 skill(连同整个文件夹)。',
892
+ parameters: {
893
+ type: 'object',
894
+ properties: {
895
+ name: { type: 'string', description: '要删除的 skill 名称' },
896
+ },
897
+ required: ['name'],
898
+ },
899
+ },
900
+ },
901
+ handler: async (args) => {
902
+ const ok = await service.deleteSkill(args.name);
903
+ return JSON.stringify({ ok, message: ok ? '已删除' : 'skill 不存在' });
904
+ },
905
+ });
906
+ // 6. skill_add_file —— 单独添加/覆盖一个附属文件
907
+ tools.register({
908
+ groups: ['skills'],
909
+ definition: {
910
+ type: 'function',
911
+ function: {
912
+ name: 'skill_add_file',
913
+ description: '为指定 skill 增量写入一个附属文件(scripts/、references/、assets/、LICENSE.txt 等)。' +
914
+ '同名文件会被覆盖;relPath 禁止使用 SKILL.md、绝对路径或 `..`。',
915
+ parameters: {
916
+ type: 'object',
917
+ properties: {
918
+ skill: { type: 'string', description: '目标 skill 名称' },
919
+ relPath: { type: 'string', description: '相对 skill 根的路径,如 scripts/run.sh' },
920
+ content: { type: 'string', description: '文件文本内容' },
921
+ },
922
+ required: ['skill', 'relPath', 'content'],
923
+ },
924
+ },
925
+ },
926
+ handler: async (args) => {
927
+ try {
928
+ const ok = await service.addSkillFile(args.skill, {
929
+ relPath: args.relPath,
930
+ content: args.content,
931
+ });
932
+ return JSON.stringify({ ok, message: ok ? '已写入' : 'skill 不存在' });
933
+ }
934
+ catch (err) {
935
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
936
+ }
937
+ },
938
+ });
939
+ // 7. skill_remove_file
940
+ tools.register({
941
+ groups: ['skills'],
942
+ definition: {
943
+ type: 'function',
944
+ function: {
945
+ name: 'skill_remove_file',
946
+ description: '删除某 skill 下的一个附属文件(不能是 SKILL.md)。',
947
+ parameters: {
948
+ type: 'object',
949
+ properties: {
950
+ skill: { type: 'string', description: '目标 skill 名称' },
951
+ relPath: { type: 'string', description: '相对 skill 根的路径' },
952
+ },
953
+ required: ['skill', 'relPath'],
954
+ },
955
+ },
956
+ },
957
+ handler: async (args) => {
958
+ try {
959
+ const ok = await service.removeSkillFile(args.skill, args.relPath);
960
+ return JSON.stringify({ ok, message: ok ? '已删除' : 'skill 不存在或文件不存在' });
961
+ }
962
+ catch (err) {
963
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
964
+ }
965
+ },
966
+ });
967
+ // 8. skill_list_files
968
+ tools.register({
969
+ groups: ['skills'],
970
+ definition: {
971
+ type: 'function',
972
+ function: {
973
+ name: 'skill_list_files',
974
+ description: '列出某 skill 目录下所有附属文件(不含 SKILL.md)的相对路径。',
975
+ parameters: {
976
+ type: 'object',
977
+ properties: {
978
+ skill: { type: 'string', description: '目标 skill 名称' },
979
+ },
980
+ required: ['skill'],
981
+ },
982
+ },
983
+ },
984
+ handler: async (args) => {
985
+ const files = await service.listSkillFiles(args.skill);
986
+ return JSON.stringify({ skill: args.skill, count: files.length, files });
987
+ },
988
+ });
989
+ // 9. skill_read_file
990
+ tools.register({
991
+ groups: ['skills'],
992
+ definition: {
993
+ type: 'function',
994
+ function: {
995
+ name: 'skill_read_file',
996
+ description: '读取某 skill 下某附属文件的文本内容。二进制文件会按 UTF-8 解码,可能乱码。',
997
+ parameters: {
998
+ type: 'object',
999
+ properties: {
1000
+ skill: { type: 'string', description: '目标 skill 名称' },
1001
+ relPath: { type: 'string', description: '相对 skill 根的路径' },
1002
+ },
1003
+ required: ['skill', 'relPath'],
1004
+ },
1005
+ },
1006
+ },
1007
+ handler: async (args) => {
1008
+ try {
1009
+ const content = await service.readSkillFile(args.skill, args.relPath);
1010
+ if (content == null)
1011
+ return JSON.stringify({ error: 'skill 不存在或文件不存在' });
1012
+ return JSON.stringify({ skill: args.skill, relPath: args.relPath, content });
1013
+ }
1014
+ catch (err) {
1015
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
1016
+ }
1017
+ },
1018
+ });
1019
+ // 10. skill_rescan —— 手动重新扫描目录(便于外部添加 skill 后无需重启)
1020
+ tools.register({
1021
+ groups: ['skills'],
1022
+ definition: {
1023
+ type: 'function',
1024
+ function: {
1025
+ name: 'skill_rescan',
1026
+ description: '重新扫描 skills 目录(手动添加文件夹后调用)。',
1027
+ parameters: { type: 'object', properties: {} },
1028
+ },
1029
+ },
1030
+ handler: async () => {
1031
+ await service.rescan();
1032
+ return JSON.stringify({ ok: true, count: skillsCache.size });
1033
+ },
1034
+ });
1035
+ // ── 启动:ready 后一次全量扫描 + 启用 storage watch 增量同步 ──
1036
+ // 使用 sticky 'ready' 事件:bouncePlugin 后新实例仍能收到。
1037
+ ctx.on('ready', async () => {
1038
+ try {
1039
+ await rescanSkills();
1040
+ logger.info(`技能系统已启动 (Anthropic Agent Skills) uri=${skillsUri} 已加载 ${skillsCache.size} 个技能`);
1041
+ }
1042
+ catch (err) {
1043
+ logger.warn(`首次扫描 skills 失败:${err}`);
1044
+ }
1045
+ // 监听变化 → 标脏 → 按需重扫(去抖靠 storage 层)
1046
+ try {
1047
+ const unwatch = storage.watch?.(skillsUri, async () => {
1048
+ try {
1049
+ await rescanSkills();
1050
+ compiledTriggers.clear();
1051
+ logger.debug(`skills 目录变化,已重新扫描,现有 ${skillsCache.size} 个技能`);
1052
+ }
1053
+ catch (err) {
1054
+ logger.warn(`重扫 skills 失败:${err}`);
1055
+ }
1056
+ });
1057
+ if (unwatch)
1058
+ ctx.onDispose(unwatch);
1059
+ }
1060
+ catch (err) {
1061
+ logger.warn(`skills 目录监听启动失败,请手动调用 skill_rescan: ${err}`);
1062
+ }
1063
+ });
1064
+ }
1065
+ //# sourceMappingURL=index.js.map