@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.
- package/.prettierrc +9 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +28 -0
- package/package.json +53 -0
- package/src/engine/agent.ts +478 -0
- package/src/engine/llm-provider.test.ts +275 -0
- package/src/engine/llm-provider.ts +330 -0
- package/src/engine/stream-parser.ts +170 -0
- package/src/index.ts +142 -0
- package/src/mounts/mount-manager.test.ts +516 -0
- package/src/mounts/mount-manager.ts +327 -0
- package/src/mounts/mount-registry.ts +196 -0
- package/src/mounts/zod-to-string.test.ts +154 -0
- package/src/mounts/zod-to-string.ts +213 -0
- package/src/presets/agent-tools.ts +57 -0
- package/src/presets/index.ts +5 -0
- package/src/sandbox/README.md +1321 -0
- package/src/sandbox/bridges/README.md +571 -0
- package/src/sandbox/bridges/actor.test.ts +229 -0
- package/src/sandbox/bridges/actor.ts +195 -0
- package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
- package/src/sandbox/bridges/bucket.test.ts +300 -0
- package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
- package/src/sandbox/bridges/console-multiple.test.ts +187 -0
- package/src/sandbox/bridges/console.test.ts +157 -0
- package/src/sandbox/bridges/console.ts +122 -0
- package/src/sandbox/bridges/fetch.ts +93 -0
- package/src/sandbox/bridges/index.ts +78 -0
- package/src/sandbox/bridges/readable-stream.ts +323 -0
- package/src/sandbox/bridges/response.test.ts +154 -0
- package/src/sandbox/bridges/response.ts +123 -0
- package/src/sandbox/bridges/review-fixes.test.ts +331 -0
- package/src/sandbox/bridges/search.test.ts +475 -0
- package/src/sandbox/bridges/search.ts +264 -0
- package/src/sandbox/bridges/shared/body-methods.ts +93 -0
- package/src/sandbox/bridges/shared/cleanup.ts +112 -0
- package/src/sandbox/bridges/shared/convert.ts +76 -0
- package/src/sandbox/bridges/shared/headers.ts +181 -0
- package/src/sandbox/bridges/shared/index.ts +36 -0
- package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
- package/src/sandbox/bridges/shared/path-parser.ts +109 -0
- package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
- package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
- package/src/sandbox/bridges/shared/response-object.ts +280 -0
- package/src/sandbox/bridges/shared/result-builder.ts +130 -0
- package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
- package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
- package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
- package/src/sandbox/bridges/storage.ts +421 -0
- package/src/sandbox/bridges/text-decoder.ts +190 -0
- package/src/sandbox/bridges/text-encoder.ts +102 -0
- package/src/sandbox/bridges/types.ts +39 -0
- package/src/sandbox/bridges/utils.ts +123 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/quickjs-wasm.d.ts +9 -0
- package/src/sandbox/sandbox.test.ts +191 -0
- package/src/sandbox/sandbox.ts +831 -0
- package/src/sandbox/test-helper.ts +43 -0
- package/src/sandbox/test-mocks.ts +154 -0
- package/src/sandbox/user-stream.test.ts +77 -0
- package/src/skills/frontmatter.test.ts +305 -0
- package/src/skills/frontmatter.ts +200 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/skills-loader.test.ts +237 -0
- package/src/skills/skills-loader.ts +200 -0
- package/src/tools/actor-storage-tools.ts +250 -0
- package/src/tools/code-tools.test.ts +199 -0
- package/src/tools/code-tools.ts +444 -0
- package/src/tools/file-tools.ts +206 -0
- package/src/tools/registry.ts +125 -0
- package/src/tools/script-tools.ts +145 -0
- package/src/tools/smartbucket-tools.ts +203 -0
- package/src/tools/sql-tools.ts +213 -0
- package/src/tools/tool-factory.ts +119 -0
- package/src/types.ts +512 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +15 -0
- 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('<html>');
|
|
233
|
+
expect(prompt).toContain('&');
|
|
234
|
+
expect(prompt).toContain('"quoted"');
|
|
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, '&')
|
|
197
|
+
.replace(/</g, '<')
|
|
198
|
+
.replace(/>/g, '>')
|
|
199
|
+
.replace(/"/g, '"');
|
|
200
|
+
}
|