skyloom 1.14.6 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
package/src/core/skill.ts CHANGED
@@ -1,384 +1,384 @@
1
- /**
2
- * Skill system — Anthropic-compatible composable capability modules.
3
- *
4
- * Skills use Markdown + YAML frontmatter format. When activated, a skill
5
- * injects its system prompt and can register custom handler tools.
6
- */
7
-
8
- import * as fs from 'fs';
9
- import * as path from 'path';
10
- import { parse as parseYaml } from 'yaml';
11
-
12
- // Tuning for lazy skill loading
13
- const SKILL_BODY_LITE_THRESHOLD = 2000;
14
- const SKILL_BODY_LITE_MAX_CHARS = 1500;
15
-
16
- // Claude Code -> sky tool-name aliases
17
- const CLAUDE_TOOL_ALIASES: Record<string, string> = {
18
- read: 'read_file',
19
- write: 'write_file',
20
- edit: 'edit_file',
21
- multiedit: 'edit_file',
22
- delete: 'delete_file',
23
- bash: 'run_bash',
24
- shell: 'run_bash',
25
- grep: 'grep',
26
- glob: 'file_search',
27
- search: 'code_search',
28
- websearch: 'web_search',
29
- webfetch: 'fetch_page',
30
- ls: 'list_directory',
31
- tree: 'tree',
32
- fetch: 'http_get',
33
- taskdone: 'task_done',
34
- };
35
-
36
- /**
37
- * A composable capability module for an agent.
38
- */
39
- export class Skill {
40
- name: string;
41
- description: string;
42
- systemPrompt: string = '';
43
- requiredTools: string[] = [];
44
- tools: any[] = [];
45
- handler: ((agent: any, toolRegistry: any) => any[]) | null = null;
46
- model: string | null = null;
47
- temperature: number | null = null;
48
- maxTokens: number | null = null;
49
- triggers: string[] = [];
50
- resourceDir: string | null = null;
51
- license: string | null = null;
52
- allowedTools: string[] | null = null;
53
- sourcePath: string | null = null;
54
- bodyTruncated: boolean = false;
55
- metadata: Record<string, any> = {};
56
-
57
- constructor(config: Partial<SkillConfig>) {
58
- this.name = config.name || '';
59
- this.description = config.description || '';
60
- this.systemPrompt = config.systemPrompt || '';
61
- this.requiredTools = config.requiredTools || [];
62
- this.tools = config.tools || [];
63
- this.handler = config.handler || null;
64
- this.model = config.model || null;
65
- this.temperature = config.temperature ?? null;
66
- this.maxTokens = config.maxTokens ?? null;
67
- this.triggers = config.triggers || [];
68
- this.resourceDir = config.resourceDir || null;
69
- this.license = config.license || null;
70
- this.allowedTools = config.allowedTools || null;
71
- this.sourcePath = config.sourcePath || null;
72
- this.bodyTruncated = config.bodyTruncated || false;
73
- this.metadata = config.metadata || {};
74
- }
75
-
76
- /**
77
- * Load a skill from a Markdown file with YAML frontmatter.
78
- */
79
- static fromMarkdown(filePath: string): Skill | null {
80
- const p = path.resolve(filePath);
81
- let text: string;
82
- try {
83
- text = fs.readFileSync(p, 'utf-8');
84
- } catch {
85
- return null;
86
- }
87
-
88
- const parsed = parseFrontmatter(text);
89
- if (!parsed) return null;
90
-
91
- const { fm, body } = parsed;
92
- const name = (fm.name as string) || path.basename(p, '.md');
93
- const description = (fm.description as string) || '';
94
-
95
- const toolsRaw = fm.tools;
96
- const requiredTools: string[] = Array.isArray(toolsRaw)
97
- ? toolsRaw.filter((t: any) => typeof t === 'string')
98
- : [];
99
-
100
- // Config overrides
101
- const model = fm.model as string | undefined;
102
- const temperature = fm.temperature as number | undefined;
103
- const maxTokens = (fm.maxTokens ?? fm.max_tokens) as number | undefined;
104
-
105
- // Triggers
106
- const triggersRaw = fm.triggers;
107
- const triggers: string[] = Array.isArray(triggersRaw)
108
- ? triggersRaw.filter((t: any) => typeof t === 'string')
109
- : [];
110
-
111
- // Auto-derive triggers from description if not specified
112
- const finalTriggers = triggers.length > 0 ? triggers
113
- : (description ? deriveTriggersFromDescription(description) : []);
114
-
115
- // License and allowed-tools
116
- const licenseRaw = fm.license as string | undefined;
117
- const license = licenseRaw?.trim() || null;
118
-
119
- const allowedRaw = fm['allowed-tools'] ?? fm.allowed_tools;
120
- let allowedTools: string[] | null = null;
121
- if (Array.isArray(allowedRaw)) {
122
- allowedTools = allowedRaw.filter((t: any) => typeof t === 'string');
123
- if (allowedTools.length === 0) allowedTools = null;
124
- } else if (typeof allowedRaw === 'string' && allowedRaw.trim()) {
125
- allowedTools = allowedRaw.split(',').map((t: string) => t.trim()).filter(Boolean);
126
- }
127
- if (allowedTools) {
128
- allowedTools = allowedTools.map(t => normalizeClaudeToolName(t));
129
- // Dedupe preserving order
130
- const seen = new Set<string>();
131
- const deduped: string[] = [];
132
- for (const t of allowedTools) {
133
- if (!seen.has(t)) { seen.add(t); deduped.push(t); }
134
- }
135
- allowedTools = deduped;
136
- }
137
-
138
- // Preserve metadata fields
139
- const knownKeys = new Set([
140
- 'name', 'description', 'tools', 'model', 'temperature', 'maxTokens', 'max_tokens',
141
- 'triggers', 'license', 'allowed-tools', 'allowed_tools',
142
- ]);
143
- const extraMetadata: Record<string, any> = {};
144
- for (const [k, v] of Object.entries(fm)) {
145
- if (!knownKeys.has(k) && !k.startsWith('_')) {
146
- extraMetadata[k] = v;
147
- }
148
- }
149
-
150
- // Lazy-load large skill bodies
151
- let bodyStripped = body.trim();
152
- let bodyTruncated = false;
153
- if (bodyStripped.length > SKILL_BODY_LITE_THRESHOLD) {
154
- bodyStripped = extractSkillHead(bodyStripped, SKILL_BODY_LITE_MAX_CHARS);
155
- bodyTruncated = true;
156
- }
157
-
158
- return new Skill({
159
- name,
160
- description,
161
- systemPrompt: bodyStripped,
162
- requiredTools,
163
- model: typeof model === 'string' ? model : null,
164
- temperature: typeof temperature === 'number' ? temperature : null,
165
- maxTokens: typeof maxTokens === 'number' ? maxTokens : null,
166
- triggers: finalTriggers,
167
- license,
168
- allowedTools,
169
- sourcePath: p,
170
- bodyTruncated,
171
- metadata: extraMetadata,
172
- });
173
- }
174
- }
175
-
176
- interface SkillConfig {
177
- name: string;
178
- description: string;
179
- systemPrompt?: string;
180
- requiredTools?: string[];
181
- tools?: any[];
182
- handler?: ((agent: any, toolRegistry: any) => any[]) | null;
183
- model?: string | null;
184
- temperature?: number | null;
185
- maxTokens?: number | null;
186
- triggers?: string[];
187
- resourceDir?: string | null;
188
- license?: string | null;
189
- allowedTools?: string[] | null;
190
- sourcePath?: string | null;
191
- bodyTruncated?: boolean;
192
- metadata?: Record<string, any>;
193
- }
194
-
195
- /**
196
- * Parse YAML frontmatter from markdown text.
197
- * Returns { fm, body } or null.
198
- */
199
- function parseFrontmatter(text: string): { fm: Record<string, any>; body: string } | null {
200
- const match = text.match(/^---\s*\n(.*?)\n---\s*\n?(.*)/s);
201
- if (!match) return null;
202
- try {
203
- const fm = parseYaml(match[1]) || {};
204
- return { fm, body: match[2] };
205
- } catch {
206
- return null;
207
- }
208
- }
209
-
210
- /**
211
- * Normalize a Claude Code tool name into sky's registry name.
212
- */
213
- function normalizeClaudeToolName(raw: string): string {
214
- let s = raw.trim();
215
- if (!s) return s;
216
- // Strip permission scoping: Bash(ls *) -> Bash
217
- const paren = s.indexOf('(');
218
- if (paren > 0) s = s.slice(0, paren).trim();
219
- // Check if it's already a valid sky name
220
- const aliasValues = new Set(Object.values(CLAUDE_TOOL_ALIASES));
221
- if (aliasValues.has(s)) return s;
222
- return CLAUDE_TOOL_ALIASES[s.toLowerCase()] ?? s;
223
- }
224
-
225
- /**
226
- * Extract the head of a SKILL.md body — title plus first major section.
227
- */
228
- function extractSkillHead(body: string, maxChars: number): string {
229
- const out: string[] = [];
230
- let charCount = 0;
231
- let h2Count = 0;
232
- for (const line of body.split('\n')) {
233
- const isH2 = line.startsWith('## ') && !line.startsWith('### ');
234
- if (isH2) {
235
- h2Count++;
236
- if (h2Count > 1) break;
237
- }
238
- if (out.length > 0 && charCount + line.length + 1 > maxChars) break;
239
- out.push(line);
240
- charCount += line.length + 1;
241
- }
242
- return out.join('\n').trimEnd();
243
- }
244
-
245
- // Patterns for auto-deriving triggers
246
- const TRIGGER_QUOTED = /["'"'""]([^"'""\n]{1,40})["'""']/g;
247
- const TRIGGER_EXT = /(?<![A-Za-z0-9])\.[A-Za-z0-9]{2,6}\b/g;
248
- const TRIGGER_STRIP = " \t,.;:!?,。、;:!?、。";
249
-
250
- /**
251
- * Pull candidate trigger phrases out of a skill description.
252
- */
253
- function deriveTriggersFromDescription(description: string): string[] {
254
- const raw: string[] = [];
255
- let m: RegExpExecArray | null;
256
- while ((m = TRIGGER_QUOTED.exec(description)) !== null) {
257
- raw.push(m[1]);
258
- }
259
- while ((m = TRIGGER_EXT.exec(description)) !== null) {
260
- raw.push(m[0]);
261
- }
262
-
263
- const seen = new Set<string>();
264
- const out: string[] = [];
265
- for (let token of raw) {
266
- token = token.replace(/[ \t,.;:!?,。、;:!?、。]+$/, '').replace(/^[ \t]+/, '');
267
- if (!token || token.length < 2) continue;
268
- const key = token.toLowerCase();
269
- if (seen.has(key)) continue;
270
- seen.add(key);
271
- out.push(token);
272
- if (out.length >= 12) break;
273
- }
274
- return out;
275
- }
276
-
277
- /**
278
- * Central registry for all available skills.
279
- */
280
- export class SkillRegistry {
281
- private _skills: Map<string, Skill> = new Map();
282
-
283
- register(skill: Skill): void {
284
- this._skills.set(skill.name, skill);
285
- }
286
-
287
- get(name: string): Skill | undefined {
288
- return this._skills.get(name);
289
- }
290
-
291
- getSkills(names?: string[]): Skill[] {
292
- if (!names) return Array.from(this._skills.values());
293
- return names.map(n => this._skills.get(n)).filter(Boolean) as Skill[];
294
- }
295
-
296
- listNames(): string[] {
297
- return Array.from(this._skills.keys());
298
- }
299
-
300
- merge(other: SkillRegistry): void {
301
- for (const [name, skill] of other._skills) {
302
- this._skills.set(name, skill);
303
- }
304
- }
305
-
306
- /**
307
- * Load folder-style skills: `<root>/<skill-name>/SKILL.md` (the Claude
308
- * Code layout). Only SKILL.md is parsed — sibling files (reference.md,
309
- * scripts/…) are the skill's resources, not separate skills. The skill's
310
- * resourceDir is its own folder so relative references resolve.
311
- */
312
- loadSkillFolders(rootDir: string): Skill[] {
313
- const loaded: Skill[] = [];
314
- let root: string;
315
- try {
316
- root = path.resolve(rootDir.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ''));
317
- } catch {
318
- return loaded;
319
- }
320
- if (!fs.existsSync(root)) return loaded;
321
-
322
- let entries: string[];
323
- try {
324
- entries = fs.readdirSync(root);
325
- } catch {
326
- return loaded;
327
- }
328
- for (const entry of entries.sort()) {
329
- if (entry.startsWith('.') || entry.startsWith('_')) continue;
330
- const skillDir = path.join(root, entry);
331
- try {
332
- if (!fs.statSync(skillDir).isDirectory()) continue;
333
- } catch {
334
- continue;
335
- }
336
- const skillFile = path.join(skillDir, 'SKILL.md');
337
- if (!fs.existsSync(skillFile)) continue;
338
- const skill = Skill.fromMarkdown(skillFile);
339
- if (skill) {
340
- skill.resourceDir = skillDir;
341
- this.register(skill);
342
- loaded.push(skill);
343
- }
344
- }
345
- return loaded;
346
- }
347
-
348
- /**
349
- * Load all .md skill files from a directory (Anthropic format).
350
- */
351
- loadSkillsFromDirectory(directory: string): Skill[] {
352
- const loaded: Skill[] = [];
353
- let dirPath: string;
354
- try {
355
- dirPath = path.resolve(directory.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ''));
356
- } catch {
357
- return loaded;
358
- }
359
-
360
- if (!fs.existsSync(dirPath)) return loaded;
361
-
362
- let entries: string[];
363
- try {
364
- entries = fs.readdirSync(dirPath);
365
- } catch {
366
- return loaded;
367
- }
368
-
369
- for (const entry of entries.sort()) {
370
- if (entry.startsWith('_') || entry.startsWith('.') || !entry.endsWith('.md')) continue;
371
- const fullPath = path.join(dirPath, entry);
372
- const skill = Skill.fromMarkdown(fullPath);
373
- if (skill) {
374
- skill.resourceDir = dirPath;
375
- this.register(skill);
376
- loaded.push(skill);
377
- }
378
- }
379
- return loaded;
380
- }
381
- }
382
-
383
- // Global skill registry singleton
384
- export const globalSkillRegistry = new SkillRegistry();
1
+ /**
2
+ * Skill system — Anthropic-compatible composable capability modules.
3
+ *
4
+ * Skills use Markdown + YAML frontmatter format. When activated, a skill
5
+ * injects its system prompt and can register custom handler tools.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { parse as parseYaml } from 'yaml';
11
+
12
+ // Tuning for lazy skill loading
13
+ const SKILL_BODY_LITE_THRESHOLD = 2000;
14
+ const SKILL_BODY_LITE_MAX_CHARS = 1500;
15
+
16
+ // Claude Code -> sky tool-name aliases
17
+ const CLAUDE_TOOL_ALIASES: Record<string, string> = {
18
+ read: 'read_file',
19
+ write: 'write_file',
20
+ edit: 'edit_file',
21
+ multiedit: 'edit_file',
22
+ delete: 'delete_file',
23
+ bash: 'run_bash',
24
+ shell: 'run_bash',
25
+ grep: 'grep',
26
+ glob: 'file_search',
27
+ search: 'code_search',
28
+ websearch: 'web_search',
29
+ webfetch: 'fetch_page',
30
+ ls: 'list_directory',
31
+ tree: 'tree',
32
+ fetch: 'http_get',
33
+ taskdone: 'task_done',
34
+ };
35
+
36
+ /**
37
+ * A composable capability module for an agent.
38
+ */
39
+ export class Skill {
40
+ name: string;
41
+ description: string;
42
+ systemPrompt: string = '';
43
+ requiredTools: string[] = [];
44
+ tools: any[] = [];
45
+ handler: ((agent: any, toolRegistry: any) => any[]) | null = null;
46
+ model: string | null = null;
47
+ temperature: number | null = null;
48
+ maxTokens: number | null = null;
49
+ triggers: string[] = [];
50
+ resourceDir: string | null = null;
51
+ license: string | null = null;
52
+ allowedTools: string[] | null = null;
53
+ sourcePath: string | null = null;
54
+ bodyTruncated: boolean = false;
55
+ metadata: Record<string, any> = {};
56
+
57
+ constructor(config: Partial<SkillConfig>) {
58
+ this.name = config.name || '';
59
+ this.description = config.description || '';
60
+ this.systemPrompt = config.systemPrompt || '';
61
+ this.requiredTools = config.requiredTools || [];
62
+ this.tools = config.tools || [];
63
+ this.handler = config.handler || null;
64
+ this.model = config.model || null;
65
+ this.temperature = config.temperature ?? null;
66
+ this.maxTokens = config.maxTokens ?? null;
67
+ this.triggers = config.triggers || [];
68
+ this.resourceDir = config.resourceDir || null;
69
+ this.license = config.license || null;
70
+ this.allowedTools = config.allowedTools || null;
71
+ this.sourcePath = config.sourcePath || null;
72
+ this.bodyTruncated = config.bodyTruncated || false;
73
+ this.metadata = config.metadata || {};
74
+ }
75
+
76
+ /**
77
+ * Load a skill from a Markdown file with YAML frontmatter.
78
+ */
79
+ static fromMarkdown(filePath: string): Skill | null {
80
+ const p = path.resolve(filePath);
81
+ let text: string;
82
+ try {
83
+ text = fs.readFileSync(p, 'utf-8');
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ const parsed = parseFrontmatter(text);
89
+ if (!parsed) return null;
90
+
91
+ const { fm, body } = parsed;
92
+ const name = (fm.name as string) || path.basename(p, '.md');
93
+ const description = (fm.description as string) || '';
94
+
95
+ const toolsRaw = fm.tools;
96
+ const requiredTools: string[] = Array.isArray(toolsRaw)
97
+ ? toolsRaw.filter((t: any) => typeof t === 'string')
98
+ : [];
99
+
100
+ // Config overrides
101
+ const model = fm.model as string | undefined;
102
+ const temperature = fm.temperature as number | undefined;
103
+ const maxTokens = (fm.maxTokens ?? fm.max_tokens) as number | undefined;
104
+
105
+ // Triggers
106
+ const triggersRaw = fm.triggers;
107
+ const triggers: string[] = Array.isArray(triggersRaw)
108
+ ? triggersRaw.filter((t: any) => typeof t === 'string')
109
+ : [];
110
+
111
+ // Auto-derive triggers from description if not specified
112
+ const finalTriggers = triggers.length > 0 ? triggers
113
+ : (description ? deriveTriggersFromDescription(description) : []);
114
+
115
+ // License and allowed-tools
116
+ const licenseRaw = fm.license as string | undefined;
117
+ const license = licenseRaw?.trim() || null;
118
+
119
+ const allowedRaw = fm['allowed-tools'] ?? fm.allowed_tools;
120
+ let allowedTools: string[] | null = null;
121
+ if (Array.isArray(allowedRaw)) {
122
+ allowedTools = allowedRaw.filter((t: any) => typeof t === 'string');
123
+ if (allowedTools.length === 0) allowedTools = null;
124
+ } else if (typeof allowedRaw === 'string' && allowedRaw.trim()) {
125
+ allowedTools = allowedRaw.split(',').map((t: string) => t.trim()).filter(Boolean);
126
+ }
127
+ if (allowedTools) {
128
+ allowedTools = allowedTools.map(t => normalizeClaudeToolName(t));
129
+ // Dedupe preserving order
130
+ const seen = new Set<string>();
131
+ const deduped: string[] = [];
132
+ for (const t of allowedTools) {
133
+ if (!seen.has(t)) { seen.add(t); deduped.push(t); }
134
+ }
135
+ allowedTools = deduped;
136
+ }
137
+
138
+ // Preserve metadata fields
139
+ const knownKeys = new Set([
140
+ 'name', 'description', 'tools', 'model', 'temperature', 'maxTokens', 'max_tokens',
141
+ 'triggers', 'license', 'allowed-tools', 'allowed_tools',
142
+ ]);
143
+ const extraMetadata: Record<string, any> = {};
144
+ for (const [k, v] of Object.entries(fm)) {
145
+ if (!knownKeys.has(k) && !k.startsWith('_')) {
146
+ extraMetadata[k] = v;
147
+ }
148
+ }
149
+
150
+ // Lazy-load large skill bodies
151
+ let bodyStripped = body.trim();
152
+ let bodyTruncated = false;
153
+ if (bodyStripped.length > SKILL_BODY_LITE_THRESHOLD) {
154
+ bodyStripped = extractSkillHead(bodyStripped, SKILL_BODY_LITE_MAX_CHARS);
155
+ bodyTruncated = true;
156
+ }
157
+
158
+ return new Skill({
159
+ name,
160
+ description,
161
+ systemPrompt: bodyStripped,
162
+ requiredTools,
163
+ model: typeof model === 'string' ? model : null,
164
+ temperature: typeof temperature === 'number' ? temperature : null,
165
+ maxTokens: typeof maxTokens === 'number' ? maxTokens : null,
166
+ triggers: finalTriggers,
167
+ license,
168
+ allowedTools,
169
+ sourcePath: p,
170
+ bodyTruncated,
171
+ metadata: extraMetadata,
172
+ });
173
+ }
174
+ }
175
+
176
+ interface SkillConfig {
177
+ name: string;
178
+ description: string;
179
+ systemPrompt?: string;
180
+ requiredTools?: string[];
181
+ tools?: any[];
182
+ handler?: ((agent: any, toolRegistry: any) => any[]) | null;
183
+ model?: string | null;
184
+ temperature?: number | null;
185
+ maxTokens?: number | null;
186
+ triggers?: string[];
187
+ resourceDir?: string | null;
188
+ license?: string | null;
189
+ allowedTools?: string[] | null;
190
+ sourcePath?: string | null;
191
+ bodyTruncated?: boolean;
192
+ metadata?: Record<string, any>;
193
+ }
194
+
195
+ /**
196
+ * Parse YAML frontmatter from markdown text.
197
+ * Returns { fm, body } or null.
198
+ */
199
+ function parseFrontmatter(text: string): { fm: Record<string, any>; body: string } | null {
200
+ const match = text.match(/^---\s*\n(.*?)\n---\s*\n?(.*)/s);
201
+ if (!match) return null;
202
+ try {
203
+ const fm = parseYaml(match[1]) || {};
204
+ return { fm, body: match[2] };
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Normalize a Claude Code tool name into sky's registry name.
212
+ */
213
+ function normalizeClaudeToolName(raw: string): string {
214
+ let s = raw.trim();
215
+ if (!s) return s;
216
+ // Strip permission scoping: Bash(ls *) -> Bash
217
+ const paren = s.indexOf('(');
218
+ if (paren > 0) s = s.slice(0, paren).trim();
219
+ // Check if it's already a valid sky name
220
+ const aliasValues = new Set(Object.values(CLAUDE_TOOL_ALIASES));
221
+ if (aliasValues.has(s)) return s;
222
+ return CLAUDE_TOOL_ALIASES[s.toLowerCase()] ?? s;
223
+ }
224
+
225
+ /**
226
+ * Extract the head of a SKILL.md body — title plus first major section.
227
+ */
228
+ function extractSkillHead(body: string, maxChars: number): string {
229
+ const out: string[] = [];
230
+ let charCount = 0;
231
+ let h2Count = 0;
232
+ for (const line of body.split('\n')) {
233
+ const isH2 = line.startsWith('## ') && !line.startsWith('### ');
234
+ if (isH2) {
235
+ h2Count++;
236
+ if (h2Count > 1) break;
237
+ }
238
+ if (out.length > 0 && charCount + line.length + 1 > maxChars) break;
239
+ out.push(line);
240
+ charCount += line.length + 1;
241
+ }
242
+ return out.join('\n').trimEnd();
243
+ }
244
+
245
+ // Patterns for auto-deriving triggers
246
+ const TRIGGER_QUOTED = /["'"'""]([^"'""\n]{1,40})["'""']/g;
247
+ const TRIGGER_EXT = /(?<![A-Za-z0-9])\.[A-Za-z0-9]{2,6}\b/g;
248
+ const TRIGGER_STRIP = " \t,.;:!?,。、;:!?、。";
249
+
250
+ /**
251
+ * Pull candidate trigger phrases out of a skill description.
252
+ */
253
+ function deriveTriggersFromDescription(description: string): string[] {
254
+ const raw: string[] = [];
255
+ let m: RegExpExecArray | null;
256
+ while ((m = TRIGGER_QUOTED.exec(description)) !== null) {
257
+ raw.push(m[1]);
258
+ }
259
+ while ((m = TRIGGER_EXT.exec(description)) !== null) {
260
+ raw.push(m[0]);
261
+ }
262
+
263
+ const seen = new Set<string>();
264
+ const out: string[] = [];
265
+ for (let token of raw) {
266
+ token = token.replace(/[ \t,.;:!?,。、;:!?、。]+$/, '').replace(/^[ \t]+/, '');
267
+ if (!token || token.length < 2) continue;
268
+ const key = token.toLowerCase();
269
+ if (seen.has(key)) continue;
270
+ seen.add(key);
271
+ out.push(token);
272
+ if (out.length >= 12) break;
273
+ }
274
+ return out;
275
+ }
276
+
277
+ /**
278
+ * Central registry for all available skills.
279
+ */
280
+ export class SkillRegistry {
281
+ private _skills: Map<string, Skill> = new Map();
282
+
283
+ register(skill: Skill): void {
284
+ this._skills.set(skill.name, skill);
285
+ }
286
+
287
+ get(name: string): Skill | undefined {
288
+ return this._skills.get(name);
289
+ }
290
+
291
+ getSkills(names?: string[]): Skill[] {
292
+ if (!names) return Array.from(this._skills.values());
293
+ return names.map(n => this._skills.get(n)).filter(Boolean) as Skill[];
294
+ }
295
+
296
+ listNames(): string[] {
297
+ return Array.from(this._skills.keys());
298
+ }
299
+
300
+ merge(other: SkillRegistry): void {
301
+ for (const [name, skill] of other._skills) {
302
+ this._skills.set(name, skill);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Load folder-style skills: `<root>/<skill-name>/SKILL.md` (the Claude
308
+ * Code layout). Only SKILL.md is parsed — sibling files (reference.md,
309
+ * scripts/…) are the skill's resources, not separate skills. The skill's
310
+ * resourceDir is its own folder so relative references resolve.
311
+ */
312
+ loadSkillFolders(rootDir: string): Skill[] {
313
+ const loaded: Skill[] = [];
314
+ let root: string;
315
+ try {
316
+ root = path.resolve(rootDir.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ''));
317
+ } catch {
318
+ return loaded;
319
+ }
320
+ if (!fs.existsSync(root)) return loaded;
321
+
322
+ let entries: string[];
323
+ try {
324
+ entries = fs.readdirSync(root);
325
+ } catch {
326
+ return loaded;
327
+ }
328
+ for (const entry of entries.sort()) {
329
+ if (entry.startsWith('.') || entry.startsWith('_')) continue;
330
+ const skillDir = path.join(root, entry);
331
+ try {
332
+ if (!fs.statSync(skillDir).isDirectory()) continue;
333
+ } catch {
334
+ continue;
335
+ }
336
+ const skillFile = path.join(skillDir, 'SKILL.md');
337
+ if (!fs.existsSync(skillFile)) continue;
338
+ const skill = Skill.fromMarkdown(skillFile);
339
+ if (skill) {
340
+ skill.resourceDir = skillDir;
341
+ this.register(skill);
342
+ loaded.push(skill);
343
+ }
344
+ }
345
+ return loaded;
346
+ }
347
+
348
+ /**
349
+ * Load all .md skill files from a directory (Anthropic format).
350
+ */
351
+ loadSkillsFromDirectory(directory: string): Skill[] {
352
+ const loaded: Skill[] = [];
353
+ let dirPath: string;
354
+ try {
355
+ dirPath = path.resolve(directory.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ''));
356
+ } catch {
357
+ return loaded;
358
+ }
359
+
360
+ if (!fs.existsSync(dirPath)) return loaded;
361
+
362
+ let entries: string[];
363
+ try {
364
+ entries = fs.readdirSync(dirPath);
365
+ } catch {
366
+ return loaded;
367
+ }
368
+
369
+ for (const entry of entries.sort()) {
370
+ if (entry.startsWith('_') || entry.startsWith('.') || !entry.endsWith('.md')) continue;
371
+ const fullPath = path.join(dirPath, entry);
372
+ const skill = Skill.fromMarkdown(fullPath);
373
+ if (skill) {
374
+ skill.resourceDir = dirPath;
375
+ this.register(skill);
376
+ loaded.push(skill);
377
+ }
378
+ }
379
+ return loaded;
380
+ }
381
+ }
382
+
383
+ // Global skill registry singleton
384
+ export const globalSkillRegistry = new SkillRegistry();