@yeaft/webchat-agent 0.1.456 → 0.1.460
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/package.json +1 -1
- package/unify/skills.js +345 -70
- package/unify/tools/skill.js +70 -26
package/package.json
CHANGED
package/unify/skills.js
CHANGED
|
@@ -1,24 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* skills.js — Skill loading and management
|
|
3
3
|
*
|
|
4
|
-
* Skills
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Skills can be:
|
|
5
|
+
* 1. Single .md files: skills/my-skill.md (legacy, still supported)
|
|
6
|
+
* 2. Directories: skills/my-skill/SKILL.md + references/ + templates/
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* Directory-based skills support progressive disclosure:
|
|
9
|
+
* - list() → metadata only (name, description, trigger, mode, category, platforms)
|
|
10
|
+
* - view() → full SKILL.md content + linked files from references/ and templates/
|
|
11
|
+
*
|
|
12
|
+
* Categories are derived from nested directories:
|
|
13
|
+
* skills/coding/review/SKILL.md → category = "coding/review"
|
|
14
|
+
*
|
|
15
|
+
* Frontmatter fields:
|
|
16
|
+
* name, description, trigger, mode, platforms, keywords
|
|
17
|
+
*
|
|
18
|
+
* - trigger: string (keyword list) OR /regex/ pattern
|
|
19
|
+
* - keywords: array of match keywords (alternative to trigger)
|
|
20
|
+
* - platforms: array of ["macos", "linux", "windows"]
|
|
16
21
|
*
|
|
17
22
|
* Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
|
-
import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
21
|
-
import { join, basename } from 'path';
|
|
25
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync, statSync } from 'fs';
|
|
26
|
+
import { join, basename, relative, dirname, sep } from 'path';
|
|
27
|
+
import { platform } from 'os';
|
|
28
|
+
|
|
29
|
+
// ─── Platform Matching ────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const PLATFORM_MAP = {
|
|
32
|
+
macos: 'darwin',
|
|
33
|
+
linux: 'linux',
|
|
34
|
+
windows: 'win32',
|
|
35
|
+
darwin: 'darwin',
|
|
36
|
+
win32: 'win32',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a skill matches the current platform.
|
|
41
|
+
* @param {string[]} [platforms] — e.g. ['macos', 'linux']
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function matchesPlatform(platforms) {
|
|
45
|
+
if (!platforms || platforms.length === 0) return true;
|
|
46
|
+
const currentPlatform = platform();
|
|
47
|
+
return platforms.some(p => PLATFORM_MAP[p.toLowerCase()] === currentPlatform);
|
|
48
|
+
}
|
|
22
49
|
|
|
23
50
|
// ─── Skill Parsing ─────────────────────────────────────────
|
|
24
51
|
|
|
@@ -26,17 +53,23 @@ import { join, basename } from 'path';
|
|
|
26
53
|
* @typedef {Object} Skill
|
|
27
54
|
* @property {string} name — unique skill name
|
|
28
55
|
* @property {string} description — human-readable description
|
|
29
|
-
* @property {string} trigger — when this skill should be invoked
|
|
56
|
+
* @property {string} trigger — when this skill should be invoked (keyword string or /regex/)
|
|
57
|
+
* @property {string[]} [keywords] — explicit match keywords
|
|
30
58
|
* @property {string} mode — 'chat' | 'work' | 'both'
|
|
59
|
+
* @property {string[]} [platforms] — platform filter e.g. ['macos', 'linux']
|
|
60
|
+
* @property {string} [category] — derived from directory path
|
|
31
61
|
* @property {string} content — full skill instructions (markdown body)
|
|
32
|
-
* @property {string}
|
|
62
|
+
* @property {string} _source — 'file' | 'directory'
|
|
63
|
+
* @property {string} _path — full path to skill file or directory
|
|
64
|
+
* @property {string[]} [_references] — filenames in references/ dir
|
|
65
|
+
* @property {string[]} [_templates] — filenames in templates/ dir
|
|
33
66
|
*/
|
|
34
67
|
|
|
35
68
|
/**
|
|
36
|
-
* Parse a skill .md file.
|
|
69
|
+
* Parse YAML-like frontmatter from a skill .md file.
|
|
37
70
|
*
|
|
38
71
|
* @param {string} raw — raw file content
|
|
39
|
-
* @param {string} filename — source filename
|
|
72
|
+
* @param {string} [filename] — source filename (for name fallback)
|
|
40
73
|
* @returns {Skill|null}
|
|
41
74
|
*/
|
|
42
75
|
export function parseSkill(raw, filename = '') {
|
|
@@ -50,8 +83,9 @@ export function parseSkill(raw, filename = '') {
|
|
|
50
83
|
|
|
51
84
|
const skill = {
|
|
52
85
|
content: body,
|
|
53
|
-
_filename: filename,
|
|
54
86
|
mode: 'both',
|
|
87
|
+
_source: 'file',
|
|
88
|
+
_path: '',
|
|
55
89
|
};
|
|
56
90
|
|
|
57
91
|
for (const line of frontmatter.split('\n')) {
|
|
@@ -65,6 +99,18 @@ export function parseSkill(raw, filename = '') {
|
|
|
65
99
|
case 'description': skill.description = value; break;
|
|
66
100
|
case 'trigger': skill.trigger = value; break;
|
|
67
101
|
case 'mode': skill.mode = value; break;
|
|
102
|
+
case 'platforms': {
|
|
103
|
+
// Parse [macos, linux] or macos, linux
|
|
104
|
+
const cleaned = value.replace(/^\[|\]$/g, '');
|
|
105
|
+
skill.platforms = cleaned.split(',').map(t => t.trim()).filter(Boolean);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'keywords': {
|
|
109
|
+
const cleaned = value.replace(/^\[|\]$/g, '');
|
|
110
|
+
skill.keywords = cleaned.split(',').map(t => t.trim()).filter(Boolean);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'category': skill.category = value; break;
|
|
68
114
|
}
|
|
69
115
|
}
|
|
70
116
|
|
|
@@ -89,16 +135,187 @@ export function serializeSkill(skill) {
|
|
|
89
135
|
`description: ${skill.description || ''}`,
|
|
90
136
|
`trigger: ${skill.trigger || ''}`,
|
|
91
137
|
`mode: ${skill.mode || 'both'}`,
|
|
92
|
-
'---',
|
|
93
138
|
];
|
|
94
139
|
|
|
140
|
+
if (skill.platforms && skill.platforms.length > 0) {
|
|
141
|
+
fm.push(`platforms: [${skill.platforms.join(', ')}]`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (skill.keywords && skill.keywords.length > 0) {
|
|
145
|
+
fm.push(`keywords: [${skill.keywords.join(', ')}]`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (skill.category) {
|
|
149
|
+
fm.push(`category: ${skill.category}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fm.push('---');
|
|
153
|
+
|
|
95
154
|
return fm.join('\n') + '\n\n' + (skill.content || '');
|
|
96
155
|
}
|
|
97
156
|
|
|
157
|
+
// ─── Directory Scanning ───────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* List files in a subdirectory (non-recursive).
|
|
161
|
+
* @param {string} dir
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
function listSubdirFiles(dir) {
|
|
165
|
+
if (!existsSync(dir)) return [];
|
|
166
|
+
try {
|
|
167
|
+
return readdirSync(dir).filter(f => {
|
|
168
|
+
try { return statSync(join(dir, f)).isFile(); } catch { return false; }
|
|
169
|
+
});
|
|
170
|
+
} catch { return []; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Recursively discover skills in a directory.
|
|
175
|
+
* Supports:
|
|
176
|
+
* - skills/foo.md (single-file skill)
|
|
177
|
+
* - skills/foo/SKILL.md (directory-based skill)
|
|
178
|
+
* - skills/category/foo/SKILL.md (nested category)
|
|
179
|
+
*
|
|
180
|
+
* @param {string} rootDir — skills root directory
|
|
181
|
+
* @param {string} [subPath] — relative path from root (for category derivation)
|
|
182
|
+
* @returns {{ skills: Skill[], errors: string[] }}
|
|
183
|
+
*/
|
|
184
|
+
function discoverSkills(rootDir, subPath = '') {
|
|
185
|
+
const dir = subPath ? join(rootDir, subPath) : rootDir;
|
|
186
|
+
const skills = [];
|
|
187
|
+
const errors = [];
|
|
188
|
+
|
|
189
|
+
if (!existsSync(dir)) return { skills, errors };
|
|
190
|
+
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
errors.push(`Cannot read directory ${dir}: ${err.message}`);
|
|
196
|
+
return { skills, errors };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const entryPath = join(dir, entry.name);
|
|
201
|
+
const relPath = subPath ? join(subPath, entry.name) : entry.name;
|
|
202
|
+
|
|
203
|
+
if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
|
|
204
|
+
// Single-file skill (legacy format)
|
|
205
|
+
try {
|
|
206
|
+
const raw = readFileSync(entryPath, 'utf8');
|
|
207
|
+
const skill = parseSkill(raw, entry.name);
|
|
208
|
+
if (skill && skill.name) {
|
|
209
|
+
skill._source = 'file';
|
|
210
|
+
skill._path = entryPath;
|
|
211
|
+
// Category from parent directory path
|
|
212
|
+
if (subPath) {
|
|
213
|
+
skill.category = skill.category || subPath.split(sep).join('/');
|
|
214
|
+
}
|
|
215
|
+
skills.push(skill);
|
|
216
|
+
} else {
|
|
217
|
+
errors.push(`Failed to parse skill: ${relPath}`);
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
errors.push(`Error loading ${relPath}: ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
} else if (entry.isDirectory()) {
|
|
223
|
+
// Check for SKILL.md inside this directory
|
|
224
|
+
const skillMdPath = join(entryPath, 'SKILL.md');
|
|
225
|
+
if (existsSync(skillMdPath)) {
|
|
226
|
+
// Directory-based skill
|
|
227
|
+
try {
|
|
228
|
+
const raw = readFileSync(skillMdPath, 'utf8');
|
|
229
|
+
const skill = parseSkill(raw, entry.name);
|
|
230
|
+
if (skill && skill.name) {
|
|
231
|
+
skill._source = 'directory';
|
|
232
|
+
skill._path = entryPath;
|
|
233
|
+
// Derive category from parent path
|
|
234
|
+
if (subPath) {
|
|
235
|
+
skill.category = skill.category || subPath.split(sep).join('/');
|
|
236
|
+
}
|
|
237
|
+
// Discover linked files
|
|
238
|
+
skill._references = listSubdirFiles(join(entryPath, 'references'));
|
|
239
|
+
skill._templates = listSubdirFiles(join(entryPath, 'templates'));
|
|
240
|
+
skills.push(skill);
|
|
241
|
+
} else {
|
|
242
|
+
errors.push(`Failed to parse skill: ${relPath}/SKILL.md`);
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
errors.push(`Error loading ${relPath}/SKILL.md: ${err.message}`);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
// No SKILL.md — treat as category directory, recurse
|
|
249
|
+
const sub = discoverSkills(rootDir, relPath);
|
|
250
|
+
skills.push(...sub.skills);
|
|
251
|
+
errors.push(...sub.errors);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { skills, errors };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Trigger Matching ─────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Test if a trigger string matches a prompt.
|
|
263
|
+
* Supports:
|
|
264
|
+
* - /regex/ patterns (trigger starts and ends with /)
|
|
265
|
+
* - keyword-based matching (word overlap with stem matching)
|
|
266
|
+
*
|
|
267
|
+
* @param {string} trigger
|
|
268
|
+
* @param {string} prompt — lowercase prompt
|
|
269
|
+
* @param {string[]} promptWords — cleaned prompt words
|
|
270
|
+
* @returns {boolean}
|
|
271
|
+
*/
|
|
272
|
+
function matchTrigger(trigger, prompt, promptWords) {
|
|
273
|
+
// Regex trigger: /pattern/flags
|
|
274
|
+
const regexMatch = trigger.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
275
|
+
if (regexMatch) {
|
|
276
|
+
try {
|
|
277
|
+
const re = new RegExp(regexMatch[1], regexMatch[2] || 'i');
|
|
278
|
+
return re.test(prompt);
|
|
279
|
+
} catch {
|
|
280
|
+
// Invalid regex, fall through to keyword matching
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Keyword-based matching
|
|
285
|
+
const triggerWords = trigger.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2);
|
|
286
|
+
if (triggerWords.length === 0) return false;
|
|
287
|
+
|
|
288
|
+
const cleanPrompt = prompt.replace(/[^\w\s]/g, '');
|
|
289
|
+
const matchCount = triggerWords.filter(tw => {
|
|
290
|
+
if (cleanPrompt.includes(tw)) return true;
|
|
291
|
+
const twStem = tw.slice(0, Math.min(tw.length, 4));
|
|
292
|
+
return promptWords.some(pw => {
|
|
293
|
+
if (pw.includes(tw) || tw.includes(pw)) return true;
|
|
294
|
+
const pwStem = pw.slice(0, Math.min(pw.length, 4));
|
|
295
|
+
return twStem.length >= 4 && pwStem.length >= 4 && twStem === pwStem;
|
|
296
|
+
});
|
|
297
|
+
}).length;
|
|
298
|
+
|
|
299
|
+
return matchCount >= 1 && matchCount >= Math.ceil(triggerWords.length * 0.3);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Test if keywords list matches a prompt.
|
|
304
|
+
* Any keyword found in prompt = match.
|
|
305
|
+
*
|
|
306
|
+
* @param {string[]} keywords
|
|
307
|
+
* @param {string} prompt — lowercase prompt
|
|
308
|
+
* @returns {boolean}
|
|
309
|
+
*/
|
|
310
|
+
function matchKeywords(keywords, prompt) {
|
|
311
|
+
return keywords.some(kw => prompt.includes(kw.toLowerCase()));
|
|
312
|
+
}
|
|
313
|
+
|
|
98
314
|
// ─── SkillManager ──────────────────────────────────────────
|
|
99
315
|
|
|
100
316
|
/**
|
|
101
317
|
* SkillManager — loads, indexes, and queries skills.
|
|
318
|
+
* Supports both single-file and directory-based skills.
|
|
102
319
|
*/
|
|
103
320
|
export class SkillManager {
|
|
104
321
|
/** @type {Map<string, Skill>} */
|
|
@@ -114,32 +331,29 @@ export class SkillManager {
|
|
|
114
331
|
this.#skillsDir = join(yeaftDir, 'skills');
|
|
115
332
|
}
|
|
116
333
|
|
|
334
|
+
/** The skills root directory path. */
|
|
335
|
+
get skillsDir() {
|
|
336
|
+
return this.#skillsDir;
|
|
337
|
+
}
|
|
338
|
+
|
|
117
339
|
/**
|
|
118
|
-
* Load all skills from the skills directory.
|
|
340
|
+
* Load all skills from the skills directory (recursive).
|
|
119
341
|
*
|
|
120
342
|
* @returns {{ loaded: number, errors: string[] }}
|
|
121
343
|
*/
|
|
122
344
|
load() {
|
|
123
345
|
this.#skills.clear();
|
|
124
|
-
const errors = [];
|
|
125
346
|
|
|
126
347
|
if (!existsSync(this.#skillsDir)) {
|
|
127
348
|
return { loaded: 0, errors: [] };
|
|
128
349
|
}
|
|
129
350
|
|
|
130
|
-
const
|
|
351
|
+
const { skills, errors } = discoverSkills(this.#skillsDir);
|
|
131
352
|
|
|
132
|
-
for (const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (skill && skill.name) {
|
|
137
|
-
this.#skills.set(skill.name, skill);
|
|
138
|
-
} else {
|
|
139
|
-
errors.push(`Failed to parse skill: ${file}`);
|
|
140
|
-
}
|
|
141
|
-
} catch (err) {
|
|
142
|
-
errors.push(`Error loading ${file}: ${err.message}`);
|
|
353
|
+
for (const skill of skills) {
|
|
354
|
+
// Platform filtering at load time
|
|
355
|
+
if (matchesPlatform(skill.platforms)) {
|
|
356
|
+
this.#skills.set(skill.name, skill);
|
|
143
357
|
}
|
|
144
358
|
}
|
|
145
359
|
|
|
@@ -167,20 +381,71 @@ export class SkillManager {
|
|
|
167
381
|
}
|
|
168
382
|
|
|
169
383
|
/**
|
|
170
|
-
* List all skills, optionally filtered by mode.
|
|
384
|
+
* List all skills (metadata only — no content), optionally filtered by mode.
|
|
385
|
+
* This is the "progressive disclosure" list tier.
|
|
171
386
|
*
|
|
172
387
|
* @param {string} [mode] — 'chat' | 'work' | undefined (all)
|
|
173
|
-
* @returns {
|
|
388
|
+
* @returns {Array<{ name: string, description: string, trigger: string, mode: string, category?: string, platforms?: string[], keywords?: string[], source: string, hasReferences: boolean, hasTemplates: boolean }>}
|
|
174
389
|
*/
|
|
175
390
|
list(mode) {
|
|
176
391
|
const skills = [...this.#skills.values()];
|
|
177
|
-
|
|
392
|
+
const filtered = mode ? skills.filter(s => s.mode === 'both' || s.mode === mode) : skills;
|
|
393
|
+
|
|
394
|
+
return filtered.map(s => ({
|
|
395
|
+
name: s.name,
|
|
396
|
+
description: s.description || '',
|
|
397
|
+
trigger: s.trigger || '',
|
|
398
|
+
mode: s.mode || 'both',
|
|
399
|
+
category: s.category || undefined,
|
|
400
|
+
platforms: s.platforms || undefined,
|
|
401
|
+
keywords: s.keywords || undefined,
|
|
402
|
+
source: s._source,
|
|
403
|
+
hasReferences: (s._references && s._references.length > 0) || false,
|
|
404
|
+
hasTemplates: (s._templates && s._templates.length > 0) || false,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* View a skill's full content + linked files (progressive disclosure view tier).
|
|
410
|
+
*
|
|
411
|
+
* @param {string} name — skill name
|
|
412
|
+
* @param {string} [filePath] — specific linked file to read (e.g. "references/style-guide.md")
|
|
413
|
+
* @returns {{ skill: Skill, references: string[], templates: string[], linkedContent?: string } | null}
|
|
414
|
+
*/
|
|
415
|
+
view(name, filePath) {
|
|
416
|
+
const skill = this.#skills.get(name);
|
|
417
|
+
if (!skill) return null;
|
|
418
|
+
|
|
419
|
+
const result = {
|
|
420
|
+
skill,
|
|
421
|
+
references: skill._references || [],
|
|
422
|
+
templates: skill._templates || [],
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Read a specific linked file if requested
|
|
426
|
+
if (filePath && skill._source === 'directory') {
|
|
427
|
+
const fullPath = join(skill._path, filePath);
|
|
428
|
+
// Security: ensure path doesn't escape skill directory
|
|
429
|
+
const resolved = join(skill._path, filePath);
|
|
430
|
+
if (!resolved.startsWith(skill._path)) {
|
|
431
|
+
result.linkedContent = 'Error: path traversal not allowed';
|
|
432
|
+
} else if (existsSync(fullPath)) {
|
|
433
|
+
try {
|
|
434
|
+
result.linkedContent = readFileSync(fullPath, 'utf8');
|
|
435
|
+
} catch (err) {
|
|
436
|
+
result.linkedContent = `Error reading file: ${err.message}`;
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
result.linkedContent = `File not found: ${filePath}`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
178
442
|
|
|
179
|
-
return
|
|
443
|
+
return result;
|
|
180
444
|
}
|
|
181
445
|
|
|
182
446
|
/**
|
|
183
|
-
* Find skills relevant to a prompt
|
|
447
|
+
* Find skills relevant to a prompt.
|
|
448
|
+
* Enhanced matching: regex triggers, keyword lists, name/description match.
|
|
184
449
|
*
|
|
185
450
|
* @param {string} prompt — user's prompt
|
|
186
451
|
* @param {string} [mode] — filter by mode
|
|
@@ -190,38 +455,28 @@ export class SkillManager {
|
|
|
190
455
|
if (!prompt) return [];
|
|
191
456
|
|
|
192
457
|
const lowerPrompt = prompt.toLowerCase();
|
|
193
|
-
// Strip punctuation and split on whitespace for clean word matching
|
|
194
458
|
const cleanPrompt = lowerPrompt.replace(/[^\w\s]/g, '');
|
|
195
459
|
const promptWords = cleanPrompt.split(/\s+/).filter(w => w.length > 2);
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (pw.includes(tw) || tw.includes(pw)) return true;
|
|
209
|
-
const pwStem = pw.slice(0, Math.min(pw.length, 4));
|
|
210
|
-
return twStem.length >= 4 && pwStem.length >= 4 && twStem === pwStem;
|
|
211
|
-
});
|
|
212
|
-
}).length;
|
|
213
|
-
// At least 1 meaningful match and ≥30% of trigger words
|
|
214
|
-
if (matchCount >= 1 && matchCount >= Math.ceil(triggerWords.length * 0.3)) {
|
|
215
|
-
return true;
|
|
216
|
-
}
|
|
460
|
+
const allSkills = [...this.#skills.values()];
|
|
461
|
+
const filtered = mode ? allSkills.filter(s => s.mode === 'both' || s.mode === mode) : allSkills;
|
|
462
|
+
|
|
463
|
+
return filtered.filter(skill => {
|
|
464
|
+
// 1. Regex or keyword trigger match
|
|
465
|
+
if (skill.trigger && matchTrigger(skill.trigger, lowerPrompt, promptWords)) {
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 2. Explicit keywords match
|
|
470
|
+
if (skill.keywords && skill.keywords.length > 0 && matchKeywords(skill.keywords, lowerPrompt)) {
|
|
471
|
+
return true;
|
|
217
472
|
}
|
|
218
473
|
|
|
219
|
-
//
|
|
474
|
+
// 3. Name match
|
|
220
475
|
if (lowerPrompt.includes(skill.name.toLowerCase())) {
|
|
221
476
|
return true;
|
|
222
477
|
}
|
|
223
478
|
|
|
224
|
-
//
|
|
479
|
+
// 4. Description match
|
|
225
480
|
if (skill.description && lowerPrompt.includes(skill.description.toLowerCase())) {
|
|
226
481
|
return true;
|
|
227
482
|
}
|
|
@@ -231,7 +486,7 @@ export class SkillManager {
|
|
|
231
486
|
}
|
|
232
487
|
|
|
233
488
|
/**
|
|
234
|
-
* Add or update a skill.
|
|
489
|
+
* Add or update a skill (single-file format).
|
|
235
490
|
*
|
|
236
491
|
* @param {Skill} skill
|
|
237
492
|
* @returns {string} — filename
|
|
@@ -242,14 +497,18 @@ export class SkillManager {
|
|
|
242
497
|
const filename = `${skill.name}.md`;
|
|
243
498
|
const filePath = join(this.#skillsDir, filename);
|
|
244
499
|
|
|
500
|
+
if (!existsSync(this.#skillsDir)) {
|
|
501
|
+
mkdirSync(this.#skillsDir, { recursive: true });
|
|
502
|
+
}
|
|
503
|
+
|
|
245
504
|
writeFileSync(filePath, serializeSkill(skill), 'utf8');
|
|
246
|
-
this.#skills.set(skill.name, { ...skill,
|
|
505
|
+
this.#skills.set(skill.name, { ...skill, _source: 'file', _path: filePath });
|
|
247
506
|
|
|
248
507
|
return filename;
|
|
249
508
|
}
|
|
250
509
|
|
|
251
510
|
/**
|
|
252
|
-
* Remove a skill.
|
|
511
|
+
* Remove a skill (supports both file and directory skills).
|
|
253
512
|
*
|
|
254
513
|
* @param {string} name
|
|
255
514
|
* @returns {boolean}
|
|
@@ -258,11 +517,14 @@ export class SkillManager {
|
|
|
258
517
|
const skill = this.#skills.get(name);
|
|
259
518
|
if (!skill) return false;
|
|
260
519
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
520
|
+
if (skill._source === 'directory' && skill._path) {
|
|
521
|
+
// For directory skills, we only delete the SKILL.md to "deactivate"
|
|
522
|
+
// Full directory removal is left to the user (too dangerous to rm -rf)
|
|
523
|
+
const skillMd = join(skill._path, 'SKILL.md');
|
|
524
|
+
try { unlinkSync(skillMd); } catch { /* noop */ }
|
|
525
|
+
} else {
|
|
526
|
+
const filePath = skill._path || join(this.#skillsDir, `${name}.md`);
|
|
527
|
+
try { unlinkSync(filePath); } catch { /* noop */ }
|
|
266
528
|
}
|
|
267
529
|
|
|
268
530
|
this.#skills.delete(name);
|
|
@@ -296,6 +558,19 @@ export class SkillManager {
|
|
|
296
558
|
return relevant.map(s => this.getPromptContent(s.name)).join('\n\n');
|
|
297
559
|
}
|
|
298
560
|
|
|
561
|
+
/**
|
|
562
|
+
* List unique categories across all loaded skills.
|
|
563
|
+
*
|
|
564
|
+
* @returns {string[]}
|
|
565
|
+
*/
|
|
566
|
+
listCategories() {
|
|
567
|
+
const categories = new Set();
|
|
568
|
+
for (const skill of this.#skills.values()) {
|
|
569
|
+
if (skill.category) categories.add(skill.category);
|
|
570
|
+
}
|
|
571
|
+
return [...categories].sort();
|
|
572
|
+
}
|
|
573
|
+
|
|
299
574
|
/** Number of loaded skills. */
|
|
300
575
|
get size() {
|
|
301
576
|
return this.#skills.size;
|
package/unify/tools/skill.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill.js — Skill invocation tool
|
|
2
|
+
* skill.js — Skill invocation tool (progressive disclosure)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Actions:
|
|
5
|
+
* list → metadata only (name, description, category, source)
|
|
6
|
+
* view → full skill content + linked files listing
|
|
7
|
+
* search → find relevant skills for a query
|
|
8
|
+
* load → alias for view (backward compat)
|
|
9
|
+
*
|
|
10
|
+
* Directory-based skills (SKILL.md + references/ + templates/) support
|
|
11
|
+
* reading linked files via the view action's filePath parameter.
|
|
6
12
|
*
|
|
7
13
|
* Reference: yeaft-unify-design.md §8
|
|
8
14
|
*/
|
|
@@ -11,31 +17,42 @@ import { defineTool } from './types.js';
|
|
|
11
17
|
|
|
12
18
|
export default defineTool({
|
|
13
19
|
name: 'Skill',
|
|
14
|
-
description: `Load and
|
|
20
|
+
description: `Load and query skills from the Yeaft skill library.
|
|
15
21
|
|
|
16
|
-
Skills are specialized behaviors or workflows
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
- Find relevant skills for the current context
|
|
22
|
+
Skills are specialized behaviors or workflows in ~/.yeaft/skills/.
|
|
23
|
+
Two formats supported:
|
|
24
|
+
- Single file: skills/my-skill.md
|
|
25
|
+
- Directory: skills/my-skill/SKILL.md + references/ + templates/
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
Actions:
|
|
28
|
+
- "list" — list all skills (metadata only: name, description, category)
|
|
29
|
+
- "view" — view a skill's full content. For directory skills, also lists linked files. Pass filePath to read a specific reference/template.
|
|
30
|
+
- "search" — find relevant skills for a query string
|
|
31
|
+
- "load" — alias for "view" (backward compatible)`,
|
|
23
32
|
parameters: {
|
|
24
33
|
type: 'object',
|
|
25
34
|
properties: {
|
|
26
35
|
action: {
|
|
27
36
|
type: 'string',
|
|
28
|
-
enum: ['list', 'load', 'search'],
|
|
29
|
-
description: '"list" lists all skills, "load" loads a specific skill, "search" finds relevant skills',
|
|
37
|
+
enum: ['list', 'view', 'load', 'search'],
|
|
38
|
+
description: '"list" lists all skills, "view"/"load" loads a specific skill, "search" finds relevant skills',
|
|
30
39
|
},
|
|
31
40
|
name: {
|
|
32
41
|
type: 'string',
|
|
33
|
-
description: 'Skill name (for "load" action)',
|
|
42
|
+
description: 'Skill name (for "view"/"load" action)',
|
|
34
43
|
},
|
|
35
44
|
query: {
|
|
36
45
|
type: 'string',
|
|
37
46
|
description: 'Search query (for "search" action)',
|
|
38
47
|
},
|
|
48
|
+
filePath: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Read a linked file from a directory skill (e.g. "references/style-guide.md")',
|
|
51
|
+
},
|
|
52
|
+
category: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'Filter by category (for "list" action)',
|
|
55
|
+
},
|
|
39
56
|
},
|
|
40
57
|
required: ['action'],
|
|
41
58
|
},
|
|
@@ -53,36 +70,61 @@ Skills provide domain-specific guidance and workflows that enhance your capabili
|
|
|
53
70
|
|
|
54
71
|
switch (input.action) {
|
|
55
72
|
case 'list': {
|
|
56
|
-
|
|
73
|
+
let skills = skillManager.list();
|
|
57
74
|
if (skills.length === 0) {
|
|
58
75
|
return JSON.stringify({
|
|
59
76
|
skills: [],
|
|
60
|
-
|
|
77
|
+
categories: [],
|
|
78
|
+
message: 'No skills found. Add .md files or directories with SKILL.md to ~/.yeaft/skills/',
|
|
61
79
|
});
|
|
62
80
|
}
|
|
81
|
+
// Filter by category if specified
|
|
82
|
+
if (input.category) {
|
|
83
|
+
skills = skills.filter(s => s.category === input.category || (s.category && s.category.startsWith(input.category + '/')));
|
|
84
|
+
}
|
|
63
85
|
return JSON.stringify({
|
|
64
|
-
skills
|
|
65
|
-
|
|
66
|
-
description: s.description || '',
|
|
67
|
-
trigger: s.trigger || '',
|
|
68
|
-
mode: s.mode || 'both',
|
|
69
|
-
})),
|
|
86
|
+
skills,
|
|
87
|
+
categories: skillManager.listCategories(),
|
|
70
88
|
totalCount: skills.length,
|
|
71
89
|
}, null, 2);
|
|
72
90
|
}
|
|
73
91
|
|
|
92
|
+
case 'view':
|
|
74
93
|
case 'load': {
|
|
75
94
|
if (!input.name) {
|
|
76
|
-
return JSON.stringify({ error: 'Skill name is required for "
|
|
95
|
+
return JSON.stringify({ error: 'Skill name is required for "view" action' });
|
|
77
96
|
}
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
97
|
+
const result = skillManager.view(input.name, input.filePath);
|
|
98
|
+
if (!result) {
|
|
80
99
|
return JSON.stringify({
|
|
81
100
|
error: `Skill "${input.name}" not found`,
|
|
82
101
|
available: skillManager.list().map(s => s.name),
|
|
83
102
|
});
|
|
84
103
|
}
|
|
85
|
-
|
|
104
|
+
|
|
105
|
+
// If reading a specific linked file, return just that content
|
|
106
|
+
if (input.filePath && result.linkedContent !== undefined) {
|
|
107
|
+
return result.linkedContent;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Return full skill content + linked file listing
|
|
111
|
+
const output = {
|
|
112
|
+
name: result.skill.name,
|
|
113
|
+
description: result.skill.description || '',
|
|
114
|
+
mode: result.skill.mode,
|
|
115
|
+
category: result.skill.category || null,
|
|
116
|
+
source: result.skill._source,
|
|
117
|
+
content: result.skill.content,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (result.references.length > 0) {
|
|
121
|
+
output.references = result.references;
|
|
122
|
+
}
|
|
123
|
+
if (result.templates.length > 0) {
|
|
124
|
+
output.templates = result.templates;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return JSON.stringify(output, null, 2);
|
|
86
128
|
}
|
|
87
129
|
|
|
88
130
|
case 'search': {
|
|
@@ -95,13 +137,15 @@ Skills provide domain-specific guidance and workflows that enhance your capabili
|
|
|
95
137
|
name: s.name,
|
|
96
138
|
description: s.description || '',
|
|
97
139
|
trigger: s.trigger || '',
|
|
140
|
+
category: s.category || null,
|
|
141
|
+
source: s._source,
|
|
98
142
|
})),
|
|
99
143
|
totalResults: results.length,
|
|
100
144
|
}, null, 2);
|
|
101
145
|
}
|
|
102
146
|
|
|
103
147
|
default:
|
|
104
|
-
return JSON.stringify({ error: `Unknown action: ${input.action}. Use "list", "
|
|
148
|
+
return JSON.stringify({ error: `Unknown action: ${input.action}. Use "list", "view", or "search".` });
|
|
105
149
|
}
|
|
106
150
|
},
|
|
107
151
|
});
|