@restregular/dsh-skill-center 0.2.2 → 0.3.1
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/README.md +226 -15
- package/client/client.js +136 -15
- package/lib/index.js +467 -25
- package/package.json +6 -3
- package/skills/skill-authoring/SKILL.md +58 -0
- package/skills/skill-authoring/references/frontmatter.md +71 -0
- package/skills/skill-authoring/references/plugin-bundled.md +89 -0
- package/skills/skill-authoring/references/resources.md +51 -0
- package/skills/skill-authoring/references/roots.md +44 -0
- package/skills/skill-authoring/templates/minimal/SKILL.md +28 -0
package/lib/index.js
CHANGED
|
@@ -22,12 +22,18 @@ import {
|
|
|
22
22
|
} from 'node:fs';
|
|
23
23
|
import { homedir } from 'node:os';
|
|
24
24
|
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
25
26
|
|
|
26
27
|
/** Loader identity of the Host half. */
|
|
27
28
|
export const name = 'skill-center';
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Services this plugin needs. `webServer` is *not* listed here: the loopback
|
|
32
|
+
* route is mounted through `ctx.inject(['webServer'], …)` so a deployment
|
|
33
|
+
* without a web server still gets the bundled skill and the `skill_create`
|
|
34
|
+
* tool instead of an inert plugin.
|
|
35
|
+
*/
|
|
36
|
+
export const inject = ['skills'];
|
|
31
37
|
|
|
32
38
|
/** Loopback route prefix owned by this plugin. */
|
|
33
39
|
const ROUTE = '/dsh-skill-center';
|
|
@@ -52,6 +58,35 @@ const ROOT_LABELS = {
|
|
|
52
58
|
'user-agents': { label: '用户 Agents 技能', rank: 500 },
|
|
53
59
|
};
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 随包(内嵌)技能:目录名 == frontmatter name,正文与资源都在本包 `skills/` 下。
|
|
63
|
+
* 这些技能以 provider 形式注册,资源通过 `resourceBase` 目录暴露给模型 ——
|
|
64
|
+
* 与第一方的 `@deepseek-ai/dsh-skill-office` 同一形态。
|
|
65
|
+
*/
|
|
66
|
+
const BUNDLED_SKILL_ROOT = fileURLToPath(new URL('../skills/', import.meta.url));
|
|
67
|
+
|
|
68
|
+
/** 随包技能的名字列表。 */
|
|
69
|
+
const BUNDLED_SKILL_NAMES = ['skill-authoring'];
|
|
70
|
+
|
|
71
|
+
/** provider 名:面板的 provider 列显示这个名字。 */
|
|
72
|
+
const BUNDLED_SKILL_PROVIDER = 'dsh-skill-center';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 浏览器模块图里本插件那一行的 id 候选:图按**包名**编号,插件 id 只是宿主侧名字。
|
|
76
|
+
* 两个都收,免得 loader 行名或注册 id 变一处就误报。
|
|
77
|
+
*/
|
|
78
|
+
const BROWSER_ENTRY_IDS = new Set([
|
|
79
|
+
'@restregular/dsh-skill-center',
|
|
80
|
+
'dsh-skill-center',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 随包技能的 rank,与 `@deepseek-ai/dsh-skill` 的 `BUNDLED_SKILL_RANK` 一致:
|
|
85
|
+
* 项目根(100/200)与用户根(400/500)的同名技能都能盖过它。
|
|
86
|
+
* 这里写字面量而不是 import,是为了让本插件保持零运行时依赖。
|
|
87
|
+
*/
|
|
88
|
+
const BUNDLED_SKILL_RANK = 600;
|
|
89
|
+
|
|
55
90
|
// ---------------------------------------------------------------------------
|
|
56
91
|
// HTTP 小工具
|
|
57
92
|
// ---------------------------------------------------------------------------
|
|
@@ -174,6 +209,109 @@ function frontmatterField(text, key) {
|
|
|
174
209
|
return raw.replace(/^["']/u, '').replace(/["']$/u, '');
|
|
175
210
|
}
|
|
176
211
|
|
|
212
|
+
/**
|
|
213
|
+
* 读 frontmatter 里的布尔字段,取值规则与 dsh-skill-filesystem 的
|
|
214
|
+
* `frontmatterBoolean` 一致(true/false/1/0/yes/no/on/off,大小写不敏感)。
|
|
215
|
+
* @param text - 文件全文。
|
|
216
|
+
* @param key - 字段名。
|
|
217
|
+
* @returns 布尔值,或 undefined(缺字段或值非法)。
|
|
218
|
+
*/
|
|
219
|
+
function frontmatterBoolean(text, key) {
|
|
220
|
+
const raw = frontmatterField(text, key);
|
|
221
|
+
if (raw === undefined) return undefined;
|
|
222
|
+
const value = raw.toLowerCase();
|
|
223
|
+
if (value === 'true' || value === '1' || value === 'yes' || value === 'on') return true;
|
|
224
|
+
if (value === 'false' || value === '0' || value === 'no' || value === 'off') return false;
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// 随包技能:注册成 provider,资源用 resourceBase 目录暴露
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 把本包 `skills/<name>/` 读成注册表候选。
|
|
234
|
+
*
|
|
235
|
+
* `resourceBase` 是模型能不能用上这个技能资源的关键:注册表会把
|
|
236
|
+
* "Base directory for this skill: <绝对路径>" 渲染进 `<skill_resources>`,
|
|
237
|
+
* 并告诉模型把正文里的相对路径解析到这个目录。缺了它,资源引用就只是空话。
|
|
238
|
+
*
|
|
239
|
+
* @param name - 技能名(等于目录名)。
|
|
240
|
+
* @returns 注册表候选。
|
|
241
|
+
* @throws 当 SKILL.md 缺失或没有 description 时(属于打包错误,要立刻看见)。
|
|
242
|
+
*/
|
|
243
|
+
function bundledSkillCandidate(name) {
|
|
244
|
+
const directory = join(BUNDLED_SKILL_ROOT, name);
|
|
245
|
+
const path = join(directory, 'SKILL.md');
|
|
246
|
+
const text = readFileSync(path, 'utf8');
|
|
247
|
+
const description = frontmatterField(text, 'description');
|
|
248
|
+
if (description === undefined) {
|
|
249
|
+
throw new Error(`随包技能 "${name}" 的 SKILL.md 缺少 description:${path}`);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
name,
|
|
253
|
+
description,
|
|
254
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
255
|
+
provider: BUNDLED_SKILL_PROVIDER,
|
|
256
|
+
source: 'bundled',
|
|
257
|
+
rank: BUNDLED_SKILL_RANK,
|
|
258
|
+
resourceBase: { kind: 'directory', path: directory },
|
|
259
|
+
locator: path,
|
|
260
|
+
path,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 读随包技能的正文(frontmatter 之后的部分)。
|
|
266
|
+
* @param name - 技能名。
|
|
267
|
+
* @returns 已 trim 的 Markdown 正文。
|
|
268
|
+
*/
|
|
269
|
+
function bundledSkillBody(name) {
|
|
270
|
+
const text = readFileSync(join(BUNDLED_SKILL_ROOT, name, 'SKILL.md'), 'utf8');
|
|
271
|
+
const match = matchFrontmatter(text);
|
|
272
|
+
return (match === undefined ? text : text.slice(match[0].length)).trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 构造随包技能的 provider。
|
|
277
|
+
*
|
|
278
|
+
* `list()` 只回目录摘要;正文每次 `get()` 现读,所以仓库里的 SKILL.md 改完
|
|
279
|
+
* 下一次加载就生效(注册表只缓存目录,不缓存正文)。
|
|
280
|
+
*
|
|
281
|
+
* @returns skill provider 对象。
|
|
282
|
+
*/
|
|
283
|
+
function createBundledSkillProvider() {
|
|
284
|
+
const candidates = BUNDLED_SKILL_NAMES.map((name) => bundledSkillCandidate(name));
|
|
285
|
+
const byName = new Map(candidates.map((candidate) => [candidate.name, candidate]));
|
|
286
|
+
return {
|
|
287
|
+
name: BUNDLED_SKILL_PROVIDER,
|
|
288
|
+
list: () => Promise.resolve(candidates),
|
|
289
|
+
get(winner) {
|
|
290
|
+
const known = byName.get(winner?.name);
|
|
291
|
+
if (known === undefined) throw new Error(`随包技能 "${String(winner?.name)}" 不存在`);
|
|
292
|
+
const { rank: _rank, locator: _locator, ...summary } = known;
|
|
293
|
+
return Promise.resolve({ ...summary, content: bundledSkillBody(known.name) });
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 注册随包技能。失败只记一行日志:技能资产出问题不该把整个插件(含面板)带下水。
|
|
300
|
+
* @param ctx - 宿主上下文。
|
|
301
|
+
*/
|
|
302
|
+
function mountBundledSkills(ctx) {
|
|
303
|
+
const skills = ctx.skills ?? ctx.get('skills');
|
|
304
|
+
if (skills === undefined) return;
|
|
305
|
+
try {
|
|
306
|
+
ctx.effect(
|
|
307
|
+
() => skills.registerProvider(createBundledSkillProvider),
|
|
308
|
+
'skill-center: bundled skills',
|
|
309
|
+
);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
ctx.logger?.warn?.(`skill-center: 随包技能未能注册:${error?.message ?? error}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
177
315
|
// ---------------------------------------------------------------------------
|
|
178
316
|
// 技能根目录
|
|
179
317
|
// ---------------------------------------------------------------------------
|
|
@@ -374,13 +512,29 @@ function describeSkill(skill, roots) {
|
|
|
374
512
|
const root = rootOfPath(path, roots);
|
|
375
513
|
const onDisk = path !== undefined && existsSync(path) && statSync(path).isFile();
|
|
376
514
|
let bytes;
|
|
515
|
+
let invocation;
|
|
377
516
|
if (onDisk) {
|
|
378
517
|
try {
|
|
379
518
|
bytes = statSync(path).size;
|
|
380
519
|
} catch {
|
|
381
520
|
bytes = undefined;
|
|
382
521
|
}
|
|
522
|
+
// 受管根里的技能以**磁盘为准**:技能目录是缓存的,写完盘之后 watcher 的失效
|
|
523
|
+
// 事件到达之前,快照里的调用策略还是旧值 —— 面板若照抄快照,刚拨完开关的界面
|
|
524
|
+
// 会被自己重读回来的旧值弹回去(手动刷新才会变)。这里现读一次 frontmatter 求解。
|
|
525
|
+
if (root !== undefined) {
|
|
526
|
+
try {
|
|
527
|
+
const text = readFileSync(path, 'utf8');
|
|
528
|
+
invocation = {
|
|
529
|
+
modelInvocable: frontmatterBoolean(text, 'disable-model-invocation') !== true,
|
|
530
|
+
userInvocable: frontmatterBoolean(text, 'user-invocable') !== false,
|
|
531
|
+
};
|
|
532
|
+
} catch {
|
|
533
|
+
invocation = undefined;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
383
536
|
}
|
|
537
|
+
const effective = invocation ?? skill.invocation;
|
|
384
538
|
return {
|
|
385
539
|
name: skill.name,
|
|
386
540
|
description: skill.description,
|
|
@@ -389,8 +543,8 @@ function describeSkill(skill, roots) {
|
|
|
389
543
|
source: skill.source,
|
|
390
544
|
path,
|
|
391
545
|
directory: path === undefined ? undefined : dirname(path),
|
|
392
|
-
modelInvocable:
|
|
393
|
-
userInvocable:
|
|
546
|
+
modelInvocable: effective?.modelInvocable !== false,
|
|
547
|
+
userInvocable: effective?.userInvocable !== false,
|
|
394
548
|
rootId: root?.id,
|
|
395
549
|
rootLabel: root?.label,
|
|
396
550
|
writable: root !== undefined && onDisk,
|
|
@@ -438,7 +592,10 @@ async function opDiagnose(ctx, request) {
|
|
|
438
592
|
const clientModules = ctx.get('clientModules');
|
|
439
593
|
const graph = clientModules?.graph?.();
|
|
440
594
|
const entries = Array.isArray(graph?.entries) ? graph.entries : [];
|
|
441
|
-
|
|
595
|
+
// 浏览器模块图按**包名**给行编号(客户端 bundle 的注册 id 也是包名),
|
|
596
|
+
// 插件 id 只是宿主侧的 loader 名字:只按 'dsh-skill-center' 找会永远找不到,
|
|
597
|
+
// diagnose 就会把"客户端半边好好地挂在图里"误报成 inGraph: false。
|
|
598
|
+
const mine = entries.find((entry) => BROWSER_ENTRY_IDS.has(entry.id));
|
|
442
599
|
let catalog;
|
|
443
600
|
try {
|
|
444
601
|
const catalogRead = await readCatalog(ctx, request);
|
|
@@ -451,6 +608,7 @@ async function opDiagnose(ctx, request) {
|
|
|
451
608
|
host: { mounted: true, route: ROUTE },
|
|
452
609
|
client: {
|
|
453
610
|
inGraph: mine !== undefined,
|
|
611
|
+
entryId: mine?.id,
|
|
454
612
|
graphRevision: graph?.rev,
|
|
455
613
|
graphEntryCount: entries.length,
|
|
456
614
|
url: mine?.url,
|
|
@@ -548,45 +706,163 @@ async function opSetInvocation(ctx, request) {
|
|
|
548
706
|
}
|
|
549
707
|
|
|
550
708
|
/**
|
|
551
|
-
*
|
|
709
|
+
* 归一化资源相对路径:必须是技能包目录内的相对路径,拒绝绝对路径、`.`/`..` 段与
|
|
710
|
+
* 二次写入 SKILL.md。面板与模型工具都走这道闸。
|
|
711
|
+
*
|
|
712
|
+
* @param raw - 原始路径(允许 `\` 分隔)。
|
|
713
|
+
* @param directory - 技能包目录。
|
|
714
|
+
* @returns 归一化后的 posix 相对路径,或 undefined(非法)。
|
|
715
|
+
*/
|
|
716
|
+
function normalizeResourcePath(raw, directory) {
|
|
717
|
+
if (typeof raw !== 'string') return undefined;
|
|
718
|
+
const value = raw.replace(/\\/gu, '/').trim();
|
|
719
|
+
if (value === '' || value.startsWith('/') || /^[A-Za-z]:/u.test(value)) return undefined;
|
|
720
|
+
const segments = value.split('/').filter((segment) => segment !== '');
|
|
721
|
+
if (segments.length === 0) return undefined;
|
|
722
|
+
if (segments.some((segment) => segment === '.' || segment === '..')) return undefined;
|
|
723
|
+
const relative = segments.join('/');
|
|
724
|
+
if (relative.toLowerCase() === 'skill.md') return undefined;
|
|
725
|
+
const base = resolve(directory);
|
|
726
|
+
const target = resolve(base, ...segments);
|
|
727
|
+
if (target !== base && !target.startsWith(base.endsWith(sep) ? base : base + sep)) return undefined;
|
|
728
|
+
return relative;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 新建(或按 `overwrite` 覆盖)一个技能包:写 frontmatter + 正文,再把资源文件落到
|
|
733
|
+
* 同一个技能目录里。面板的 `create` 与模型工具 `skill_create` 共用这一份实现。
|
|
734
|
+
*
|
|
552
735
|
* @param ctx - 宿主上下文。
|
|
553
|
-
* @param request - `{ sessionId, cwd, name, description, body, rootId, modelInvocable, userInvocable }`。
|
|
554
|
-
* @returns
|
|
736
|
+
* @param request - `{ sessionId, cwd, name, description, whenToUse, body, rootId, resources, overwrite, modelInvocable, userInvocable }`。
|
|
737
|
+
* @returns 响应体;`ok` 为 false 时带 `error`。
|
|
555
738
|
*/
|
|
556
|
-
async function
|
|
739
|
+
async function createSkillBundle(ctx, request) {
|
|
557
740
|
const skillName = String(request.name ?? '').trim();
|
|
558
741
|
if (!SKILL_NAME.test(skillName)) {
|
|
559
742
|
return { ok: false, error: `技能名 "${skillName}" 不合法:只允许小写字母、数字与单个连字符(kebab-case)` };
|
|
560
743
|
}
|
|
561
|
-
const description = String(request.description ?? '').trim();
|
|
744
|
+
const description = String(request.description ?? '').replace(/\s+/gu, ' ').trim();
|
|
562
745
|
if (description === '') return { ok: false, error: 'description 必填' };
|
|
746
|
+
const whenToUse = String(request.whenToUse ?? '').replace(/\s+/gu, ' ').trim();
|
|
747
|
+
const overwrite = request.overwrite === true;
|
|
748
|
+
const rawResources = Array.isArray(request.resources) ? request.resources : [];
|
|
563
749
|
|
|
564
750
|
const catalog = await readCatalog(ctx, request);
|
|
565
|
-
|
|
566
|
-
|
|
751
|
+
const existing = locateOnDisk(skillName, catalog.roots);
|
|
752
|
+
if (existing === undefined && catalog.skills.some((skill) => skill.name === skillName)) {
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
error: `技能 "${skillName}" 已存在,但由插件内嵌或其它来源提供,不在可管理的技能根目录里`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
if (existing !== undefined && !overwrite) {
|
|
759
|
+
return { ok: false, error: `技能 "${skillName}" 已存在:${existing.path}(需要覆盖请传 overwrite)` };
|
|
567
760
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
761
|
+
|
|
762
|
+
let root = existing?.root;
|
|
763
|
+
let directory = existing?.dir;
|
|
764
|
+
if (existing === undefined) {
|
|
765
|
+
if (request.rootId !== undefined && request.rootId !== '') {
|
|
766
|
+
// 显式指定的根允许尚不存在(mkdir 会补上):项目根就在工作区里,
|
|
767
|
+
// 由调用方明确点名比"静默换到用户根"更可预期。
|
|
768
|
+
root = catalog.roots.find((candidate) => candidate.id === request.rootId);
|
|
769
|
+
if (root === undefined) {
|
|
770
|
+
return {
|
|
771
|
+
ok: false,
|
|
772
|
+
error: `未知的技能根 "${request.rootId}";可选:${catalog.roots.map((candidate) => candidate.id).join('、')}`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
} else {
|
|
776
|
+
const usable = catalog.roots.filter((candidate) => candidate.exists || candidate.id.startsWith('user-'));
|
|
777
|
+
root = usable.find((candidate) => candidate.id === 'user-agents') ?? usable[0];
|
|
778
|
+
}
|
|
779
|
+
if (root === undefined) return { ok: false, error: '没有可用的技能根目录' };
|
|
780
|
+
directory = join(root.path, skillName);
|
|
781
|
+
if (existsSync(directory) && !overwrite) {
|
|
782
|
+
return { ok: false, error: `目录已存在:${directory}(需要覆盖请传 overwrite)` };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const resources = [];
|
|
787
|
+
if (existing?.flat === true) {
|
|
788
|
+
if (rawResources.length > 0) {
|
|
789
|
+
return {
|
|
790
|
+
ok: false,
|
|
791
|
+
error: `技能 "${skillName}" 是平坦文件 ${existing.path},无法承载资源文件;请改用目录包`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
for (const entry of rawResources) {
|
|
796
|
+
const relative = normalizeResourcePath(entry?.path, directory);
|
|
797
|
+
if (relative === undefined) {
|
|
798
|
+
return {
|
|
799
|
+
ok: false,
|
|
800
|
+
error: `资源路径 "${String(entry?.path ?? '')}" 不合法:必须是技能目录内的相对路径,不能是 SKILL.md,也不能含 . 或 .. 段`,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
if (typeof entry?.content !== 'string') {
|
|
804
|
+
return { ok: false, error: `资源 "${relative}" 缺少 content` };
|
|
805
|
+
}
|
|
806
|
+
resources.push({
|
|
807
|
+
relative,
|
|
808
|
+
content: entry.content,
|
|
809
|
+
target: join(directory, ...relative.split('/')),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
576
814
|
const invocation = {
|
|
577
815
|
modelInvocable: request.modelInvocable !== false,
|
|
578
816
|
userInvocable: request.userInvocable !== false,
|
|
579
817
|
};
|
|
580
818
|
const lines = ['---', `name: ${skillName}`, `description: ${JSON.stringify(description)}`];
|
|
819
|
+
if (whenToUse !== '') lines.push(`whenToUse: ${JSON.stringify(whenToUse)}`);
|
|
581
820
|
if (invocation.modelInvocable === false) lines.push('disable-model-invocation: true');
|
|
582
821
|
if (invocation.userInvocable === false) lines.push('user-invocable: false');
|
|
583
822
|
lines.push('---', '');
|
|
584
823
|
const body = String(request.body ?? '').trim();
|
|
585
824
|
lines.push(body === '' ? `# ${skillName}` : body, '');
|
|
586
|
-
|
|
587
|
-
const
|
|
825
|
+
|
|
826
|
+
const flat = existing?.flat === true;
|
|
827
|
+
const path = flat ? existing.path : join(directory, 'SKILL.md');
|
|
828
|
+
mkdirSync(flat ? dirname(path) : directory, { recursive: true });
|
|
588
829
|
writeFileSync(path, lines.join('\n'), 'utf8');
|
|
589
|
-
|
|
830
|
+
for (const resource of resources) {
|
|
831
|
+
mkdirSync(dirname(resource.target), { recursive: true });
|
|
832
|
+
writeFileSync(resource.target, resource.content, 'utf8');
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
ok: true,
|
|
837
|
+
name: skillName,
|
|
838
|
+
path,
|
|
839
|
+
directory: flat ? dirname(path) : directory,
|
|
840
|
+
rootId: root?.id,
|
|
841
|
+
rootLabel: root?.label,
|
|
842
|
+
replaced: existing !== undefined,
|
|
843
|
+
resources: resources.map((resource) => resource.relative),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* 新建一个技能目录包(HTTP `op=create`)。
|
|
849
|
+
* @param ctx - 宿主上下文。
|
|
850
|
+
* @param request - 见 createSkillBundle。
|
|
851
|
+
* @returns 响应体。
|
|
852
|
+
*/
|
|
853
|
+
async function opCreate(ctx, request) {
|
|
854
|
+
const created = await createSkillBundle(ctx, request);
|
|
855
|
+
if (created.ok !== true) return created;
|
|
856
|
+
return {
|
|
857
|
+
ok: true,
|
|
858
|
+
name: created.name,
|
|
859
|
+
path: created.path,
|
|
860
|
+
directory: created.directory,
|
|
861
|
+
rootId: created.rootId,
|
|
862
|
+
rootLabel: created.rootLabel,
|
|
863
|
+
replaced: created.replaced,
|
|
864
|
+
resources: created.resources,
|
|
865
|
+
};
|
|
590
866
|
}
|
|
591
867
|
|
|
592
868
|
/**
|
|
@@ -614,6 +890,161 @@ async function opDelete(ctx, request) {
|
|
|
614
890
|
return { ok: true, name: request.name, movedTo: target, rootId: entry.root.id };
|
|
615
891
|
}
|
|
616
892
|
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
// 模型工具:skill_create
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* `skill_create` 的参数 schema。
|
|
899
|
+
*
|
|
900
|
+
* 这里手写**原始 JSON Schema**,而不是 import `@deepseek-ai/dsh-tools` 的
|
|
901
|
+
* `defineTool`:本包因此保持零运行时依赖,也不依赖宿主 node_modules 的解析路径。
|
|
902
|
+
* 只使用注册表支持的子集:type/oneOf/properties/required/additionalProperties/
|
|
903
|
+
* items/enum/const 加注解键(description/title…)。
|
|
904
|
+
*/
|
|
905
|
+
const SKILL_CREATE_PARAMETERS = {
|
|
906
|
+
type: 'object',
|
|
907
|
+
properties: {
|
|
908
|
+
name: {
|
|
909
|
+
type: 'string',
|
|
910
|
+
description: 'Kebab-case skill name; it is also the bundle directory name.',
|
|
911
|
+
},
|
|
912
|
+
description: {
|
|
913
|
+
type: 'string',
|
|
914
|
+
description: 'Frontmatter description — the only routing signal the model catalog shows. Write it as "Use when …".',
|
|
915
|
+
},
|
|
916
|
+
whenToUse: {
|
|
917
|
+
type: 'string',
|
|
918
|
+
description: 'Optional "whenToUse" frontmatter field (provider metadata; not rendered into the catalog).',
|
|
919
|
+
},
|
|
920
|
+
body: {
|
|
921
|
+
type: 'string',
|
|
922
|
+
description: 'Full Markdown body that follows the frontmatter. Omit to write a placeholder "# <name>" heading.',
|
|
923
|
+
},
|
|
924
|
+
root: {
|
|
925
|
+
type: 'string',
|
|
926
|
+
enum: ['project-dsh', 'project-agents', 'user-dsh', 'user-agents'],
|
|
927
|
+
description: 'Target skill root. Defaults to user-agents (~/.agents/skills); pass a project root for a repository-scoped skill.',
|
|
928
|
+
},
|
|
929
|
+
resources: {
|
|
930
|
+
type: 'array',
|
|
931
|
+
description: 'Extra files written inside the same bundle directory, e.g. references/roots.md or scripts/check.py.',
|
|
932
|
+
items: {
|
|
933
|
+
type: 'object',
|
|
934
|
+
properties: {
|
|
935
|
+
path: { type: 'string', description: 'Relative path inside the bundle directory.' },
|
|
936
|
+
content: { type: 'string', description: 'Full UTF-8 file content.' },
|
|
937
|
+
},
|
|
938
|
+
required: ['path', 'content'],
|
|
939
|
+
additionalProperties: false,
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
modelInvocable: {
|
|
943
|
+
type: 'boolean',
|
|
944
|
+
description: 'Default true; false writes disable-model-invocation: true, keeping the skill out of the model catalog.',
|
|
945
|
+
},
|
|
946
|
+
userInvocable: {
|
|
947
|
+
type: 'boolean',
|
|
948
|
+
description: 'Default true; false writes user-invocable: false, disabling the user-facing /name entry point.',
|
|
949
|
+
},
|
|
950
|
+
overwrite: {
|
|
951
|
+
type: 'boolean',
|
|
952
|
+
description: 'Default false: fail when the skill already exists. true rewrites SKILL.md and the listed resources in place.',
|
|
953
|
+
},
|
|
954
|
+
},
|
|
955
|
+
required: ['name', 'description'],
|
|
956
|
+
additionalProperties: false,
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
/** `skill_create` 的返回值 schema,与 execute 的返回字段一一对应。 */
|
|
960
|
+
const SKILL_CREATE_OUTPUT = {
|
|
961
|
+
type: 'object',
|
|
962
|
+
properties: {
|
|
963
|
+
name: { type: 'string' },
|
|
964
|
+
directory: { type: 'string' },
|
|
965
|
+
skillFile: { type: 'string' },
|
|
966
|
+
rootId: { type: 'string' },
|
|
967
|
+
rootLabel: { type: 'string' },
|
|
968
|
+
resources: { type: 'array', items: { type: 'string' } },
|
|
969
|
+
replaced: { type: 'boolean' },
|
|
970
|
+
},
|
|
971
|
+
required: ['name', 'directory', 'skillFile', 'resources', 'replaced'],
|
|
972
|
+
additionalProperties: false,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* 构造 `skill_create` 工具定义:把面板的"新建"能力交给模型,并顺带解决
|
|
977
|
+
* "技能根目录在工作区之外"这个真实障碍 —— 文件写入工具被沙箱限制在工作区内,
|
|
978
|
+
* 而受管技能根不在工作区。
|
|
979
|
+
*
|
|
980
|
+
* @param ctx - 宿主上下文。
|
|
981
|
+
* @returns 可交给 `ctx.tools.register()` 的定义。
|
|
982
|
+
*/
|
|
983
|
+
function skillCreateTool(ctx) {
|
|
984
|
+
return {
|
|
985
|
+
name: 'skill_create',
|
|
986
|
+
description: 'Create a Harness skill bundle: writes <root>/<name>/SKILL.md with the given frontmatter and body, plus any extra files listed in resources, inside that same directory. Use it to author or scaffold a skill, especially when the target skill root is outside the session workspace (~/.agents/skills, ~/.dsh/skills), where the file-write tools are sandboxed and cannot write. Put long-form detail in resources (for example references/<topic>.md, scripts/<tool>.py) and refer to those files by relative path from the body. Fails when a skill of that name already exists unless overwrite is true.',
|
|
987
|
+
parameters: SKILL_CREATE_PARAMETERS,
|
|
988
|
+
output: {
|
|
989
|
+
schema: SKILL_CREATE_OUTPUT,
|
|
990
|
+
render(_args, value) {
|
|
991
|
+
const lines = [
|
|
992
|
+
`${value.replaced === true ? 'Replaced' : 'Created'} skill "${value.name}"`,
|
|
993
|
+
`directory: ${value.directory}`,
|
|
994
|
+
`SKILL.md: ${value.skillFile}`,
|
|
995
|
+
`root: ${value.rootLabel} (${value.rootId})`,
|
|
996
|
+
value.resources.length === 0 ? 'resources: none' : `resources: ${value.resources.join(', ')}`,
|
|
997
|
+
];
|
|
998
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
async execute(args, exec) {
|
|
1002
|
+
const session = exec?.agent?.session;
|
|
1003
|
+
const created = await createSkillBundle(ctx, {
|
|
1004
|
+
sessionId: session?.id,
|
|
1005
|
+
cwd: session?.header?.cwd,
|
|
1006
|
+
name: args.name,
|
|
1007
|
+
description: args.description,
|
|
1008
|
+
whenToUse: args.whenToUse,
|
|
1009
|
+
body: args.body,
|
|
1010
|
+
rootId: args.root,
|
|
1011
|
+
resources: args.resources,
|
|
1012
|
+
overwrite: args.overwrite,
|
|
1013
|
+
modelInvocable: args.modelInvocable,
|
|
1014
|
+
userInvocable: args.userInvocable,
|
|
1015
|
+
});
|
|
1016
|
+
if (created.ok !== true) throw new Error(created.error);
|
|
1017
|
+
return {
|
|
1018
|
+
name: created.name,
|
|
1019
|
+
directory: created.directory,
|
|
1020
|
+
skillFile: created.path,
|
|
1021
|
+
rootId: created.rootId,
|
|
1022
|
+
rootLabel: created.rootLabel,
|
|
1023
|
+
resources: created.resources,
|
|
1024
|
+
replaced: created.replaced,
|
|
1025
|
+
};
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* 注册 `skill_create`。没有 tools 服务的组合里只是少一个工具,
|
|
1032
|
+
* 面板与随包技能照常工作。
|
|
1033
|
+
* @param ctx - 宿主上下文。
|
|
1034
|
+
*/
|
|
1035
|
+
function mountSkillTools(ctx) {
|
|
1036
|
+
ctx.inject(['tools'], (scoped) => {
|
|
1037
|
+
try {
|
|
1038
|
+
scoped.effect(
|
|
1039
|
+
() => scoped.tools.register(skillCreateTool(scoped)),
|
|
1040
|
+
'skill-center: skill_create tool',
|
|
1041
|
+
);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
scoped.logger?.warn?.(`skill-center: skill_create 未能注册:${error?.message ?? error}`);
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
617
1048
|
/** 路由表:op → 处理器。 */
|
|
618
1049
|
const OPERATIONS = {
|
|
619
1050
|
ping: async () => ({ ok: true, plugin: 'dsh-skill-center', version: 1 }),
|
|
@@ -631,9 +1062,9 @@ const OPERATIONS = {
|
|
|
631
1062
|
|
|
632
1063
|
/**
|
|
633
1064
|
* 挂载回环路由,向客户端暴露技能目录与管理操作。
|
|
634
|
-
* @param ctx - 宿主上下文(已保证
|
|
1065
|
+
* @param ctx - 宿主上下文(已保证 webServer 就绪)。
|
|
635
1066
|
*/
|
|
636
|
-
|
|
1067
|
+
function mountRoutes(ctx) {
|
|
637
1068
|
const webServer = ctx.webServer ?? ctx.get('webServer');
|
|
638
1069
|
if (webServer === undefined) return;
|
|
639
1070
|
|
|
@@ -689,3 +1120,14 @@ export function apply(ctx) {
|
|
|
689
1120
|
},
|
|
690
1121
|
}), 'skill-center: loopback routes');
|
|
691
1122
|
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* 插件体:随包技能、模型工具、回环路由三块互相独立降级 ——
|
|
1126
|
+
* 任何一块失败都只记日志,不会让宿主启动失败,也不会拖垮另外两块。
|
|
1127
|
+
* @param ctx - 宿主上下文(已保证 skills 就绪)。
|
|
1128
|
+
*/
|
|
1129
|
+
export function apply(ctx) {
|
|
1130
|
+
mountBundledSkills(ctx);
|
|
1131
|
+
mountSkillTools(ctx);
|
|
1132
|
+
ctx.inject(['webServer'], (scoped) => mountRoutes(scoped));
|
|
1133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@restregular/dsh-skill-center",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "技能中心 — DeepSeek Harness
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "技能中心 — DeepSeek Harness 技能管理面板:浏览当前会话可见的所有技能、查看正文、开关模型/用户调用、新建与删除;随包附带 skill-authoring 指导技能与 skill_create 模型工具。 · Skill Center for DeepSeek Harness: browse and manage the skills visible to the current session, with a bundled skill-authoring skill and a skill_create model tool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"lib",
|
|
19
19
|
"client",
|
|
20
|
+
"skills",
|
|
20
21
|
"cordis.patch.yml"
|
|
21
22
|
],
|
|
22
23
|
"exports": {
|
|
@@ -45,7 +46,9 @@
|
|
|
45
46
|
"deepseek",
|
|
46
47
|
"skills",
|
|
47
48
|
"skill-center",
|
|
48
|
-
"agent-skills"
|
|
49
|
+
"agent-skills",
|
|
50
|
+
"skill-authoring",
|
|
51
|
+
"skill-create"
|
|
49
52
|
],
|
|
50
53
|
"publishConfig": {
|
|
51
54
|
"access": "public",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-authoring
|
|
3
|
+
description: "Use when creating, editing, moving, or reviewing a Harness skill: choosing the skill root for project or user scope, writing SKILL.md frontmatter and body, placing the skill's resource files next to it, or shipping a skill inside a plugin."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authoring Harness skills
|
|
7
|
+
|
|
8
|
+
A skill is a **directory bundle**: `<root>/<name>/SKILL.md` plus every resource file that `SKILL.md` refers to. The model sees only each skill's `name` and `description` in the session catalog; the body is loaded on demand when the model or the user invokes it, and resource files are never loaded automatically. A skill's body must therefore say which file to read and when.
|
|
9
|
+
|
|
10
|
+
## Short path
|
|
11
|
+
|
|
12
|
+
1. Pick the root — see `references/roots.md`. Project-scoped skill: `<projectRoot>/.agents/skills`. Available in every project: `$DSH_AGENTS_HOME/skills` (`~/.agents/skills`).
|
|
13
|
+
2. Create `<root>/<name>/SKILL.md`. `name` is kebab-case and should equal the directory name.
|
|
14
|
+
3. Keep the frontmatter minimal: `name` and `description` are required; the body starts after the closing `---`.
|
|
15
|
+
4. Put resource files in subdirectories of **that same bundle directory** and point at them with relative paths.
|
|
16
|
+
5. Verify: the frontmatter parses, `name` matches the directory, every referenced file exists, and the skill appears in the Skill Center panel (and in the catalog of the next model step).
|
|
17
|
+
|
|
18
|
+
Use the `skill_create` tool when the target root is outside the current workspace: the file-write tools are sandboxed to the workspace, while the managed skill roots are not, so a direct write to `~/.agents/skills/...` is denied. `skill_create` writes `<root>/<name>/SKILL.md` plus any `resources` you pass, in one call, and returns the absolute bundle directory. Inside the workspace (for example `<projectRoot>/.agents/skills`) ordinary file writes work fine.
|
|
19
|
+
|
|
20
|
+
## Frontmatter decides who can invoke the skill
|
|
21
|
+
|
|
22
|
+
| Field | Effect |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `description` | the only routing signal in the catalog — write it as "Use when …" and name the tasks, not the implementation |
|
|
25
|
+
| `disable-model-invocation: true` | the skill leaves the model catalog entirely; only an explicit `/name` from the user loads it |
|
|
26
|
+
| `user-invocable: false` | `/name` stops working for humans; the model can still load the skill |
|
|
27
|
+
|
|
28
|
+
The camelCase spellings (`disableModelInvocation`, `modelInvocable`, `userInvocable`) are **rejected**: a skill carrying one is skipped with a warning instead of loading. The full contract, the accepted boolean spellings, and the silent failure modes are in `references/frontmatter.md`.
|
|
29
|
+
|
|
30
|
+
## Resource placement
|
|
31
|
+
|
|
32
|
+
Everything a skill needs lives under its own bundle directory, because the base directory reported to the model *is* that directory and relative paths resolve against it:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
<root>/<name>/
|
|
36
|
+
SKILL.md
|
|
37
|
+
references/ long-form detail the body points at by relative path
|
|
38
|
+
scripts/ commands the body tells the model to run
|
|
39
|
+
assets/ fixtures, templates, images
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The loaded skill arrives wrapped in `<skill_content>` with a `<skill_resources>` block that names the absolute base directory; the model is told to resolve relative paths against it before reading. Files under a bundle's resource subtrees are read directly by the file tools when the body asks for them, so editing a reference file needs no catalog refresh — only the skill *list* (names, descriptions, invocation policy) is cached. Details, path rules and the Desktop `app.asar` caveat: `references/resources.md`.
|
|
43
|
+
|
|
44
|
+
## Shipping a skill inside a plugin
|
|
45
|
+
|
|
46
|
+
A plugin publishes its own skills instead of writing into the user's roots: it registers a skill provider (or a runtime skill) and points each candidate at an asset directory inside the package. Use this when the skill's instructions and resources must version together with the code they describe. `references/plugin-bundled.md` has the provider contract, the candidate fields, the rank, and the two registration APIs.
|
|
47
|
+
|
|
48
|
+
## Read next
|
|
49
|
+
|
|
50
|
+
Read only the files this task needs; the table is complete.
|
|
51
|
+
|
|
52
|
+
| Task | File |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Which root a skill belongs in, root precedence, project-root detection | `references/roots.md` |
|
|
55
|
+
| Frontmatter fields, validation, and why a skill silently does not load | `references/frontmatter.md` |
|
|
56
|
+
| Where resource files go, how the model is told to find them, Desktop limits | `references/resources.md` |
|
|
57
|
+
| Shipping skill instructions and resources inside a plugin package | `references/plugin-bundled.md` |
|
|
58
|
+
| Starter bundle for a new skill | `templates/minimal/SKILL.md` |
|