clawlet 0.2.1 → 0.4.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/README.md +13 -2
- package/package.json +10 -3
- package/src/agent.eval.test.ts +218 -0
- package/src/agent.ts +52 -1004
- package/src/cli.ts +3 -1
- package/src/evals/bootstrap_trigger.yaml +14 -0
- package/src/evals/connection_auth.yaml +12 -0
- package/src/evals/create_python_file.yaml +11 -0
- package/src/evals/directory_traversal.yaml +13 -0
- package/src/evals/empty_directory.yaml +12 -0
- package/src/evals/extend_agents_md.yaml +161 -0
- package/src/evals/external_data.yaml +16 -0
- package/src/evals/file_not_found.yaml +15 -0
- package/src/evals/memory_persistence.yaml +19 -0
- package/src/evals/move_and_rename.yaml +13 -0
- package/src/evals/needle_in_haystack.yaml +16 -0
- package/src/evals/persona_tone.yaml +16 -0
- package/src/evals/rag_user.yaml +17 -0
- package/src/evals/reasoning_multi_step.yaml +13 -0
- package/src/evals/refactoring_edit.yaml +14 -0
- package/src/evals/skill_sandbox_execution.yaml +19 -0
- package/src/evals/skill_system_installation.yaml +14 -0
- package/src/evals/soft_delete.yaml +17 -0
- package/src/evals/stat_check.yaml +16 -0
- package/src/evals/workflow_cleanup.yaml +17 -0
- package/src/evals/write_complex_json.yaml +15 -0
- package/src/llm.ts +35 -0
- package/src/logger.ts +39 -0
- package/src/memory.ts +95 -27
- package/src/storage.ts +147 -95
- package/src/tools.ts +1044 -0
- package/template/AGENTS.template +1 -1
package/src/tools.ts
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
import {
|
|
2
|
+
tool,
|
|
3
|
+
streamText,
|
|
4
|
+
generateText,
|
|
5
|
+
stepCountIs,
|
|
6
|
+
jsonSchema,
|
|
7
|
+
type ModelMessage,
|
|
8
|
+
type LanguageModel,
|
|
9
|
+
} from 'ai';
|
|
10
|
+
import 'dotenv/config';
|
|
11
|
+
import { AgentMemory } from './memory.js';
|
|
12
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import TurndownService from 'turndown';
|
|
14
|
+
import { logger } from './logger.js';
|
|
15
|
+
|
|
16
|
+
// Resolve the package root directory (where template/ lives), independent of cwd
|
|
17
|
+
const GENERATE_TEXT_TEMPERATURE = 0.6;
|
|
18
|
+
const GENERATE_TEXT_TOP_P = 0.95;
|
|
19
|
+
const GENERATE_TEXT_MAX_OUTPUT_TOKENS = 16384;
|
|
20
|
+
const GENERATE_TEXT_MAX_STEPS = 30;
|
|
21
|
+
|
|
22
|
+
const turndownService = new TurndownService()
|
|
23
|
+
|
|
24
|
+
// --- SETTINGS HELPERS ---
|
|
25
|
+
|
|
26
|
+
const SETTINGS_PATH = `${process.cwd()}/settings.json`;
|
|
27
|
+
|
|
28
|
+
interface ConnectionBearer {
|
|
29
|
+
idToken: string;
|
|
30
|
+
refreshToken?: string;
|
|
31
|
+
refreshUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ConnectionEntry {
|
|
35
|
+
bearer: ConnectionBearer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SettingsFile {
|
|
39
|
+
connections: Record<string, ConnectionEntry>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readSettings(): Promise<SettingsFile> {
|
|
43
|
+
try {
|
|
44
|
+
const raw = await readFile(SETTINGS_PATH, 'utf-8');
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (parsed && typeof parsed === 'object') {
|
|
47
|
+
if (!parsed.connections) parsed.connections = {};
|
|
48
|
+
return parsed as SettingsFile;
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
return { connections: {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function writeSettings(settings: SettingsFile): Promise<void> {
|
|
55
|
+
await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- PERMISSION HELPERS ---
|
|
59
|
+
|
|
60
|
+
function matchesPermissionPattern(actual: string, pattern: string): boolean {
|
|
61
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
62
|
+
return new RegExp(`^${escaped}$`).test(actual);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getTodayString(): string {
|
|
66
|
+
return new Date().toISOString().split('T')[0] ?? '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// --- SKILL SYSTEM PROMPT ---
|
|
71
|
+
async function buildSkillSystemPrompt(name: string, memory: AgentMemory, skillPermissions: Record<string, Array<Record<string, string>>>): Promise<string> {
|
|
72
|
+
// Read SOUL.md from workspace (if it exists)
|
|
73
|
+
let soulDoc = "";
|
|
74
|
+
try {
|
|
75
|
+
const doc = await memory.workspace.getItem('SOUL.md');
|
|
76
|
+
if (doc) soulDoc = String(doc);
|
|
77
|
+
} catch {}
|
|
78
|
+
|
|
79
|
+
// Read IDENTITY.md from workspace (if it exists)
|
|
80
|
+
let identityDoc = "";
|
|
81
|
+
try {
|
|
82
|
+
const doc = await memory.workspace.getItem('IDENTITY.md');
|
|
83
|
+
if (doc) identityDoc = String(doc);
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
// Read SKILL.md from skill in workspace (if it exists)
|
|
87
|
+
let skillDoc = "";
|
|
88
|
+
try {
|
|
89
|
+
const doc = await memory.workspace.getItem('skills:' + name + ':SKILL.md');
|
|
90
|
+
if (doc) skillDoc = String(doc);
|
|
91
|
+
} catch {}
|
|
92
|
+
|
|
93
|
+
// List all workspace files
|
|
94
|
+
let workspaceFiles = "No workspace files found.";
|
|
95
|
+
try {
|
|
96
|
+
const keys = await memory.workspace.getKeys();
|
|
97
|
+
if (keys.length > 0) workspaceFiles = keys.filter((key:string) => !key.startsWith('.trash/')).join('\n');
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
// Build identity section from SOUL.md and IDENTITY.md
|
|
101
|
+
let identitySection = `# IDENTITY: Clawlet
|
|
102
|
+
You are "Clawlet", an autonomous agent defined by the file \`AGENTS.md\`.`;
|
|
103
|
+
|
|
104
|
+
if (identityDoc) {
|
|
105
|
+
identitySection += `\n\n## Identity Definition (IDENTITY.md)\n${identityDoc}`;
|
|
106
|
+
}
|
|
107
|
+
if (soulDoc) {
|
|
108
|
+
identitySection += `\n\n## Soul & Behavioral Guidelines (SOUL.md)\n${soulDoc}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
const permissionSectionEntries = Object.keys(skillPermissions).map((toolName: string) => {
|
|
113
|
+
const permissionEntry = "- tool: " + toolName;
|
|
114
|
+
const rules: Array<Record<string, string>> = skillPermissions[toolName] as any;
|
|
115
|
+
|
|
116
|
+
if (rules.length > 0) {
|
|
117
|
+
return permissionEntry + '\n' + rules.map((rule: Record<string, string>) => {
|
|
118
|
+
return ' - ' + JSON.stringify(rule)
|
|
119
|
+
}).join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return permissionEntry + ' (no permissions)';
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const permissionsSection = permissionSectionEntries.join('\n');;
|
|
126
|
+
|
|
127
|
+
return `
|
|
128
|
+
${identitySection}
|
|
129
|
+
|
|
130
|
+
# PRIME DIRECTIVE
|
|
131
|
+
This is a specific skill session. You must obey these rules above all else.
|
|
132
|
+
|
|
133
|
+
# OPERATIONAL PROTOCOL (The "Every Session" Loop)
|
|
134
|
+
1. **INITIALIZE**:
|
|
135
|
+
- Read \`SKILL.md\` (provided below).
|
|
136
|
+
- **MANDATORY**: Check for today's memory file (\`memory:${getTodayString()}.md\`).
|
|
137
|
+
- IF it todays memory file exists -> Read it using \`fs.readFile\` to get context.
|
|
138
|
+
- IF todays mmemory file does NOT exist -> Create it using \`fs.writeFile\` (start fresh).
|
|
139
|
+
|
|
140
|
+
2. **AUTH CHECK**:
|
|
141
|
+
- Before external API calls, check \`connection.list\` for available connections.
|
|
142
|
+
- If the connection is missing, use \`connection.create\` to register and store credentials.
|
|
143
|
+
- Use \`connection.request\` for authenticated API calls (Bearer token is auto-injected).
|
|
144
|
+
|
|
145
|
+
3. **EXECUTION**:
|
|
146
|
+
- Use \`fs.readFile\` and \`fs.writeFile\` to log *significant* events to append today's memory file.
|
|
147
|
+
- Make sure to use valid JSON when generating tool_call xml tags.
|
|
148
|
+
- **Text > Brain**: If you learn something, write it down immediately.
|
|
149
|
+
|
|
150
|
+
# AVAILABLE PERMISSIONS (Permissions)
|
|
151
|
+
${permissionsSection}
|
|
152
|
+
|
|
153
|
+
# CORE RULES (SKILL.md)
|
|
154
|
+
${skillDoc}
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- TOOLS (built from memory) ---
|
|
159
|
+
|
|
160
|
+
export function createTools(memory: AgentMemory, model: LanguageModel) {
|
|
161
|
+
return {
|
|
162
|
+
now: tool({
|
|
163
|
+
description: 'Get current time and date',
|
|
164
|
+
inputSchema: jsonSchema<{}>({
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {},
|
|
167
|
+
additionalProperties: false,
|
|
168
|
+
}),
|
|
169
|
+
execute: async () => {
|
|
170
|
+
return new Date().toISOString();
|
|
171
|
+
}
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
'http.request': tool({
|
|
175
|
+
description: 'Execute HTTP requests. Provide method (GET/POST/PUT/DELETE), url, optional headers object, and optional unescaped body string. Returns status, statusText and data.',
|
|
176
|
+
inputSchema: jsonSchema<{method?:string,url:string,headers?:Record<string,string>,body?:string,transformer?:string}>({
|
|
177
|
+
type: 'object',
|
|
178
|
+
properties: {
|
|
179
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method' },
|
|
180
|
+
url: { type: 'string', description: 'URL to request' },
|
|
181
|
+
headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'Optional headers' },
|
|
182
|
+
body: { type: 'string', description: 'Optional unescaped body string' },
|
|
183
|
+
transformer: { type: 'string', enum: ['markdown'], description: 'Transform the result into e.g. markdown' }
|
|
184
|
+
},
|
|
185
|
+
required: ['url'],
|
|
186
|
+
}),
|
|
187
|
+
execute: async ({ method, url, headers, body, transformer }) => {
|
|
188
|
+
const executeMethod = method ? method : 'GET';
|
|
189
|
+
logger.debug({ method: executeMethod, url }, 'HTTP request');
|
|
190
|
+
try {
|
|
191
|
+
let parsedBody = body;
|
|
192
|
+
if (typeof body === 'string') {
|
|
193
|
+
try { parsedBody = JSON.parse(body); } catch {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const res = await fetch(url, {
|
|
197
|
+
method: executeMethod,
|
|
198
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
199
|
+
body: parsedBody ? JSON.stringify(parsedBody) : null
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const text = await res.text();
|
|
203
|
+
const transformedText = transformer === 'markdown' ? turndownService.turndown(text) : text;
|
|
204
|
+
return JSON.stringify({
|
|
205
|
+
status: res.status,
|
|
206
|
+
statusText: res.statusText,
|
|
207
|
+
data: transformedText.length > 5000 ? transformedText.substring(0, 5000) + "..." : transformedText
|
|
208
|
+
});
|
|
209
|
+
} catch (e: any) { return JSON.stringify({ error: e.message }); }
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
|
|
213
|
+
'http.get': tool({
|
|
214
|
+
description: 'Shortcut for GET requests. Provide url and optional headers.',
|
|
215
|
+
inputSchema: jsonSchema<{url: string, headers?: Record<string, string>, transformer?: string}>({
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
url: { type: 'string', description: 'URL to request' },
|
|
219
|
+
headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'Optional headers' },
|
|
220
|
+
transformer: { type: 'string', enum: ['markdown'], description: 'Transform the result into e.g. markdown' }
|
|
221
|
+
},
|
|
222
|
+
required: ['url'],
|
|
223
|
+
}),
|
|
224
|
+
execute: async ({ url, headers, transformer }) => {
|
|
225
|
+
logger.debug({ url }, 'HTTP GET');
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetch(url, {
|
|
228
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
229
|
+
});
|
|
230
|
+
const text = await res.text();
|
|
231
|
+
const transformedText = transformer === 'markdown' ? turndownService.turndown(text) : text;
|
|
232
|
+
return JSON.stringify({
|
|
233
|
+
status: res.status,
|
|
234
|
+
statusText: res.statusText,
|
|
235
|
+
data: transformedText.length > 5000 ? transformedText.substring(0, 5000) + "..." : transformedText
|
|
236
|
+
});
|
|
237
|
+
} catch (e: any) { return JSON.stringify({ error: e.message }); }
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
|
|
241
|
+
'http.post': tool({
|
|
242
|
+
description: 'Shortcut for POST requests. Provide url, optional unescaped body string, and optional headers.',
|
|
243
|
+
inputSchema: jsonSchema<{url: string, body?: string, headers?: Record<string, string>, transformer?: string}>({
|
|
244
|
+
type: 'object',
|
|
245
|
+
properties: {
|
|
246
|
+
url: { type: 'string', description: 'URL to request' },
|
|
247
|
+
body: { type: 'string', description: 'Optional unescaped body string' },
|
|
248
|
+
headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'Optional headers' },
|
|
249
|
+
transformer: { type: 'string', enum: ['markdown'], description: 'Transform the result into e.g. markdown' }
|
|
250
|
+
},
|
|
251
|
+
required: ['url'],
|
|
252
|
+
}),
|
|
253
|
+
execute: async ({ url, body, headers, transformer }) => {
|
|
254
|
+
logger.debug({ url }, 'HTTP POST');
|
|
255
|
+
try {
|
|
256
|
+
let parsedBody = body;
|
|
257
|
+
if (typeof body === 'string') {
|
|
258
|
+
try { parsedBody = JSON.parse(body); } catch {}
|
|
259
|
+
}
|
|
260
|
+
const res = await fetch(url, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
263
|
+
body: parsedBody ? JSON.stringify(parsedBody) : null
|
|
264
|
+
});
|
|
265
|
+
const text = await res.text();
|
|
266
|
+
const transformedText = transformer === 'markdown' ? turndownService.turndown(text) : text;
|
|
267
|
+
return JSON.stringify({
|
|
268
|
+
status: res.status,
|
|
269
|
+
statusText: res.statusText,
|
|
270
|
+
data: transformedText.length > 5000 ? transformedText.substring(0, 5000) + "..." : transformedText
|
|
271
|
+
});
|
|
272
|
+
} catch (e: any) { return JSON.stringify({ error: e.message }); }
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
|
|
276
|
+
'http.download': tool({
|
|
277
|
+
description: 'Download a file from a URL and save it to the workspace. Provide url and an optional filename (defaults to the last path segment of the URL).',
|
|
278
|
+
inputSchema: jsonSchema<{ url: string, filename?: string }>({
|
|
279
|
+
type: 'object',
|
|
280
|
+
properties: {
|
|
281
|
+
url: { type: 'string', description: 'URL to download from' },
|
|
282
|
+
filename: { type: 'string', description: 'Filename to save as in the workspace' },
|
|
283
|
+
},
|
|
284
|
+
required: ['url'],
|
|
285
|
+
}),
|
|
286
|
+
execute: async ({ url, filename }) => {
|
|
287
|
+
const name = filename || url.split('/').pop() || 'download';
|
|
288
|
+
logger.debug({ url, filename: name }, 'HTTP download');
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch(url);
|
|
291
|
+
if (!res.ok) return JSON.stringify({ error: `HTTP ${res.status} ${res.statusText}` });
|
|
292
|
+
|
|
293
|
+
const buffer = await res.arrayBuffer();
|
|
294
|
+
const content = Buffer.from(buffer);
|
|
295
|
+
|
|
296
|
+
// Store as base64 for binary files, as string for text
|
|
297
|
+
const contentType = res.headers.get('content-type') || '';
|
|
298
|
+
if (contentType.includes('text') || contentType.includes('json') || contentType.includes('xml')) {
|
|
299
|
+
await memory.workspace.setItem(name, new TextDecoder().decode(content));
|
|
300
|
+
} else {
|
|
301
|
+
await memory.workspace.setItemRaw(name, content);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return JSON.stringify({
|
|
305
|
+
status: res.status,
|
|
306
|
+
filename: name,
|
|
307
|
+
size: content.byteLength,
|
|
308
|
+
contentType,
|
|
309
|
+
});
|
|
310
|
+
} catch (e: any) { return JSON.stringify({ error: e.message }); }
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
|
|
314
|
+
'kv.set': tool({
|
|
315
|
+
description: 'Store a key-value pair (e.g. API keys, config). Provide "key" and "value".',
|
|
316
|
+
inputSchema: jsonSchema<{ key: string, value: string }>({
|
|
317
|
+
type: 'object',
|
|
318
|
+
properties: {
|
|
319
|
+
key: { type: 'string', description: 'The key to store' },
|
|
320
|
+
value: { type: 'string', description: 'The value to store' },
|
|
321
|
+
},
|
|
322
|
+
required: ['key', 'value'],
|
|
323
|
+
}),
|
|
324
|
+
execute: async ({ key, value }) => {
|
|
325
|
+
logger.debug({ key }, 'KV set');
|
|
326
|
+
try {
|
|
327
|
+
await memory.secrets.set(key, value);
|
|
328
|
+
return `Success: Saved ${key}.`;
|
|
329
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
|
|
333
|
+
'kv.get': tool({
|
|
334
|
+
description: 'Retrieve a value by key from the key-value store.',
|
|
335
|
+
inputSchema: jsonSchema<{ key: string }>({
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: {
|
|
338
|
+
key: { type: 'string', description: 'The key to retrieve' },
|
|
339
|
+
},
|
|
340
|
+
required: ['key'],
|
|
341
|
+
}),
|
|
342
|
+
execute: async ({ key }) => {
|
|
343
|
+
logger.debug({ key }, 'KV get');
|
|
344
|
+
try {
|
|
345
|
+
const result = await memory.secrets.get(key);
|
|
346
|
+
return result ?? "NOT_FOUND";
|
|
347
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
350
|
+
|
|
351
|
+
'kv.list': tool({
|
|
352
|
+
description: 'List all keys in the key-value store.',
|
|
353
|
+
inputSchema: jsonSchema<{}>({
|
|
354
|
+
type: 'object',
|
|
355
|
+
properties: {},
|
|
356
|
+
additionalProperties: false,
|
|
357
|
+
}),
|
|
358
|
+
execute: async () => {
|
|
359
|
+
logger.debug('KV list');
|
|
360
|
+
try {
|
|
361
|
+
const keys = await memory.secrets.listKeys();
|
|
362
|
+
return keys.join(', ') || "EMPTY_STORE";
|
|
363
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
|
|
367
|
+
'kv.delete': tool({
|
|
368
|
+
description: 'Delete a key from the key-value store.',
|
|
369
|
+
inputSchema: jsonSchema<{ key: string }>({
|
|
370
|
+
type: 'object',
|
|
371
|
+
properties: {
|
|
372
|
+
key: { type: 'string', description: 'The key to delete' },
|
|
373
|
+
},
|
|
374
|
+
required: ['key'],
|
|
375
|
+
}),
|
|
376
|
+
execute: async ({ key }) => {
|
|
377
|
+
logger.debug({ key }, 'KV delete');
|
|
378
|
+
try {
|
|
379
|
+
await memory.secrets.delete(key);
|
|
380
|
+
return `Success: Deleted ${key}.`;
|
|
381
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
|
|
385
|
+
'kv.has': tool({
|
|
386
|
+
description: 'Check if a key exists in the key-value store. Returns true or false.',
|
|
387
|
+
inputSchema: jsonSchema<{ key: string }>({
|
|
388
|
+
type: 'object',
|
|
389
|
+
properties: {
|
|
390
|
+
key: { type: 'string', description: 'The key to check' },
|
|
391
|
+
},
|
|
392
|
+
required: ['key'],
|
|
393
|
+
}),
|
|
394
|
+
execute: async ({ key }) => {
|
|
395
|
+
logger.debug({ key }, 'KV has');
|
|
396
|
+
try {
|
|
397
|
+
const exists = await memory.secrets.has(key);
|
|
398
|
+
return exists ? "true" : "false";
|
|
399
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
|
|
403
|
+
'fs.listDir': tool({
|
|
404
|
+
description: 'List all files in the workspace (including memory logs and skills).',
|
|
405
|
+
inputSchema: jsonSchema<{}>({
|
|
406
|
+
type: 'object',
|
|
407
|
+
properties: {},
|
|
408
|
+
additionalProperties: false,
|
|
409
|
+
}),
|
|
410
|
+
execute: async () => {
|
|
411
|
+
logger.debug('FS listDir');
|
|
412
|
+
try {
|
|
413
|
+
const keys = await memory.workspace.getKeys();
|
|
414
|
+
return keys.join('\n') || "No files found.";
|
|
415
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
416
|
+
}
|
|
417
|
+
}),
|
|
418
|
+
|
|
419
|
+
'fs.readFile': tool({
|
|
420
|
+
description: 'Read a file from the workspace. "path" must be one of the keys from fs.listDir (e.g. "memory:2026-02-08.md").',
|
|
421
|
+
inputSchema: jsonSchema<{ path: string }>({
|
|
422
|
+
type: 'object',
|
|
423
|
+
properties: {
|
|
424
|
+
path: { type: 'string', description: 'Path/key of the file to read' },
|
|
425
|
+
},
|
|
426
|
+
required: ['path'],
|
|
427
|
+
}),
|
|
428
|
+
execute: async ({ path }) => {
|
|
429
|
+
logger.debug({ path }, 'FS readFile');
|
|
430
|
+
try {
|
|
431
|
+
const content = await memory.workspace.getItem(path);
|
|
432
|
+
if (content === null || content === undefined) return "File not found. Create it first with fs.writeFile if needed.";
|
|
433
|
+
return String(content);
|
|
434
|
+
} catch (e: any) { return "Error reading file: " + e.message; }
|
|
435
|
+
}
|
|
436
|
+
}),
|
|
437
|
+
|
|
438
|
+
'fs.writeFile': tool({
|
|
439
|
+
description: 'Write or update a file in the workspace. "path" is the key/path (e.g. "memory:2026-02-08.md"), "content" is the full content.',
|
|
440
|
+
inputSchema: jsonSchema<{ path: string, content: string }>({
|
|
441
|
+
type: 'object',
|
|
442
|
+
properties: {
|
|
443
|
+
path: { type: 'string', description: 'Path/key of the file to write' },
|
|
444
|
+
content: { type: 'string', description: 'Full file content' },
|
|
445
|
+
},
|
|
446
|
+
required: ['path', 'content'],
|
|
447
|
+
}),
|
|
448
|
+
execute: async ({ path, content }) => {
|
|
449
|
+
logger.debug({ path }, 'FS writeFile');
|
|
450
|
+
try {
|
|
451
|
+
await memory.workspace.setItem(path, content);
|
|
452
|
+
return `Success: Wrote to ${path}`;
|
|
453
|
+
} catch (e: any) { return "Error writing file: " + e.message; }
|
|
454
|
+
}
|
|
455
|
+
}),
|
|
456
|
+
|
|
457
|
+
'fs.editFile': tool({
|
|
458
|
+
description: 'Smart edit: Replaces a specific string in a file with a new string. Use this for small, targeted changes instead of rewriting the whole file. The "find" text must be an exact, unique match.',
|
|
459
|
+
inputSchema: jsonSchema<{ path: string, find: string, replace: string }>({
|
|
460
|
+
type: 'object',
|
|
461
|
+
properties: {
|
|
462
|
+
path: { type: 'string', description: 'Path/key of the file to edit' },
|
|
463
|
+
find: { type: 'string', description: 'The EXACT text block to search for. Must be unique in the file.' },
|
|
464
|
+
replace: { type: 'string', description: 'The new text to replace it with.' },
|
|
465
|
+
},
|
|
466
|
+
required: ['path', 'find', 'replace'],
|
|
467
|
+
}),
|
|
468
|
+
execute: async ({ path, find, replace }) => {
|
|
469
|
+
logger.debug({ path }, 'FS editFile');
|
|
470
|
+
try {
|
|
471
|
+
const content = await memory.workspace.getItem(path);
|
|
472
|
+
if (content === null || content === undefined) return `Error: File "${path}" not found.`;
|
|
473
|
+
|
|
474
|
+
const fileText = String(content);
|
|
475
|
+
if (!fileText.includes(find)) {
|
|
476
|
+
return `Error: The text to replace was not found in "${path}". Check whitespace and indentation exactly.`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const parts = fileText.split(find);
|
|
480
|
+
if (parts.length > 2) {
|
|
481
|
+
return `Error: Ambiguous match. Found ${parts.length - 1} occurrences. Provide more surrounding context in "find" to make it unique.`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const newContent = fileText.replace(find, replace);
|
|
485
|
+
await memory.workspace.setItem(path, newContent);
|
|
486
|
+
return `Success: Edited "${path}". Replaced 1 occurrence.`;
|
|
487
|
+
} catch (e: any) { return "Error editing file: " + e.message; }
|
|
488
|
+
}
|
|
489
|
+
}),
|
|
490
|
+
|
|
491
|
+
'fs.delete': tool({
|
|
492
|
+
description: 'Delete a file. If the file is outside .trash/, it is moved to .trash/ (soft delete). If the file is already inside .trash/, it is permanently removed.',
|
|
493
|
+
inputSchema: jsonSchema<{ path: string }>({
|
|
494
|
+
type: 'object',
|
|
495
|
+
properties: {
|
|
496
|
+
path: { type: 'string', description: 'Path/key of the file to delete' },
|
|
497
|
+
},
|
|
498
|
+
required: ['path'],
|
|
499
|
+
}),
|
|
500
|
+
execute: async ({ path }) => {
|
|
501
|
+
try {
|
|
502
|
+
const content = await memory.workspace.getItem(path);
|
|
503
|
+
if (content === null || content === undefined) return "File not found.";
|
|
504
|
+
|
|
505
|
+
if (path.startsWith('.trash:') || path.startsWith('.trash/')) {
|
|
506
|
+
// Already in trash — hard delete
|
|
507
|
+
logger.debug({ path }, 'FS permanentDelete');
|
|
508
|
+
await memory.workspace.removeItem(path);
|
|
509
|
+
return `Success: Permanently deleted ${path}`;
|
|
510
|
+
} else {
|
|
511
|
+
// Move to .trash/
|
|
512
|
+
const trashPath = `.trash:${path}`;
|
|
513
|
+
logger.debug({ path, trashPath }, 'FS softDelete');
|
|
514
|
+
await memory.workspace.setItem(trashPath, content);
|
|
515
|
+
await memory.workspace.removeItem(path);
|
|
516
|
+
return `Success: Moved ${path} to ${trashPath}`;
|
|
517
|
+
}
|
|
518
|
+
} catch (e: any) { return "Error deleting file: " + e.message; }
|
|
519
|
+
}
|
|
520
|
+
}),
|
|
521
|
+
|
|
522
|
+
'fs.move': tool({
|
|
523
|
+
description: 'Move/rename a file in the workspace. Reads from "from", writes to "to", then removes the original.',
|
|
524
|
+
inputSchema: jsonSchema<{ from: string, to: string }>({
|
|
525
|
+
type: 'object',
|
|
526
|
+
properties: {
|
|
527
|
+
from: { type: 'string', description: 'Source path/key' },
|
|
528
|
+
to: { type: 'string', description: 'Destination path/key' },
|
|
529
|
+
},
|
|
530
|
+
required: ['from', 'to'],
|
|
531
|
+
}),
|
|
532
|
+
execute: async ({ from, to }) => {
|
|
533
|
+
logger.debug({ from, to }, 'FS move');
|
|
534
|
+
try {
|
|
535
|
+
const content = await memory.workspace.getItem(from);
|
|
536
|
+
if (content === null || content === undefined) return `File not found: ${from}`;
|
|
537
|
+
await memory.workspace.setItem(to, content);
|
|
538
|
+
await memory.workspace.removeItem(from);
|
|
539
|
+
return `Success: Moved ${from} to ${to}`;
|
|
540
|
+
} catch (e: any) { return "Error moving file: " + e.message; }
|
|
541
|
+
}
|
|
542
|
+
}),
|
|
543
|
+
|
|
544
|
+
'fs.exists': tool({
|
|
545
|
+
description: 'Check if a file exists in the workspace. Returns true or false.',
|
|
546
|
+
inputSchema: jsonSchema<{ path: string }>({
|
|
547
|
+
type: 'object',
|
|
548
|
+
properties: {
|
|
549
|
+
path: { type: 'string', description: 'Path/key of the file to check' },
|
|
550
|
+
},
|
|
551
|
+
required: ['path'],
|
|
552
|
+
}),
|
|
553
|
+
execute: async ({ path }) => {
|
|
554
|
+
logger.debug({ path }, 'FS exists');
|
|
555
|
+
try {
|
|
556
|
+
const exists = await memory.workspace.hasItem(path);
|
|
557
|
+
return exists ? "true" : "false";
|
|
558
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
559
|
+
}
|
|
560
|
+
}),
|
|
561
|
+
|
|
562
|
+
'fs.stat': tool({
|
|
563
|
+
description: 'Get metadata about a file in the workspace (e.g. mtime, size). Returns JSON with available metadata.',
|
|
564
|
+
inputSchema: jsonSchema<{ path: string }>({
|
|
565
|
+
type: 'object',
|
|
566
|
+
properties: {
|
|
567
|
+
path: { type: 'string', description: 'Path/key of the file' },
|
|
568
|
+
},
|
|
569
|
+
required: ['path'],
|
|
570
|
+
}),
|
|
571
|
+
execute: async ({ path }) => {
|
|
572
|
+
logger.debug({ path }, 'FS stat');
|
|
573
|
+
try {
|
|
574
|
+
const exists = await memory.workspace.hasItem(path);
|
|
575
|
+
if (!exists) return "File not found.";
|
|
576
|
+
const meta = await memory.workspace.getMeta(path);
|
|
577
|
+
return JSON.stringify(meta);
|
|
578
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
579
|
+
}
|
|
580
|
+
}),
|
|
581
|
+
|
|
582
|
+
'skill.install': tool({
|
|
583
|
+
description: 'Install a skill from a remote URL. Downloads SKILL.md, parses it for additional files to download, analyzes required tool permissions, and saves everything to workspace under skills/<name>/.',
|
|
584
|
+
inputSchema: jsonSchema<{ name: string; url: string }>({
|
|
585
|
+
type: 'object',
|
|
586
|
+
properties: {
|
|
587
|
+
name: { type: 'string', description: 'Skill name (e.g. "moltbook", "tavily"). Used as the folder name under skills/.' },
|
|
588
|
+
url: { type: 'string', description: 'URL to the remote SKILL.md file.' },
|
|
589
|
+
},
|
|
590
|
+
required: ['name', 'url'],
|
|
591
|
+
}),
|
|
592
|
+
execute: async ({ name, url }) => {
|
|
593
|
+
logger.info({ skill: name, url }, 'SKILL installing');
|
|
594
|
+
|
|
595
|
+
// Phase 0: Validate name
|
|
596
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
597
|
+
return JSON.stringify({ error: 'Invalid skill name. Use only letters, numbers, hyphens, underscores.' });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const skillBasePath = `skills:${name}`;
|
|
601
|
+
|
|
602
|
+
// Phase 1: Download SKILL.md
|
|
603
|
+
logger.debug('SKILL step 1/4: downloading SKILL.md');
|
|
604
|
+
let skillMdContent: string;
|
|
605
|
+
try {
|
|
606
|
+
const res = await fetch(url);
|
|
607
|
+
if (!res.ok) {
|
|
608
|
+
return JSON.stringify({ error: `Failed to download SKILL.md: HTTP ${res.status} ${res.statusText}` });
|
|
609
|
+
}
|
|
610
|
+
skillMdContent = await res.text();
|
|
611
|
+
} catch (e: any) {
|
|
612
|
+
return JSON.stringify({ error: `Network error downloading SKILL.md: ${e.message}` });
|
|
613
|
+
}
|
|
614
|
+
await memory.workspace.setItem(`${skillBasePath}:SKILL.md`, skillMdContent);
|
|
615
|
+
logger.debug({ bytes: skillMdContent.length }, 'SKILL saved SKILL.md');
|
|
616
|
+
|
|
617
|
+
// Phase 2: Extract additional files via LLM
|
|
618
|
+
logger.debug('SKILL step 2/4: analyzing for additional files');
|
|
619
|
+
let additionalFiles: Array<{ url: string; filename: string }> = [];
|
|
620
|
+
try {
|
|
621
|
+
const { text: installJson } = await generateText({
|
|
622
|
+
model,
|
|
623
|
+
messages: [
|
|
624
|
+
{
|
|
625
|
+
role: 'system',
|
|
626
|
+
content: `You are a skill file analyzer. Given a SKILL.md file, extract all additional files that need to be downloaded for complete installation. Look for:
|
|
627
|
+
- File tables listing URLs and filenames
|
|
628
|
+
- Install instructions with curl/download commands
|
|
629
|
+
- References to companion files (HEARTBEAT.md, MESSAGING.md, RULES.md, package.json, etc.)
|
|
630
|
+
|
|
631
|
+
Return ONLY a JSON array. Each element: {"url": "<download_url>", "filename": "<local_filename>"}.
|
|
632
|
+
Do NOT include SKILL.md itself. If no additional files, return [].`,
|
|
633
|
+
},
|
|
634
|
+
{ role: 'user', content: skillMdContent },
|
|
635
|
+
],
|
|
636
|
+
temperature: 0.1,
|
|
637
|
+
});
|
|
638
|
+
const match = installJson.match(/\[[\s\S]*\]/);
|
|
639
|
+
if (match) additionalFiles = JSON.parse(match[0]);
|
|
640
|
+
} catch (e: any) {
|
|
641
|
+
logger.warn({ err: e }, 'SKILL could not parse additional files');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Phase 3: Download additional files
|
|
645
|
+
logger.debug({ count: additionalFiles.length }, 'SKILL step 3/4: downloading additional files');
|
|
646
|
+
const downloadResults: Array<{ filename: string; status: string }> = [];
|
|
647
|
+
for (const file of additionalFiles) {
|
|
648
|
+
try {
|
|
649
|
+
const res = await fetch(file.url);
|
|
650
|
+
if (!res.ok) {
|
|
651
|
+
downloadResults.push({ filename: file.filename, status: `failed: HTTP ${res.status}` });
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const content = await res.text();
|
|
655
|
+
await memory.workspace.setItem(`${skillBasePath}:${file.filename}`, content);
|
|
656
|
+
downloadResults.push({ filename: file.filename, status: 'ok' });
|
|
657
|
+
logger.debug({ filename: file.filename }, 'SKILL downloaded file');
|
|
658
|
+
} catch (e: any) {
|
|
659
|
+
downloadResults.push({ filename: file.filename, status: `failed: ${e.message}` });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Phase 4: Analyze permissions via LLM
|
|
664
|
+
logger.debug('SKILL step 4/4: analyzing required permissions');
|
|
665
|
+
let skillPermissions: Record<string, Array<Record<string, string>>> = {};
|
|
666
|
+
try {
|
|
667
|
+
const { text: permJson } = await generateText({
|
|
668
|
+
model,
|
|
669
|
+
messages: [
|
|
670
|
+
{
|
|
671
|
+
role: 'system',
|
|
672
|
+
content: `You are a security analyzer for AI agent skills. Analyze this SKILL.md and determine what tools and permissions it needs.
|
|
673
|
+
|
|
674
|
+
Available tools: "http.request", "http.get", "http.post", "http.download", "connection.list", "connection.request", "connection.create", "kv.set", "kv.get", "kv.list", "kv.delete", "kv.has", "fs.readFile", "fs.writeFile", "fs.edit", "fs.delete", "fs.move", "fs.listDir", "fs.exists", "fs.stat"
|
|
675
|
+
|
|
676
|
+
IMPORTANT: If the skill requires API keys or authentication, use "connection.create" and "connection.request" instead of "kv.*" tools. Connections handle registration, token storage, and authenticated requests automatically.
|
|
677
|
+
|
|
678
|
+
For HTTP tools, include "url" (pattern with * wildcard) and "method".
|
|
679
|
+
For connection tools, include "url" pattern and "name" of the connection.
|
|
680
|
+
For KV tools, include "key" pattern. Only use kv.* for non-auth data (preferences, config, caches).
|
|
681
|
+
For FS tools, include "path" pattern.
|
|
682
|
+
|
|
683
|
+
Return ONLY a JSON object mapping tool names to arrays of permission rules. Example:
|
|
684
|
+
{"connection.create": [{"name": "example", "url": "https://api.example.com/register"}], "connection.request": [{"name": "example", "url": "https://api.example.com/*", "method": "*"}]}`,
|
|
685
|
+
},
|
|
686
|
+
{ role: 'user', content: skillMdContent },
|
|
687
|
+
],
|
|
688
|
+
temperature: 0.1,
|
|
689
|
+
});
|
|
690
|
+
const match = permJson.match(/\{[\s\S]*\}/);
|
|
691
|
+
if (match) skillPermissions = JSON.parse(match[0]);
|
|
692
|
+
} catch (e: any) {
|
|
693
|
+
logger.warn({ err: e }, 'SKILL could not analyze permissions');
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Phase 5: Write permissions.json at project root
|
|
697
|
+
const permissionsPath = `${process.cwd()}/permissions.json`;
|
|
698
|
+
let permissionsFile: { skills: Record<string, Record<string, Array<Record<string, string>>>> } = { skills: {} };
|
|
699
|
+
try {
|
|
700
|
+
const existing = await readFile(permissionsPath, 'utf-8');
|
|
701
|
+
const parsed = JSON.parse(existing);
|
|
702
|
+
if (parsed && typeof parsed === 'object') {
|
|
703
|
+
permissionsFile = parsed;
|
|
704
|
+
if (!permissionsFile.skills) permissionsFile.skills = {};
|
|
705
|
+
}
|
|
706
|
+
} catch {
|
|
707
|
+
// File doesn't exist yet, start fresh
|
|
708
|
+
}
|
|
709
|
+
permissionsFile.skills[name] = skillPermissions;
|
|
710
|
+
try {
|
|
711
|
+
await writeFile(permissionsPath, JSON.stringify(permissionsFile, null, 2), 'utf-8');
|
|
712
|
+
logger.info('SKILL updated permissions.json');
|
|
713
|
+
} catch (e: any) {
|
|
714
|
+
logger.warn({ err: e }, 'SKILL could not write permissions.json');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return JSON.stringify({
|
|
718
|
+
success: true,
|
|
719
|
+
skill: name,
|
|
720
|
+
files: [
|
|
721
|
+
{ filename: 'SKILL.md', status: 'ok' },
|
|
722
|
+
...downloadResults,
|
|
723
|
+
],
|
|
724
|
+
permissions: skillPermissions,
|
|
725
|
+
message: `Skill "${name}" installed with ${downloadResults.filter(r => r.status === 'ok').length + 1} files.`,
|
|
726
|
+
});
|
|
727
|
+
},
|
|
728
|
+
}),
|
|
729
|
+
|
|
730
|
+
'connection.list': tool({
|
|
731
|
+
description: 'List all available connections (configured API credentials). Returns comma-separated connection names.',
|
|
732
|
+
inputSchema: jsonSchema<{}>({
|
|
733
|
+
type: 'object',
|
|
734
|
+
properties: {},
|
|
735
|
+
additionalProperties: false,
|
|
736
|
+
}),
|
|
737
|
+
execute: async () => {
|
|
738
|
+
logger.debug('CONN list');
|
|
739
|
+
try {
|
|
740
|
+
const settings = await readSettings();
|
|
741
|
+
const names = Object.keys(settings.connections);
|
|
742
|
+
return names.length > 0 ? names.join(',') : 'NO_CONNECTIONS';
|
|
743
|
+
} catch (e: any) { return `Error: ${e.message}`; }
|
|
744
|
+
},
|
|
745
|
+
}),
|
|
746
|
+
|
|
747
|
+
'connection.request': tool({
|
|
748
|
+
description: 'Execute an authenticated HTTP request using a named connection. The connection\'s Bearer token is automatically injected into the Authorization header. If the connection does not exist, returns an error prompting you to create it first with connection.create.',
|
|
749
|
+
inputSchema: jsonSchema<{ name: string; url: string; method?: string; headers?: Record<string, string>; body?: string }>({
|
|
750
|
+
type: 'object',
|
|
751
|
+
properties: {
|
|
752
|
+
name: { type: 'string', description: 'Connection name (e.g. "petstore", "moltbook")' },
|
|
753
|
+
url: { type: 'string', description: 'URL to request' },
|
|
754
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method (default: GET)' },
|
|
755
|
+
headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'Optional additional headers' },
|
|
756
|
+
body: { type: 'string', description: 'Optional request body string' },
|
|
757
|
+
},
|
|
758
|
+
required: ['name', 'url'],
|
|
759
|
+
}),
|
|
760
|
+
execute: async ({ name, url, method, headers, body }) => {
|
|
761
|
+
logger.debug({ name, method: method || 'GET', url }, 'CONN request');
|
|
762
|
+
const settings = await readSettings();
|
|
763
|
+
const conn = settings.connections[name];
|
|
764
|
+
|
|
765
|
+
if (!conn) {
|
|
766
|
+
return JSON.stringify({
|
|
767
|
+
error: `Connection "${name}" not found. Available connections: ${Object.keys(settings.connections).join(', ') || 'none'}. Use connection.create to set up this connection first.`,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const executeMethod = method || 'GET';
|
|
772
|
+
try {
|
|
773
|
+
let parsedBody = body;
|
|
774
|
+
if (typeof body === 'string') {
|
|
775
|
+
try { parsedBody = JSON.parse(body); } catch {}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const res = await fetch(url, {
|
|
779
|
+
method: executeMethod,
|
|
780
|
+
headers: {
|
|
781
|
+
'Content-Type': 'application/json',
|
|
782
|
+
'Authorization': `Bearer ${conn.bearer.idToken}`,
|
|
783
|
+
...headers,
|
|
784
|
+
},
|
|
785
|
+
body: parsedBody ? JSON.stringify(parsedBody) : null,
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const text = await res.text();
|
|
789
|
+
logger.debug({ status: res.status }, 'CONN request response');
|
|
790
|
+
return JSON.stringify({
|
|
791
|
+
status: res.status,
|
|
792
|
+
statusText: res.statusText,
|
|
793
|
+
data: text.length > 2000 ? text.substring(0, 2000) + '...' : text,
|
|
794
|
+
});
|
|
795
|
+
} catch (e: any) { return JSON.stringify({ error: e.message }); }
|
|
796
|
+
},
|
|
797
|
+
}),
|
|
798
|
+
|
|
799
|
+
'connection.create': tool({
|
|
800
|
+
description: 'Create a new connection by calling a registration endpoint. Sends the request, extracts the API key from the response, and stores the connection in settings.json. The "type" must be "Bearer". The response should contain the API key and optionally a refresh token/URL.',
|
|
801
|
+
inputSchema: jsonSchema<{name:string, url:string, method?:string, headers?:Record<string,string>, body?:string, type:string, tokenPath?:string, refreshTokenPath?:string, refreshUrl?:string}>({
|
|
802
|
+
type: 'object',
|
|
803
|
+
properties: {
|
|
804
|
+
name: { type: 'string', description: 'Connection name (e.g. "moltbook", "petstore")' },
|
|
805
|
+
url: { type: 'string', description: 'Registration endpoint URL' },
|
|
806
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT'], description: 'HTTP method (default: POST)' },
|
|
807
|
+
headers: { type: 'object', additionalProperties: { type: 'string' }, description: 'Optional headers' },
|
|
808
|
+
body: { type: 'string', description: 'Optional request body string' },
|
|
809
|
+
type: { type: 'string', enum: ['Bearer'], description: 'Auth type. Currently only "Bearer" is supported.' },
|
|
810
|
+
tokenPath: { type: 'string', description: 'JSON path to the API key in the response (e.g. "agent.api_key"). Dot-separated. Defaults to "api_key".' },
|
|
811
|
+
refreshTokenPath: { type: 'string', description: 'Optional JSON path to the refresh token in the response (e.g. "agent.verification_code").' },
|
|
812
|
+
refreshUrl: { type: 'string', description: 'Optional URL for token refresh.' },
|
|
813
|
+
},
|
|
814
|
+
required: ['name', 'url', 'type'],
|
|
815
|
+
}),
|
|
816
|
+
execute: async ({ name, url, method, headers, body, type, tokenPath, refreshTokenPath, refreshUrl }: {
|
|
817
|
+
name: string; url: string; method?: string; headers?: Record<string, string>; body?: string;
|
|
818
|
+
type: string; tokenPath?: string; refreshTokenPath?: string; refreshUrl?: string;
|
|
819
|
+
}) => {
|
|
820
|
+
logger.debug({ name, method: method || 'POST', url }, 'CONN create');
|
|
821
|
+
|
|
822
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
823
|
+
return JSON.stringify({ error: 'Invalid connection name. Use only letters, numbers, hyphens, underscores.' });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (type !== 'Bearer') {
|
|
827
|
+
return JSON.stringify({ error: `Unsupported auth type "${type}". Only "Bearer" is supported.` });
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Execute registration request
|
|
831
|
+
const executeMethod = method || 'POST';
|
|
832
|
+
let responseData: any;
|
|
833
|
+
let responseText: string;
|
|
834
|
+
try {
|
|
835
|
+
let parsedBody = body;
|
|
836
|
+
if (typeof body === 'string') {
|
|
837
|
+
try { parsedBody = JSON.parse(body); } catch {}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const res = await fetch(url, {
|
|
841
|
+
method: executeMethod,
|
|
842
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
843
|
+
body: parsedBody ? JSON.stringify(parsedBody) : null,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
responseText = await res.text();
|
|
847
|
+
logger.debug({ status: res.status }, 'CONN create response');
|
|
848
|
+
|
|
849
|
+
if (!res.ok) {
|
|
850
|
+
return JSON.stringify({ error: `Registration failed: HTTP ${res.status} ${res.statusText}`, data: responseText.substring(0, 500) });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
responseData = JSON.parse(responseText);
|
|
854
|
+
} catch (e: any) {
|
|
855
|
+
return JSON.stringify({ error: `Registration request failed: ${e.message}` });
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Extract token from response using dot-path
|
|
859
|
+
const tPath = tokenPath || 'api_key';
|
|
860
|
+
let idToken: string | undefined;
|
|
861
|
+
try {
|
|
862
|
+
idToken = tPath.split('.').reduce((obj: any, key: string) => obj?.[key], responseData);
|
|
863
|
+
} catch {}
|
|
864
|
+
|
|
865
|
+
if (!idToken || typeof idToken !== 'string') {
|
|
866
|
+
return JSON.stringify({
|
|
867
|
+
error: `Could not extract token at path "${tPath}" from response.`,
|
|
868
|
+
response: responseText!.substring(0, 500),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Extract optional refresh token
|
|
873
|
+
let refreshToken: string | undefined;
|
|
874
|
+
if (refreshTokenPath) {
|
|
875
|
+
try {
|
|
876
|
+
refreshToken = refreshTokenPath.split('.').reduce((obj: any, key: string) => obj?.[key], responseData);
|
|
877
|
+
} catch {}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Save to settings.json
|
|
881
|
+
const settings = await readSettings();
|
|
882
|
+
settings.connections[name] = {
|
|
883
|
+
bearer: {
|
|
884
|
+
idToken,
|
|
885
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
886
|
+
...(refreshUrl ? { refreshUrl } : {}),
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
await writeSettings(settings);
|
|
890
|
+
logger.info({ name }, 'CONN saved connection to settings.json');
|
|
891
|
+
|
|
892
|
+
return JSON.stringify({
|
|
893
|
+
success: true,
|
|
894
|
+
connection: name,
|
|
895
|
+
response: responseData,
|
|
896
|
+
message: `Connection "${name}" created and saved. Bearer token stored.`,
|
|
897
|
+
});
|
|
898
|
+
},
|
|
899
|
+
}),
|
|
900
|
+
|
|
901
|
+
'skill.prompt': tool({
|
|
902
|
+
description: 'Chat with an installed skill in a sandboxed environment. The skill runs with its own message history and only the tools allowed by permissions.json (entries with "allowed": true).',
|
|
903
|
+
inputSchema: jsonSchema<{ name: string; prompt: string }>({
|
|
904
|
+
type: 'object',
|
|
905
|
+
properties: {
|
|
906
|
+
name: { type: 'string', description: 'Skill name (must be installed via skill.install).' },
|
|
907
|
+
prompt: { type: 'string', description: 'The prompt/message to send to the skill.' },
|
|
908
|
+
},
|
|
909
|
+
required: ['name', 'prompt'],
|
|
910
|
+
}),
|
|
911
|
+
execute: async ({ name, prompt }) => {
|
|
912
|
+
logger.info({ skill: name }, 'SKILL.PROMPT starting chat');
|
|
913
|
+
|
|
914
|
+
// 1. Validate name
|
|
915
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
916
|
+
return JSON.stringify({ error: 'Invalid skill name.' });
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 2. Read SKILL.md as system prompt
|
|
920
|
+
const skillMd = await memory.workspace.getItem(`skills:${name}:SKILL.md`);
|
|
921
|
+
if (!skillMd) {
|
|
922
|
+
return JSON.stringify({ error: `Skill "${name}" not found. Install it first with skill.install.` });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 3. Read permissions.json
|
|
926
|
+
const permissionsPath = `${process.cwd()}/permissions.json`;
|
|
927
|
+
let skillPermissions: Record<string, Array<Record<string, string>>> = {};
|
|
928
|
+
try {
|
|
929
|
+
const raw = await readFile(permissionsPath, 'utf-8');
|
|
930
|
+
const parsed = JSON.parse(raw);
|
|
931
|
+
skillPermissions = parsed?.skills?.[name] ?? {};
|
|
932
|
+
} catch {
|
|
933
|
+
return JSON.stringify({ error: `No permissions found for skill "${name}". Ask user to install skill with skill.install tool first.` });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
skillPermissions['fs.writeFile'] = skillPermissions['fs.writeFile'] || [
|
|
937
|
+
{
|
|
938
|
+
path: "memory/*",
|
|
939
|
+
allowed: "1"
|
|
940
|
+
}
|
|
941
|
+
];
|
|
942
|
+
|
|
943
|
+
skillPermissions['fs.readFile'] = skillPermissions['fs.readFile'] || [
|
|
944
|
+
{
|
|
945
|
+
path: "memory/*",
|
|
946
|
+
allowed: "1"
|
|
947
|
+
}
|
|
948
|
+
]
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
// 4. Build sandboxed tools (only tools with "allowed": true, HTTP tools get URL guards)
|
|
952
|
+
const allTools = createTools(memory, model);
|
|
953
|
+
const sandboxed = createSandboxedTools(allTools, skillPermissions);
|
|
954
|
+
|
|
955
|
+
if (Object.keys(sandboxed).length === 0) {
|
|
956
|
+
logger.info({ skill: name }, 'SKILL.PROMPT no tools allowed, text-only mode');
|
|
957
|
+
} else {
|
|
958
|
+
logger.info({ skill: name, tools: Object.keys(sandboxed) }, 'SKILL.PROMPT sandboxed tools');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// 5. Load per-skill message history (single table, partitioned by name)
|
|
962
|
+
const messages: ModelMessage[] = await memory.history.getAll(name);
|
|
963
|
+
|
|
964
|
+
// Add user prompt
|
|
965
|
+
messages.push({ role: 'user', content: prompt });
|
|
966
|
+
|
|
967
|
+
// 6. Run skill via streamText (agentic — multi-step tool use, up to 15 steps)
|
|
968
|
+
logger.debug({ skill: name, prompt: prompt.substring(0, 80) }, 'SKILL.PROMPT running');
|
|
969
|
+
try {
|
|
970
|
+
const result = streamText({
|
|
971
|
+
model,
|
|
972
|
+
system: await buildSkillSystemPrompt(name, memory, skillPermissions),
|
|
973
|
+
messages,
|
|
974
|
+
tools: Object.keys(sandboxed).length > 0 ? sandboxed : {},
|
|
975
|
+
temperature: GENERATE_TEXT_TEMPERATURE,
|
|
976
|
+
topP: GENERATE_TEXT_TOP_P,
|
|
977
|
+
maxOutputTokens: GENERATE_TEXT_MAX_OUTPUT_TOKENS,
|
|
978
|
+
stopWhen: stepCountIs(GENERATE_TEXT_MAX_STEPS),
|
|
979
|
+
onStepFinish: (step) => {
|
|
980
|
+
if (step.toolCalls.length > 0) {
|
|
981
|
+
const toolNames = step.toolCalls.map(t => t.toolName).join(', ');
|
|
982
|
+
logger.debug({ skill: name, tools: toolNames }, 'SKILL.PROMPT executed tools');
|
|
983
|
+
}
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// Consume the full stream and collect the response
|
|
988
|
+
let fullResponse = "";
|
|
989
|
+
for await (const delta of result.textStream) {
|
|
990
|
+
fullResponse += delta;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Persist messages to skill history after stream completes
|
|
994
|
+
const responseMessages = (await result.response).messages;
|
|
995
|
+
|
|
996
|
+
const messagesToSave: ModelMessage[] = [{ role: 'user', content: prompt }, ...responseMessages];
|
|
997
|
+
|
|
998
|
+
await memory.history.pushMany(name, messagesToSave);
|
|
999
|
+
|
|
1000
|
+
await memory.compactHistory(name, model);
|
|
1001
|
+
|
|
1002
|
+
return fullResponse || JSON.stringify({ success: true, response: '(no text response — tool actions only)' });
|
|
1003
|
+
} catch (e: any) {
|
|
1004
|
+
logger.error({ err: e }, 'SKILL.PROMPT error');
|
|
1005
|
+
return JSON.stringify({ error: `Skill chat failed: ${e.message}` });
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
}),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
function createSandboxedTools(
|
|
1014
|
+
allTools: ReturnType<typeof createTools>,
|
|
1015
|
+
permissions: Record<string, Array<Record<string, string>>>
|
|
1016
|
+
): Record<string, any> {
|
|
1017
|
+
const sandboxed: Record<string, any> = {};
|
|
1018
|
+
|
|
1019
|
+
Object.entries(permissions).forEach(([toolName, rules]) => {
|
|
1020
|
+
const hasAllowed = rules.some((r: any) => r.allowed === 'true' || r.allowed === true);
|
|
1021
|
+
if (!hasAllowed) return;
|
|
1022
|
+
|
|
1023
|
+
const originalTool = (allTools as any)[toolName];
|
|
1024
|
+
if (!originalTool) return;
|
|
1025
|
+
|
|
1026
|
+
sandboxed[toolName] = tool({
|
|
1027
|
+
description: originalTool.description,
|
|
1028
|
+
inputSchema: originalTool.inputSchema,
|
|
1029
|
+
execute: async (args: any) => {
|
|
1030
|
+
for (const key in args) {
|
|
1031
|
+
if (!rules.some(r => matchesPermissionPattern(args[key], r[key] || '*'))) {
|
|
1032
|
+
logger.warn({ key, value: args[key], args, rules }, 'Skill permission denied');
|
|
1033
|
+
|
|
1034
|
+
return JSON.stringify({ error: `Permission denied: ${key} not allowed for this skill with value ${args[key]}.` });
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
return originalTool.execute(args);
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
return sandboxed;
|
|
1044
|
+
}
|