skyloom 1.14.8 → 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 (156) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +51 -4
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/config/default.yaml +46 -43
  5. package/config/models.yaml +928 -155
  6. package/config/providers.yaml +109 -6
  7. package/dist/agents/snow.d.ts +2 -0
  8. package/dist/agents/snow.d.ts.map +1 -1
  9. package/dist/agents/snow.js +36 -5
  10. package/dist/agents/snow.js.map +1 -1
  11. package/dist/cli/loom_chat.d.ts.map +1 -1
  12. package/dist/cli/loom_chat.js +207 -1
  13. package/dist/cli/loom_chat.js.map +1 -1
  14. package/dist/cli/main.js +190 -40
  15. package/dist/cli/main.js.map +1 -1
  16. package/dist/cli/tui.d.ts.map +1 -1
  17. package/dist/cli/tui.js +6 -31
  18. package/dist/cli/tui.js.map +1 -1
  19. package/dist/core/agent.d.ts +6 -4
  20. package/dist/core/agent.d.ts.map +1 -1
  21. package/dist/core/agent.js +61 -20
  22. package/dist/core/agent.js.map +1 -1
  23. package/dist/core/catalog.d.ts.map +1 -1
  24. package/dist/core/catalog.js +30 -9
  25. package/dist/core/catalog.js.map +1 -1
  26. package/dist/core/commands.d.ts +110 -0
  27. package/dist/core/commands.d.ts.map +1 -0
  28. package/dist/core/commands.js +633 -0
  29. package/dist/core/commands.js.map +1 -0
  30. package/dist/core/concurrency.d.ts +38 -0
  31. package/dist/core/concurrency.d.ts.map +1 -0
  32. package/dist/core/concurrency.js +65 -0
  33. package/dist/core/concurrency.js.map +1 -0
  34. package/dist/core/factory.js +16 -16
  35. package/dist/core/file_checkpoint.d.ts +9 -0
  36. package/dist/core/file_checkpoint.d.ts.map +1 -1
  37. package/dist/core/file_checkpoint.js +33 -1
  38. package/dist/core/file_checkpoint.js.map +1 -1
  39. package/dist/core/llm.d.ts.map +1 -1
  40. package/dist/core/llm.js +66 -13
  41. package/dist/core/llm.js.map +1 -1
  42. package/dist/core/memory.js +51 -51
  43. package/dist/core/schemas.d.ts +16 -0
  44. package/dist/core/schemas.d.ts.map +1 -1
  45. package/dist/core/schemas.js +32 -0
  46. package/dist/core/schemas.js.map +1 -1
  47. package/dist/core/security.d.ts.map +1 -1
  48. package/dist/core/security.js +27 -0
  49. package/dist/core/security.js.map +1 -1
  50. package/dist/core/skymd.js +14 -14
  51. package/dist/core/trace.d.ts +105 -0
  52. package/dist/core/trace.d.ts.map +1 -0
  53. package/dist/core/trace.js +213 -0
  54. package/dist/core/trace.js.map +1 -0
  55. package/dist/tools/builtin.d.ts +2 -6
  56. package/dist/tools/builtin.d.ts.map +1 -1
  57. package/dist/tools/builtin.js +18 -111
  58. package/dist/tools/builtin.js.map +1 -1
  59. package/dist/tools/extra.d.ts +13 -0
  60. package/dist/tools/extra.d.ts.map +1 -0
  61. package/dist/tools/extra.js +827 -0
  62. package/dist/tools/extra.js.map +1 -0
  63. package/dist/tools/guards.d.ts +12 -0
  64. package/dist/tools/guards.d.ts.map +1 -0
  65. package/dist/tools/guards.js +143 -0
  66. package/dist/tools/guards.js.map +1 -0
  67. package/dist/tools/model_tool.d.ts.map +1 -1
  68. package/dist/tools/model_tool.js +24 -4
  69. package/dist/tools/model_tool.js.map +1 -1
  70. package/dist/web/markdown.d.ts +32 -0
  71. package/dist/web/markdown.d.ts.map +1 -0
  72. package/dist/web/markdown.js +202 -0
  73. package/dist/web/markdown.js.map +1 -0
  74. package/dist/web/server.d.ts +4 -0
  75. package/dist/web/server.d.ts.map +1 -1
  76. package/dist/web/server.js +14 -582
  77. package/dist/web/server.js.map +1 -1
  78. package/dist/web/ui.d.ts +31 -0
  79. package/dist/web/ui.d.ts.map +1 -0
  80. package/dist/web/ui.js +1009 -0
  81. package/dist/web/ui.js.map +1 -0
  82. package/docs/AESTHETIC_DESIGN.md +152 -152
  83. package/docs/OPTIMIZATION_PLAN.md +178 -178
  84. package/package.json +1 -1
  85. package/src/agents/snow.ts +38 -5
  86. package/src/cli/commands_md.ts +112 -112
  87. package/src/cli/input_macros.ts +83 -83
  88. package/src/cli/loom.ts +1041 -1041
  89. package/src/cli/loom_chat.ts +772 -603
  90. package/src/cli/main.ts +853 -723
  91. package/src/cli/tui.ts +264 -289
  92. package/src/core/agent/guard.ts +133 -133
  93. package/src/core/agent/task.ts +100 -100
  94. package/src/core/agent.ts +1630 -1590
  95. package/src/core/agent_helpers.ts +500 -500
  96. package/src/core/bus.ts +221 -221
  97. package/src/core/cache.ts +153 -153
  98. package/src/core/catalog.ts +199 -178
  99. package/src/core/circuit_breaker.ts +119 -119
  100. package/src/core/commands.ts +704 -0
  101. package/src/core/concurrency.ts +73 -0
  102. package/src/core/config.ts +365 -365
  103. package/src/core/constants.ts +95 -95
  104. package/src/core/factory.ts +656 -656
  105. package/src/core/file_checkpoint.ts +163 -136
  106. package/src/core/hooks.ts +126 -126
  107. package/src/core/llm.ts +972 -915
  108. package/src/core/logger.ts +143 -143
  109. package/src/core/mcp.ts +1001 -1001
  110. package/src/core/memory.ts +1201 -1201
  111. package/src/core/middleware.ts +350 -350
  112. package/src/core/model_config.ts +159 -159
  113. package/src/core/pipelines.ts +424 -424
  114. package/src/core/schemas.ts +319 -282
  115. package/src/core/security.ts +27 -0
  116. package/src/core/semantic.ts +211 -211
  117. package/src/core/skill.ts +384 -384
  118. package/src/core/skymd.ts +143 -143
  119. package/src/core/theme.ts +65 -65
  120. package/src/core/tool.ts +457 -457
  121. package/src/core/trace.ts +236 -0
  122. package/src/core/verify.ts +71 -71
  123. package/src/plugins/loader.ts +91 -91
  124. package/src/skills/loader.ts +75 -75
  125. package/src/tools/builtin.ts +571 -642
  126. package/src/tools/computer.ts +279 -279
  127. package/src/tools/extra.ts +662 -0
  128. package/src/tools/guards.ts +82 -0
  129. package/src/tools/model_tool.ts +93 -74
  130. package/src/tools/todo.ts +76 -76
  131. package/src/web/markdown.ts +193 -0
  132. package/src/web/server.ts +117 -693
  133. package/src/web/ui.ts +949 -0
  134. package/tests/agent.test.ts +211 -159
  135. package/tests/agent_helpers.test.ts +48 -48
  136. package/tests/catalog.test.ts +86 -86
  137. package/tests/checkpoint_commands.test.ts +124 -124
  138. package/tests/claude_compat.test.ts +110 -110
  139. package/tests/commands.test.ts +103 -0
  140. package/tests/concurrency.test.ts +102 -0
  141. package/tests/config.test.ts +41 -41
  142. package/tests/extra_tools.test.ts +212 -0
  143. package/tests/fence_plugin.test.ts +52 -52
  144. package/tests/guard.test.ts +75 -75
  145. package/tests/loom.test.ts +337 -337
  146. package/tests/memory.test.ts +170 -170
  147. package/tests/model_config.test.ts +109 -109
  148. package/tests/skymd.test.ts +146 -146
  149. package/tests/ssrf.test.ts +38 -38
  150. package/tests/structured_retry.test.ts +87 -0
  151. package/tests/task.test.ts +60 -60
  152. package/tests/todo_toolstats.test.ts +94 -94
  153. package/tests/trace.test.ts +128 -0
  154. package/tests/tui.test.ts +67 -67
  155. package/tests/web.test.ts +169 -0
  156. 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();