@liquidmetal-ai/precip 1.0.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 (78) hide show
  1. package/.prettierrc +9 -0
  2. package/CHANGELOG.md +8 -0
  3. package/eslint.config.mjs +28 -0
  4. package/package.json +53 -0
  5. package/src/engine/agent.ts +478 -0
  6. package/src/engine/llm-provider.test.ts +275 -0
  7. package/src/engine/llm-provider.ts +330 -0
  8. package/src/engine/stream-parser.ts +170 -0
  9. package/src/index.ts +142 -0
  10. package/src/mounts/mount-manager.test.ts +516 -0
  11. package/src/mounts/mount-manager.ts +327 -0
  12. package/src/mounts/mount-registry.ts +196 -0
  13. package/src/mounts/zod-to-string.test.ts +154 -0
  14. package/src/mounts/zod-to-string.ts +213 -0
  15. package/src/presets/agent-tools.ts +57 -0
  16. package/src/presets/index.ts +5 -0
  17. package/src/sandbox/README.md +1321 -0
  18. package/src/sandbox/bridges/README.md +571 -0
  19. package/src/sandbox/bridges/actor.test.ts +229 -0
  20. package/src/sandbox/bridges/actor.ts +195 -0
  21. package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
  22. package/src/sandbox/bridges/bucket.test.ts +300 -0
  23. package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
  24. package/src/sandbox/bridges/console-multiple.test.ts +187 -0
  25. package/src/sandbox/bridges/console.test.ts +157 -0
  26. package/src/sandbox/bridges/console.ts +122 -0
  27. package/src/sandbox/bridges/fetch.ts +93 -0
  28. package/src/sandbox/bridges/index.ts +78 -0
  29. package/src/sandbox/bridges/readable-stream.ts +323 -0
  30. package/src/sandbox/bridges/response.test.ts +154 -0
  31. package/src/sandbox/bridges/response.ts +123 -0
  32. package/src/sandbox/bridges/review-fixes.test.ts +331 -0
  33. package/src/sandbox/bridges/search.test.ts +475 -0
  34. package/src/sandbox/bridges/search.ts +264 -0
  35. package/src/sandbox/bridges/shared/body-methods.ts +93 -0
  36. package/src/sandbox/bridges/shared/cleanup.ts +112 -0
  37. package/src/sandbox/bridges/shared/convert.ts +76 -0
  38. package/src/sandbox/bridges/shared/headers.ts +181 -0
  39. package/src/sandbox/bridges/shared/index.ts +36 -0
  40. package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
  41. package/src/sandbox/bridges/shared/path-parser.ts +109 -0
  42. package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
  43. package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
  44. package/src/sandbox/bridges/shared/response-object.ts +280 -0
  45. package/src/sandbox/bridges/shared/result-builder.ts +130 -0
  46. package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
  47. package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
  48. package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
  49. package/src/sandbox/bridges/storage.ts +421 -0
  50. package/src/sandbox/bridges/text-decoder.ts +190 -0
  51. package/src/sandbox/bridges/text-encoder.ts +102 -0
  52. package/src/sandbox/bridges/types.ts +39 -0
  53. package/src/sandbox/bridges/utils.ts +123 -0
  54. package/src/sandbox/index.ts +6 -0
  55. package/src/sandbox/quickjs-wasm.d.ts +9 -0
  56. package/src/sandbox/sandbox.test.ts +191 -0
  57. package/src/sandbox/sandbox.ts +831 -0
  58. package/src/sandbox/test-helper.ts +43 -0
  59. package/src/sandbox/test-mocks.ts +154 -0
  60. package/src/sandbox/user-stream.test.ts +77 -0
  61. package/src/skills/frontmatter.test.ts +305 -0
  62. package/src/skills/frontmatter.ts +200 -0
  63. package/src/skills/index.ts +9 -0
  64. package/src/skills/skills-loader.test.ts +237 -0
  65. package/src/skills/skills-loader.ts +200 -0
  66. package/src/tools/actor-storage-tools.ts +250 -0
  67. package/src/tools/code-tools.test.ts +199 -0
  68. package/src/tools/code-tools.ts +444 -0
  69. package/src/tools/file-tools.ts +206 -0
  70. package/src/tools/registry.ts +125 -0
  71. package/src/tools/script-tools.ts +145 -0
  72. package/src/tools/smartbucket-tools.ts +203 -0
  73. package/src/tools/sql-tools.ts +213 -0
  74. package/src/tools/tool-factory.ts +119 -0
  75. package/src/types.ts +512 -0
  76. package/tsconfig.eslint.json +5 -0
  77. package/tsconfig.json +15 -0
  78. package/vitest.config.ts +33 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * YAML frontmatter parser for SKILL.md files
3
+ *
4
+ * Parses the YAML frontmatter block delimited by --- lines at the top of a markdown file.
5
+ * Handles the fields defined by the Agent Skills specification:
6
+ * - name (required)
7
+ * - description (required)
8
+ * - license (optional)
9
+ * - compatibility (optional)
10
+ * - metadata (optional)
11
+ * - allowed-tools (optional)
12
+ *
13
+ * Uses a simple line-based parser — no external YAML dependency.
14
+ * Supports flat key-value pairs, one level of nested maps (metadata),
15
+ * and YAML block scalars (folded `>` and literal `|`).
16
+ */
17
+
18
+ /**
19
+ * Parsed frontmatter from a SKILL.md file
20
+ */
21
+ export interface SkillFrontmatter {
22
+ name: string;
23
+ description: string;
24
+ license?: string;
25
+ compatibility?: string;
26
+ metadata?: Record<string, string>;
27
+ allowedTools?: string;
28
+ }
29
+
30
+ /**
31
+ * Collect indented continuation lines starting at `startIndex` and join them
32
+ * according to the block scalar style.
33
+ *
34
+ * @returns A tuple of [joinedValue, nextIndex] where nextIndex is the index of
35
+ * the first line that is NOT part of the block.
36
+ */
37
+ function collectBlockScalar(
38
+ yamlLines: string[],
39
+ startIndex: number,
40
+ style: '>' | '|'
41
+ ): [string, number] {
42
+ const contentLines: string[] = [];
43
+ let i = startIndex;
44
+
45
+ while (i < yamlLines.length) {
46
+ const line = yamlLines[i]!;
47
+
48
+ // A non-empty, non-indented line ends the block
49
+ if (line.trim() !== '' && !line.startsWith(' ') && !line.startsWith('\t')) {
50
+ break;
51
+ }
52
+
53
+ // Preserve empty lines as empty strings; strip indentation from content lines
54
+ if (line.trim() === '') {
55
+ contentLines.push('');
56
+ } else {
57
+ contentLines.push(line.replace(/^[ \t]+/, ''));
58
+ }
59
+ i++;
60
+ }
61
+
62
+ // Trim trailing empty lines
63
+ while (contentLines.length > 0 && contentLines[contentLines.length - 1] === '') {
64
+ contentLines.pop();
65
+ }
66
+
67
+ let value: string;
68
+ if (style === '|') {
69
+ // Literal: preserve newlines exactly
70
+ value = contentLines.join('\n');
71
+ } else {
72
+ // Folded: join non-empty runs with spaces, empty lines become newlines
73
+ const parts: string[] = [];
74
+ let currentRun: string[] = [];
75
+
76
+ for (const cl of contentLines) {
77
+ if (cl === '') {
78
+ if (currentRun.length > 0) {
79
+ parts.push(currentRun.join(' '));
80
+ currentRun = [];
81
+ }
82
+ parts.push('');
83
+ } else {
84
+ currentRun.push(cl);
85
+ }
86
+ }
87
+ if (currentRun.length > 0) {
88
+ parts.push(currentRun.join(' '));
89
+ }
90
+
91
+ value = parts.join('\n');
92
+ }
93
+
94
+ return [value, i];
95
+ }
96
+
97
+ /**
98
+ * Parse YAML frontmatter from a SKILL.md file
99
+ *
100
+ * @param content The full text content of the SKILL.md file
101
+ * @returns Parsed frontmatter, or null if no valid frontmatter found
102
+ */
103
+ export function parseFrontmatter(content: string): SkillFrontmatter | null {
104
+ const lines = content.split('\n');
105
+
106
+ // Must start with ---
107
+ if (lines[0]?.trim() !== '---') {
108
+ return null;
109
+ }
110
+
111
+ // Find closing ---
112
+ let endIndex = -1;
113
+ for (let i = 1; i < lines.length; i++) {
114
+ if (lines[i]!.trim() === '---') {
115
+ endIndex = i;
116
+ break;
117
+ }
118
+ }
119
+
120
+ if (endIndex === -1) {
121
+ return null;
122
+ }
123
+
124
+ const yamlLines = lines.slice(1, endIndex);
125
+ const fields: Record<string, string> = {};
126
+ let metadata: Record<string, string> | undefined;
127
+ let inMetadata = false;
128
+ let i = 0;
129
+
130
+ while (i < yamlLines.length) {
131
+ const line = yamlLines[i]!;
132
+
133
+ // Skip empty lines and comments
134
+ if (line.trim() === '' || line.trim().startsWith('#')) {
135
+ i++;
136
+ continue;
137
+ }
138
+
139
+ // Check for nested map entry (indented key: value under metadata)
140
+ if (inMetadata && (line.startsWith(' ') || line.startsWith('\t'))) {
141
+ const match = line.match(/^\s+(\S+)\s*:\s*"?([^"]*)"?\s*$/);
142
+ if (match) {
143
+ if (!metadata) metadata = {};
144
+ metadata[match[1]!] = match[2]!.trim();
145
+ }
146
+ i++;
147
+ continue;
148
+ }
149
+
150
+ // Top-level key: value
151
+ inMetadata = false;
152
+ const colonIndex = line.indexOf(':');
153
+ if (colonIndex === -1) {
154
+ i++;
155
+ continue;
156
+ }
157
+
158
+ const key = line.slice(0, colonIndex).trim();
159
+ const value = line.slice(colonIndex + 1).trim();
160
+
161
+ if (key === 'metadata') {
162
+ inMetadata = true;
163
+ i++;
164
+ continue;
165
+ }
166
+
167
+ // Check for block scalar indicators
168
+ if (value === '>' || value === '|') {
169
+ const [blockValue, nextIndex] = collectBlockScalar(
170
+ yamlLines,
171
+ i + 1,
172
+ value as '>' | '|'
173
+ );
174
+ fields[key] = blockValue;
175
+ i = nextIndex;
176
+ continue;
177
+ }
178
+
179
+ // Strip surrounding quotes if present
180
+ fields[key] = value.replace(/^["']|["']$/g, '');
181
+ i++;
182
+ }
183
+
184
+ // Validate required fields
185
+ if (!fields.name || !fields.description) {
186
+ return null;
187
+ }
188
+
189
+ const result: SkillFrontmatter = {
190
+ name: fields.name,
191
+ description: fields.description
192
+ };
193
+
194
+ if (fields.license) result.license = fields.license;
195
+ if (fields.compatibility) result.compatibility = fields.compatibility;
196
+ if (fields['allowed-tools']) result.allowedTools = fields['allowed-tools'];
197
+ if (metadata) result.metadata = metadata;
198
+
199
+ return result;
200
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Skills module - Agent Skills discovery and prompt generation
3
+ */
4
+
5
+ export { parseFrontmatter } from './frontmatter.js';
6
+ export type { SkillFrontmatter } from './frontmatter.js';
7
+
8
+ export { discoverSkills, generateSkillsPrompt } from './skills-loader.js';
9
+ export type { SkillMetadata, SkillsConfig } from './skills-loader.js';
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Tests for skills loader and prompt generation
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { discoverSkills, generateSkillsPrompt, type SkillMetadata } from './skills-loader.js';
7
+ import { MountManager } from '../mounts/mount-manager.js';
8
+
9
+ // Mock bucket that serves skill files
10
+ function createMockSkillsBucket(files: Record<string, string>) {
11
+ return {
12
+ get: vi.fn(async (key: string) => {
13
+ const content = files[key];
14
+ if (!content) return null;
15
+ return {
16
+ text: async () => content,
17
+ body: null,
18
+ size: content.length,
19
+ uploaded: new Date(),
20
+ httpMetadata: {}
21
+ };
22
+ }),
23
+ put: vi.fn(),
24
+ list: vi.fn(async (opts?: { prefix?: string; delimiter?: string }) => {
25
+ const prefix = opts?.prefix || '';
26
+ const delimiter = opts?.delimiter;
27
+
28
+ if (delimiter) {
29
+ // Return subdirectories as delimitedPrefixes
30
+ const prefixes = new Set<string>();
31
+ for (const key of Object.keys(files)) {
32
+ if (key.startsWith(prefix)) {
33
+ const rest = key.slice(prefix.length);
34
+ const slashIndex = rest.indexOf('/');
35
+ if (slashIndex >= 0) {
36
+ prefixes.add(prefix + rest.slice(0, slashIndex + 1));
37
+ }
38
+ }
39
+ }
40
+ return {
41
+ objects: [],
42
+ delimitedPrefixes: Array.from(prefixes),
43
+ truncated: false
44
+ };
45
+ }
46
+
47
+ const objects = Object.keys(files)
48
+ .filter(key => key.startsWith(prefix))
49
+ .map(key => ({ key, size: files[key].length, uploaded: new Date() }));
50
+ return { objects, delimitedPrefixes: [], truncated: false };
51
+ }),
52
+ delete: vi.fn(),
53
+ head: vi.fn()
54
+ } as any;
55
+ }
56
+
57
+ describe('discoverSkills', () => {
58
+ it('should discover skills from a bucket mount', async () => {
59
+ const bucket = createMockSkillsBucket({
60
+ 'skills/data-cleanup/SKILL.md': `---
61
+ name: data-cleanup
62
+ description: Normalize and deduplicate data across tables.
63
+ ---
64
+
65
+ # Data Cleanup
66
+
67
+ Instructions here.`,
68
+ 'skills/report-builder/SKILL.md': `---
69
+ name: report-builder
70
+ description: Generate formatted reports from SQL query results.
71
+ ---
72
+
73
+ # Report Builder
74
+
75
+ Instructions here.`
76
+ });
77
+
78
+ const manager = new MountManager({
79
+ knowledge: { type: 'bucket', resource: bucket }
80
+ });
81
+
82
+ const skills = await discoverSkills(manager, { path: '/knowledge/skills/' });
83
+
84
+ expect(skills).toHaveLength(2);
85
+ expect(skills[0].name).toBe('data-cleanup');
86
+ expect(skills[0].description).toBe('Normalize and deduplicate data across tables.');
87
+ expect(skills[0].location).toBe('/knowledge/skills/data-cleanup/SKILL.md');
88
+ expect(skills[1].name).toBe('report-builder');
89
+ });
90
+
91
+ it('should return empty array for non-existent mount', async () => {
92
+ const manager = new MountManager({});
93
+ const skills = await discoverSkills(manager, { path: '/missing/skills/' });
94
+ expect(skills).toHaveLength(0);
95
+ });
96
+
97
+ it('should skip skills with invalid frontmatter', async () => {
98
+ const bucket = createMockSkillsBucket({
99
+ 'skills/good-skill/SKILL.md': `---
100
+ name: good-skill
101
+ description: A valid skill.
102
+ ---
103
+
104
+ Content.`,
105
+ 'skills/bad-skill/SKILL.md': `# No frontmatter here
106
+
107
+ Just markdown.`
108
+ });
109
+
110
+ const manager = new MountManager({
111
+ kb: { type: 'bucket', resource: bucket }
112
+ });
113
+
114
+ const skills = await discoverSkills(manager, { path: '/kb/skills/' });
115
+ expect(skills).toHaveLength(1);
116
+ expect(skills[0].name).toBe('good-skill');
117
+ });
118
+
119
+ it('should warn when directory name does not match skill name', async () => {
120
+ const bucket = createMockSkillsBucket({
121
+ 'skills/wrong-dir/SKILL.md': `---
122
+ name: correct-name
123
+ description: Skill with mismatched directory.
124
+ ---`
125
+ });
126
+
127
+ const manager = new MountManager({
128
+ kb: { type: 'bucket', resource: bucket }
129
+ });
130
+
131
+ const logger = {
132
+ info: vi.fn(),
133
+ warn: vi.fn(),
134
+ error: vi.fn(),
135
+ debug: vi.fn()
136
+ };
137
+
138
+ const skills = await discoverSkills(manager, { path: '/kb/skills/' }, logger);
139
+ expect(skills).toHaveLength(1);
140
+ expect(skills[0].name).toBe('correct-name');
141
+ expect(logger.warn).toHaveBeenCalledWith(
142
+ expect.stringContaining('does not match skill name')
143
+ );
144
+ });
145
+
146
+ it('should reject non-bucket mount types', async () => {
147
+ const mockKv = {
148
+ get: vi.fn(),
149
+ put: vi.fn(),
150
+ list: vi.fn(),
151
+ delete: vi.fn(),
152
+ getWithMetadata: vi.fn(),
153
+ clear: vi.fn()
154
+ } as any;
155
+
156
+ const manager = new MountManager({
157
+ cache: { type: 'kv', resource: mockKv }
158
+ });
159
+
160
+ const logger = {
161
+ info: vi.fn(),
162
+ warn: vi.fn(),
163
+ error: vi.fn(),
164
+ debug: vi.fn()
165
+ };
166
+
167
+ const skills = await discoverSkills(manager, { path: '/cache/skills/' }, logger);
168
+ expect(skills).toHaveLength(0);
169
+ expect(logger.warn).toHaveBeenCalledWith(
170
+ expect.stringContaining('must be a bucket or smartbucket')
171
+ );
172
+ });
173
+ });
174
+
175
+ describe('generateSkillsPrompt', () => {
176
+ it('should generate valid XML for skills', () => {
177
+ const skills: SkillMetadata[] = [
178
+ {
179
+ name: 'data-cleanup',
180
+ description: 'Normalize and deduplicate data across tables.',
181
+ location: '/knowledge/skills/data-cleanup/SKILL.md',
182
+ frontmatter: {
183
+ name: 'data-cleanup',
184
+ description: 'Normalize and deduplicate data across tables.'
185
+ }
186
+ },
187
+ {
188
+ name: 'report-builder',
189
+ description: 'Generate formatted reports from SQL query results.',
190
+ location: '/knowledge/skills/report-builder/SKILL.md',
191
+ frontmatter: {
192
+ name: 'report-builder',
193
+ description: 'Generate formatted reports from SQL query results.'
194
+ }
195
+ }
196
+ ];
197
+
198
+ const prompt = generateSkillsPrompt(skills);
199
+
200
+ expect(prompt).toContain('<available_skills>');
201
+ expect(prompt).toContain('</available_skills>');
202
+ expect(prompt).toContain('<skill>');
203
+ expect(prompt).toContain('<name>data-cleanup</name>');
204
+ expect(prompt).toContain(
205
+ '<description>Normalize and deduplicate data across tables.</description>'
206
+ );
207
+ expect(prompt).toContain(
208
+ '<location>/knowledge/skills/data-cleanup/SKILL.md</location>'
209
+ );
210
+ expect(prompt).toContain('<name>report-builder</name>');
211
+ });
212
+
213
+ it('should return empty string for no skills', () => {
214
+ expect(generateSkillsPrompt([])).toBe('');
215
+ });
216
+
217
+ it('should escape XML special characters', () => {
218
+ const skills: SkillMetadata[] = [
219
+ {
220
+ name: 'test-skill',
221
+ description: 'Handles <html> & "quoted" values',
222
+ location: '/kb/skills/test-skill/SKILL.md',
223
+ frontmatter: {
224
+ name: 'test-skill',
225
+ description: 'Handles <html> & "quoted" values'
226
+ }
227
+ }
228
+ ];
229
+
230
+ const prompt = generateSkillsPrompt(skills);
231
+
232
+ expect(prompt).toContain('&lt;html&gt;');
233
+ expect(prompt).toContain('&amp;');
234
+ expect(prompt).toContain('&quot;quoted&quot;');
235
+ expect(prompt).not.toContain('<html>');
236
+ });
237
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Skills Loader - Discovers and loads Agent Skills from mounted storage
3
+ *
4
+ * Implements the Agent Skills specification for progressive disclosure:
5
+ * 1. At startup, scans a configured path for skill directories containing SKILL.md
6
+ * 2. Parses YAML frontmatter (name + description) from each SKILL.md
7
+ * 3. Generates <available_skills> XML for injection into the system prompt
8
+ *
9
+ * The agent reads the full SKILL.md on demand (using existing read tools)
10
+ * and executes scripts via the run_script tool.
11
+ *
12
+ * @see https://agentskills.io/specification
13
+ */
14
+
15
+ import type { Bucket } from '@liquidmetal-ai/raindrop-framework';
16
+ import type { Logger } from '../types.js';
17
+ import { MountManager } from '../mounts/mount-manager.js';
18
+ import { parseFrontmatter, type SkillFrontmatter } from './frontmatter.js';
19
+
20
+ /**
21
+ * Metadata for a discovered skill
22
+ */
23
+ export interface SkillMetadata {
24
+ /** Skill name from frontmatter */
25
+ name: string;
26
+ /** Skill description from frontmatter */
27
+ description: string;
28
+ /** Full mount path to the SKILL.md file */
29
+ location: string;
30
+ /** Parsed frontmatter (includes optional fields) */
31
+ frontmatter: SkillFrontmatter;
32
+ }
33
+
34
+ /**
35
+ * Configuration for the skills loader
36
+ */
37
+ export interface SkillsConfig {
38
+ /**
39
+ * Mount path where skills are stored.
40
+ * Skills are expected as subdirectories each containing a SKILL.md.
41
+ *
42
+ * Example: "/knowledge/skills/" scans for:
43
+ * /knowledge/skills/data-cleanup/SKILL.md
44
+ * /knowledge/skills/report-builder/SKILL.md
45
+ */
46
+ path: string;
47
+ }
48
+
49
+ /**
50
+ * Discover skills from a mounted bucket path.
51
+ *
52
+ * Scans the given path for subdirectories containing SKILL.md files,
53
+ * parses their frontmatter, and returns metadata for each valid skill.
54
+ */
55
+ export async function discoverSkills(
56
+ mountManager: MountManager,
57
+ config: SkillsConfig,
58
+ logger?: Logger
59
+ ): Promise<SkillMetadata[]> {
60
+ const skills: SkillMetadata[] = [];
61
+
62
+ // Parse the skills path to find the mount and prefix
63
+ const parsed = mountManager.parsePath(config.path);
64
+ const mount = mountManager.getMount(parsed.mountName);
65
+
66
+ if (!mount) {
67
+ logger?.warn?.(`Skills mount not found: ${parsed.mountName}`);
68
+ return skills;
69
+ }
70
+
71
+ // Only bucket and smartbucket mounts support listing + reading files
72
+ if (mount.type !== 'bucket' && mount.type !== 'smartbucket') {
73
+ logger?.warn?.(`Skills mount must be a bucket or smartbucket, got: ${mount.type}`);
74
+ return skills;
75
+ }
76
+
77
+ const bucket = mount.resource as Bucket;
78
+ const prefix = parsed.path;
79
+
80
+ try {
81
+ // List contents at the skills path to find subdirectories.
82
+ // We look for SKILL.md files using a delimiter to find skill directories.
83
+ const listed = await bucket.list({
84
+ prefix: prefix ? (prefix.endsWith('/') ? prefix : prefix + '/') : undefined,
85
+ delimiter: '/'
86
+ });
87
+
88
+ // delimitedPrefixes gives us subdirectories (e.g., "skills/data-cleanup/")
89
+ const skillDirs = listed.delimitedPrefixes || [];
90
+
91
+ // Also check for SKILL.md files directly in the listing
92
+ // (in case the bucket implementation returns them as objects)
93
+ const skillMdFiles = listed.objects
94
+ .filter(obj => obj.key.endsWith('/SKILL.md') || obj.key === 'SKILL.md')
95
+ .map(obj => {
96
+ // Extract the directory path
97
+ const dir = obj.key.slice(0, obj.key.lastIndexOf('/') + 1);
98
+ return dir;
99
+ });
100
+
101
+ const allDirs = [...new Set([...skillDirs, ...skillMdFiles])];
102
+
103
+ for (const dir of allDirs) {
104
+ const skillMdPath = `${dir}SKILL.md`;
105
+
106
+ try {
107
+ const obj = await bucket.get(skillMdPath);
108
+ if (!obj) continue;
109
+
110
+ const content = await obj.text();
111
+ const frontmatter = parseFrontmatter(content);
112
+
113
+ if (!frontmatter) {
114
+ logger?.warn?.(`Invalid or missing frontmatter in ${skillMdPath}`);
115
+ continue;
116
+ }
117
+
118
+ // Validate that the directory name matches the skill name (per spec)
119
+ const dirName = dir.replace(/\/$/, '').split('/').pop();
120
+ if (dirName && dirName !== frontmatter.name) {
121
+ logger?.warn?.(
122
+ `Skill directory name "${dirName}" does not match skill name "${frontmatter.name}" in ${skillMdPath}`
123
+ );
124
+ // Continue anyway — this is a warning, not a hard error
125
+ }
126
+
127
+ const location = `/${parsed.mountName}/${skillMdPath}`.replace(/\/+/g, '/');
128
+
129
+ skills.push({
130
+ name: frontmatter.name,
131
+ description: frontmatter.description,
132
+ location,
133
+ frontmatter
134
+ });
135
+
136
+ logger?.debug?.(`Discovered skill: ${frontmatter.name} at ${location}`);
137
+ } catch (e) {
138
+ logger?.warn?.(`Failed to read skill at ${skillMdPath}: ${e}`);
139
+ }
140
+ }
141
+
142
+ // Sort by name for consistent ordering
143
+ skills.sort((a, b) => a.name.localeCompare(b.name));
144
+
145
+ logger?.info?.(`Discovered ${skills.length} skill(s) from ${config.path}`);
146
+ } catch (e) {
147
+ logger?.error?.(`Failed to scan skills path ${config.path}: ${e}`);
148
+ }
149
+
150
+ return skills;
151
+ }
152
+
153
+ /**
154
+ * Generate the <available_skills> XML block for the system prompt.
155
+ *
156
+ * Follows the Agent Skills specification format:
157
+ * ```xml
158
+ * <available_skills>
159
+ * <skill>
160
+ * <name>...</name>
161
+ * <description>...</description>
162
+ * <location>...</location>
163
+ * </skill>
164
+ * </available_skills>
165
+ * ```
166
+ *
167
+ * @see https://agentskills.io/integrate-skills
168
+ */
169
+ export function generateSkillsPrompt(skills: SkillMetadata[]): string {
170
+ if (skills.length === 0) {
171
+ return '';
172
+ }
173
+
174
+ const lines: string[] = [];
175
+ lines.push('');
176
+ lines.push('<available_skills>');
177
+
178
+ for (const skill of skills) {
179
+ lines.push(' <skill>');
180
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
181
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
182
+ lines.push(` <location>${escapeXml(skill.location)}</location>`);
183
+ lines.push(' </skill>');
184
+ }
185
+
186
+ lines.push('</available_skills>');
187
+
188
+ return lines.join('\n');
189
+ }
190
+
191
+ /**
192
+ * Escape special XML characters
193
+ */
194
+ function escapeXml(str: string): string {
195
+ return str
196
+ .replace(/&/g, '&amp;')
197
+ .replace(/</g, '&lt;')
198
+ .replace(/>/g, '&gt;')
199
+ .replace(/"/g, '&quot;');
200
+ }