@xpr-agents/openclaw 0.3.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.
Files changed (57) hide show
  1. package/README.md +51 -10
  2. package/openclaw.plugin.json +15 -1
  3. package/package.json +7 -4
  4. package/skills/code-sandbox/SKILL.md +30 -0
  5. package/skills/code-sandbox/dist/index.js +188 -0
  6. package/skills/code-sandbox/skill.json +13 -0
  7. package/skills/code-sandbox/src/index.ts +212 -0
  8. package/skills/creative/SKILL.md +32 -0
  9. package/skills/creative/dist/index.js +667 -0
  10. package/skills/creative/skill.json +13 -0
  11. package/skills/creative/src/index.ts +679 -0
  12. package/skills/defi/SKILL.md +123 -0
  13. package/skills/defi/dist/index.js +1745 -0
  14. package/skills/defi/skill.json +44 -0
  15. package/skills/defi/src/index.ts +1788 -0
  16. package/skills/defi/test-read.mjs +281 -0
  17. package/skills/governance/SKILL.md +69 -0
  18. package/skills/governance/dist/index.js +632 -0
  19. package/skills/governance/skill.json +21 -0
  20. package/skills/governance/src/index.ts +656 -0
  21. package/skills/governance/test-read.mjs +176 -0
  22. package/skills/lending/SKILL.md +63 -0
  23. package/skills/lending/dist/index.js +1039 -0
  24. package/skills/lending/skill.json +29 -0
  25. package/skills/lending/src/index.ts +1105 -0
  26. package/skills/lending/test-read.mjs +156 -0
  27. package/skills/nft/SKILL.md +95 -0
  28. package/skills/nft/dist/index.js +1520 -0
  29. package/skills/nft/skill.json +37 -0
  30. package/skills/nft/src/index.ts +1539 -0
  31. package/skills/shellbook/SKILL.md +59 -0
  32. package/skills/shellbook/dist/index.js +381 -0
  33. package/skills/shellbook/skill.json +29 -0
  34. package/skills/shellbook/src/index.ts +391 -0
  35. package/skills/shellbook/tsconfig.json +14 -0
  36. package/skills/smart-contracts/SKILL.md +128 -0
  37. package/skills/smart-contracts/dist/index.js +1225 -0
  38. package/skills/smart-contracts/skill.json +25 -0
  39. package/skills/smart-contracts/src/index.ts +1327 -0
  40. package/skills/smart-contracts/tsconfig.json +14 -0
  41. package/skills/structured-data/SKILL.md +36 -0
  42. package/skills/structured-data/dist/index.js +501 -0
  43. package/skills/structured-data/skill.json +13 -0
  44. package/skills/structured-data/src/index.ts +597 -0
  45. package/skills/tax/SKILL.md +109 -0
  46. package/skills/tax/dist/index.js +1749 -0
  47. package/skills/tax/skill.json +20 -0
  48. package/skills/tax/src/index.ts +1985 -0
  49. package/skills/web-scraping/SKILL.md +29 -0
  50. package/skills/web-scraping/dist/index.js +311 -0
  51. package/skills/web-scraping/skill.json +13 -0
  52. package/skills/web-scraping/src/index.ts +371 -0
  53. package/skills/xmd/SKILL.md +52 -0
  54. package/skills/xmd/dist/index.js +596 -0
  55. package/skills/xmd/skill.json +22 -0
  56. package/skills/xmd/src/index.ts +635 -0
  57. package/skills/xmd/test-read.mjs +178 -0
@@ -0,0 +1,667 @@
1
+ "use strict";
2
+ /**
3
+ * Creative Skill — AI image/video generation, PDF creation, GitHub repos, IPFS storage
4
+ *
5
+ * Built-in skill that provides deliverable tools for job completion.
6
+ * Extracted from the main agent runner to validate the skill module format.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.getDeliverable = getDeliverable;
10
+ exports.default = creativeSkill;
11
+ // ── Shared helpers ──────────────────────────────
12
+ const MAX_DELIVERABLES = 200;
13
+ const deliverables = new Map();
14
+ function setDeliverable(jobId, entry) {
15
+ deliverables.set(jobId, entry);
16
+ if (deliverables.size > MAX_DELIVERABLES) {
17
+ const oldest = deliverables.keys().next().value;
18
+ if (oldest !== undefined)
19
+ deliverables.delete(oldest);
20
+ }
21
+ }
22
+ /** Get deliverable by job ID (used by the agent runner to serve /deliverables/:jobId) */
23
+ function getDeliverable(jobId) {
24
+ return deliverables.get(jobId);
25
+ }
26
+ const PINATA_GATEWAY = process.env.PINATA_GATEWAY || '';
27
+ function ipfsUrl(cid) {
28
+ if (PINATA_GATEWAY) {
29
+ const gw = PINATA_GATEWAY.replace(/\/+$/, '');
30
+ return `${gw}/ipfs/${cid}`;
31
+ }
32
+ return `https://ipfs.io/ipfs/${cid}`;
33
+ }
34
+ async function uploadJsonToIpfs(content, jobId, contentType) {
35
+ const jwt = process.env.PINATA_JWT;
36
+ if (!jwt)
37
+ return null;
38
+ try {
39
+ const resp = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` },
42
+ body: JSON.stringify({
43
+ pinataContent: { job_id: jobId, content, content_type: contentType, created_at: new Date().toISOString() },
44
+ pinataMetadata: { name: `job-${jobId}-deliverable` },
45
+ }),
46
+ });
47
+ const data = await resp.json();
48
+ if (data.IpfsHash)
49
+ return ipfsUrl(data.IpfsHash);
50
+ }
51
+ catch (e) {
52
+ console.error('[ipfs] JSON upload failed:', e);
53
+ }
54
+ return null;
55
+ }
56
+ async function uploadBinaryToIpfs(buffer, filename, mimeType) {
57
+ const jwt = process.env.PINATA_JWT;
58
+ if (!jwt)
59
+ return null;
60
+ try {
61
+ const formData = new FormData();
62
+ formData.append('file', new Blob([new Uint8Array(buffer)], { type: mimeType }), filename);
63
+ formData.append('pinataMetadata', JSON.stringify({ name: filename }));
64
+ const resp = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
65
+ method: 'POST',
66
+ headers: { Authorization: `Bearer ${jwt}` },
67
+ body: formData,
68
+ });
69
+ const data = await resp.json();
70
+ if (data.IpfsHash)
71
+ return ipfsUrl(data.IpfsHash);
72
+ }
73
+ catch (e) {
74
+ console.error('[ipfs] Binary upload failed:', e);
75
+ }
76
+ return null;
77
+ }
78
+ const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
79
+ async function downloadFromUrl(url) {
80
+ if (!/^https?:\/\//.test(url))
81
+ return null;
82
+ try {
83
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30000), redirect: 'follow' });
84
+ if (!resp.ok)
85
+ return null;
86
+ const contentType = resp.headers.get('content-type') || 'application/octet-stream';
87
+ const contentLength = parseInt(resp.headers.get('content-length') || '0');
88
+ if (contentLength > MAX_DOWNLOAD_SIZE) {
89
+ console.warn(`[download] Too large: ${contentLength}`);
90
+ return null;
91
+ }
92
+ const arrayBuffer = await resp.arrayBuffer();
93
+ if (arrayBuffer.byteLength > MAX_DOWNLOAD_SIZE)
94
+ return null;
95
+ return { buffer: Buffer.from(arrayBuffer), mimeType: contentType.split(';')[0].trim() };
96
+ }
97
+ catch (e) {
98
+ console.error(`[download] Failed: ${url}`, e);
99
+ return null;
100
+ }
101
+ }
102
+ function stripMarkdownInline(text) {
103
+ text = text.replace(/<cite[^>]*>([\s\S]*?)<\/cite>/g, '$1');
104
+ text = text.replace(/<[^>]+>/g, '');
105
+ return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
106
+ }
107
+ function extractImages(text) {
108
+ const matches = [];
109
+ const re = /!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g;
110
+ let m;
111
+ while ((m = re.exec(text)) !== null) {
112
+ matches.push({ alt: m[1], url: m[2] });
113
+ }
114
+ return matches;
115
+ }
116
+ async function downloadImage(url) {
117
+ try {
118
+ const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
119
+ if (!resp.ok)
120
+ return null;
121
+ const ct = (resp.headers.get('content-type') || '').split(';')[0].trim();
122
+ if (!ct.startsWith('image/'))
123
+ return null;
124
+ const buf = Buffer.from(await resp.arrayBuffer());
125
+ if (buf.length < 100)
126
+ return null;
127
+ const type = ct.includes('png') ? 'png' : ct.includes('jpeg') || ct.includes('jpg') ? 'jpeg' : '';
128
+ if (!type)
129
+ return null;
130
+ return { buffer: buf, type };
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ async function generatePdfFromMarkdown(content) {
137
+ const PDFDocument = require('pdfkit');
138
+ const allImages = extractImages(content);
139
+ const imageCache = new Map();
140
+ if (allImages.length > 0) {
141
+ await Promise.allSettled(allImages.slice(0, 10).map(async (img) => {
142
+ const data = await downloadImage(img.url);
143
+ if (data)
144
+ imageCache.set(img.url, data);
145
+ }));
146
+ }
147
+ return new Promise((resolve, reject) => {
148
+ try {
149
+ const doc = new PDFDocument({ size: 'A4', margin: 50 });
150
+ const chunks = [];
151
+ doc.on('data', (chunk) => chunks.push(chunk));
152
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
153
+ doc.on('error', reject);
154
+ const pageWidth = 595.28 - 100;
155
+ const lines = content.split('\n');
156
+ let inCodeBlock = false;
157
+ // ── Table rendering helper ──
158
+ function renderTable(tableLines) {
159
+ // Parse rows, skip separator rows (|---|---|)
160
+ const rows = [];
161
+ let headerIdx = -1;
162
+ for (let i = 0; i < tableLines.length; i++) {
163
+ const raw = tableLines[i].trim();
164
+ // Separator row
165
+ if (/^\|[\s:]*-{2,}[\s:|-]*\|?\s*$/.test(raw)) {
166
+ headerIdx = i - 1;
167
+ continue;
168
+ }
169
+ const cells = raw.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => stripMarkdownInline(c.trim()));
170
+ rows.push(cells);
171
+ }
172
+ if (rows.length === 0)
173
+ return;
174
+ const numCols = Math.max(...rows.map(r => r.length));
175
+ const fontSize = numCols > 5 ? 8 : 9;
176
+ const cellPadding = 4;
177
+ const rowHeight = fontSize + cellPadding * 2 + 2;
178
+ // Measure column widths — proportional based on max content width
179
+ doc.fontSize(fontSize).font('Helvetica');
180
+ const colMaxWidths = new Array(numCols).fill(0);
181
+ for (const row of rows) {
182
+ for (let c = 0; c < numCols; c++) {
183
+ const text = row[c] || '';
184
+ const w = doc.widthOfString(text);
185
+ if (w > colMaxWidths[c])
186
+ colMaxWidths[c] = w;
187
+ }
188
+ }
189
+ // Scale columns to fit page width
190
+ const totalMaxWidth = colMaxWidths.reduce((s, w) => s + w, 0);
191
+ const availableWidth = pageWidth - (numCols * cellPadding * 2);
192
+ const colWidths = colMaxWidths.map(w => {
193
+ const scaled = totalMaxWidth > 0 ? (w / totalMaxWidth) * availableWidth : availableWidth / numCols;
194
+ return Math.max(scaled, 30); // min 30pt per column
195
+ });
196
+ const tableWidth = colWidths.reduce((s, w) => s + cellPadding * 2 + w, 0);
197
+ doc.moveDown(0.3);
198
+ const startX = 50;
199
+ for (let r = 0; r < rows.length; r++) {
200
+ const isHeader = (headerIdx >= 0 && r === 0);
201
+ // Page break check
202
+ if (doc.y + rowHeight > 780) {
203
+ doc.addPage();
204
+ }
205
+ const y = doc.y;
206
+ let x = startX;
207
+ // Background for header
208
+ if (isHeader) {
209
+ doc.rect(x, y, tableWidth, rowHeight).fillColor('#f0f0f0').fill().fillColor('#000000');
210
+ }
211
+ // Draw cells
212
+ for (let c = 0; c < numCols; c++) {
213
+ const cellWidth = colWidths[c] + cellPadding * 2;
214
+ const text = rows[r][c] || '';
215
+ // Cell border
216
+ doc.rect(x, y, cellWidth, rowHeight).strokeColor('#cccccc').lineWidth(0.5).stroke();
217
+ // Cell text
218
+ doc.fontSize(fontSize).font(isHeader ? 'Helvetica-Bold' : 'Helvetica').fillColor('#000000');
219
+ doc.text(text, x + cellPadding, y + cellPadding, {
220
+ width: colWidths[c],
221
+ height: rowHeight - cellPadding,
222
+ ellipsis: true,
223
+ lineBreak: false,
224
+ });
225
+ x += cellWidth;
226
+ }
227
+ doc.y = y + rowHeight;
228
+ }
229
+ doc.moveDown(0.3);
230
+ }
231
+ // ── Process lines ──
232
+ let tableBuffer = [];
233
+ for (let li = 0; li <= lines.length; li++) {
234
+ const line = li < lines.length ? lines[li] : '';
235
+ const isTableLine = li < lines.length && /^\s*\|/.test(line);
236
+ // Flush buffered table when we hit a non-table line
237
+ if (!isTableLine && tableBuffer.length > 0) {
238
+ renderTable(tableBuffer);
239
+ tableBuffer = [];
240
+ }
241
+ if (li >= lines.length)
242
+ break;
243
+ if (isTableLine) {
244
+ tableBuffer.push(line);
245
+ continue;
246
+ }
247
+ if (line.startsWith('```')) {
248
+ inCodeBlock = !inCodeBlock;
249
+ doc.moveDown(0.3);
250
+ continue;
251
+ }
252
+ if (inCodeBlock) {
253
+ doc.fontSize(9).font('Courier').text(line);
254
+ continue;
255
+ }
256
+ const imgMatch = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)\s*$/);
257
+ if (imgMatch) {
258
+ const imgData = imageCache.get(imgMatch[2]);
259
+ if (imgData) {
260
+ try {
261
+ doc.moveDown(0.3);
262
+ doc.image(imgData.buffer, { fit: [pageWidth, 300], align: 'center' });
263
+ doc.moveDown(0.3);
264
+ if (imgMatch[1]) {
265
+ doc.fontSize(9).font('Helvetica').fillColor('#666666')
266
+ .text(imgMatch[1], { align: 'center' }).fillColor('#000000');
267
+ }
268
+ doc.moveDown(0.3);
269
+ }
270
+ catch {
271
+ doc.fontSize(9).font('Helvetica').fillColor('#666666')
272
+ .text(`[Image: ${imgMatch[1] || imgMatch[2]}]`, { align: 'center' }).fillColor('#000000');
273
+ }
274
+ }
275
+ else {
276
+ doc.fontSize(9).font('Helvetica').fillColor('#666666')
277
+ .text(`[Image: ${imgMatch[1] || imgMatch[2]}]`, { align: 'center' }).fillColor('#000000');
278
+ }
279
+ continue;
280
+ }
281
+ if (/^---+$/.test(line.trim())) {
282
+ doc.moveDown(0.3);
283
+ const y = doc.y;
284
+ doc.moveTo(50, y).lineTo(545, y).strokeColor('#cccccc').lineWidth(0.5).stroke();
285
+ doc.moveDown(0.5);
286
+ continue;
287
+ }
288
+ // Blockquote
289
+ if (line.startsWith('> ')) {
290
+ doc.moveDown(0.2);
291
+ doc.fontSize(10).font('Helvetica-Oblique').fillColor('#555555')
292
+ .text(stripMarkdownInline(line.slice(2)), { indent: 15 })
293
+ .fillColor('#000000');
294
+ doc.moveDown(0.2);
295
+ continue;
296
+ }
297
+ if (line.startsWith('# ')) {
298
+ doc.moveDown(0.5).fontSize(22).font('Helvetica-Bold').text(stripMarkdownInline(line.slice(2))).moveDown(0.3);
299
+ }
300
+ else if (line.startsWith('## ')) {
301
+ doc.moveDown(0.4).fontSize(17).font('Helvetica-Bold').text(stripMarkdownInline(line.slice(3))).moveDown(0.2);
302
+ }
303
+ else if (line.startsWith('### ')) {
304
+ doc.moveDown(0.3).fontSize(14).font('Helvetica-Bold').text(stripMarkdownInline(line.slice(4))).moveDown(0.2);
305
+ }
306
+ else if (/^[-*] /.test(line)) {
307
+ doc.fontSize(11).font('Helvetica').text(` \u2022 ${stripMarkdownInline(line.slice(2))}`, { indent: 10 });
308
+ }
309
+ else if (/^\d+\.\s/.test(line)) {
310
+ const m = line.match(/^(\d+\.)\s(.*)/);
311
+ if (m)
312
+ doc.fontSize(11).font('Helvetica').text(` ${m[1]} ${stripMarkdownInline(m[2])}`, { indent: 10 });
313
+ }
314
+ else if (line.trim() === '') {
315
+ doc.moveDown(0.4);
316
+ }
317
+ else {
318
+ doc.fontSize(11).font('Helvetica').text(stripMarkdownInline(line));
319
+ }
320
+ }
321
+ doc.end();
322
+ }
323
+ catch (err) {
324
+ reject(err);
325
+ }
326
+ });
327
+ }
328
+ async function createGithubRepo(jobId, repoName, description, files) {
329
+ const token = process.env.GITHUB_TOKEN;
330
+ const owner = process.env.GITHUB_OWNER;
331
+ if (!token || !owner)
332
+ return null;
333
+ const headers = {
334
+ Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
335
+ 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28',
336
+ };
337
+ try {
338
+ const createResp = await fetch('https://api.github.com/user/repos', {
339
+ method: 'POST', headers,
340
+ body: JSON.stringify({ name: repoName, description, private: false, auto_init: true }),
341
+ });
342
+ if (!createResp.ok) {
343
+ console.error('[github] Create repo failed:', await createResp.text());
344
+ return null;
345
+ }
346
+ const repo = await createResp.json();
347
+ const refResp = await fetch(`https://api.github.com/repos/${repo.full_name}/git/ref/heads/${repo.default_branch}`, { headers });
348
+ const refData = await refResp.json();
349
+ const commitResp = await fetch(`https://api.github.com/repos/${repo.full_name}/git/commits/${refData.object.sha}`, { headers });
350
+ const commitData = await commitResp.json();
351
+ const treeItems = [];
352
+ for (const [filePath, fileContent] of Object.entries(files)) {
353
+ const blobResp = await fetch(`https://api.github.com/repos/${repo.full_name}/git/blobs`, {
354
+ method: 'POST', headers, body: JSON.stringify({ content: fileContent, encoding: 'utf-8' }),
355
+ });
356
+ const blobData = await blobResp.json();
357
+ treeItems.push({ path: filePath, mode: '100644', type: 'blob', sha: blobData.sha });
358
+ }
359
+ const treeResp = await fetch(`https://api.github.com/repos/${repo.full_name}/git/trees`, {
360
+ method: 'POST', headers, body: JSON.stringify({ base_tree: commitData.tree.sha, tree: treeItems }),
361
+ });
362
+ const treeData = await treeResp.json();
363
+ const newCommitResp = await fetch(`https://api.github.com/repos/${repo.full_name}/git/commits`, {
364
+ method: 'POST', headers,
365
+ body: JSON.stringify({ message: `Job #${jobId} deliverable`, tree: treeData.sha, parents: [refData.object.sha] }),
366
+ });
367
+ const newCommitData = await newCommitResp.json();
368
+ await fetch(`https://api.github.com/repos/${repo.full_name}/git/refs/heads/${repo.default_branch}`, {
369
+ method: 'PATCH', headers, body: JSON.stringify({ sha: newCommitData.sha }),
370
+ });
371
+ console.log(`[github] Created repo: ${repo.html_url}`);
372
+ return repo.html_url;
373
+ }
374
+ catch (e) {
375
+ console.error('[github] Failed:', e);
376
+ return null;
377
+ }
378
+ }
379
+ function toDataUri(content, contentType) {
380
+ const json = JSON.stringify({ content, content_type: contentType, created_at: new Date().toISOString() });
381
+ return `data:application/json;base64,${Buffer.from(json).toString('base64')}`;
382
+ }
383
+ // ── Skill entry point ───────────────────────────
384
+ function creativeSkill(api) {
385
+ // ── store_deliverable ──
386
+ api.registerTool({
387
+ name: 'store_deliverable',
388
+ description: [
389
+ 'Store job deliverable content before delivering on-chain. Call this BEFORE xpr_deliver_job.',
390
+ 'Routes by content_type:',
391
+ ' text/markdown (default) — stores as JSON on IPFS',
392
+ ' application/pdf — generates PDF from your Markdown, uploads binary to IPFS.',
393
+ ' Images referenced as ![alt](url) in the Markdown are downloaded and embedded in the PDF.',
394
+ ' Do NOT include <cite> or other HTML tags in the content — use clean Markdown only.',
395
+ ' image/*, audio/*, video/* — downloads source_url and uploads binary to IPFS',
396
+ ' text/csv, text/plain, text/html — stores as JSON on IPFS',
397
+ ].join('\n'),
398
+ parameters: {
399
+ type: 'object',
400
+ required: ['job_id', 'content'],
401
+ properties: {
402
+ job_id: { type: 'number', description: 'Job ID' },
403
+ content: { type: 'string', description: 'Full deliverable content (markdown, text, CSV, etc.). For media types, can be empty if source_url is provided.' },
404
+ content_type: { type: 'string', description: 'MIME type: text/markdown (default), application/pdf, image/png, audio/mpeg, video/mp4, text/csv, etc.' },
405
+ source_url: { type: 'string', description: 'URL to download binary content from (for image/audio/video). The file is downloaded and uploaded to IPFS.' },
406
+ filename: { type: 'string', description: 'Optional filename for the deliverable (e.g. "report.pdf")' },
407
+ },
408
+ },
409
+ handler: async ({ job_id, content, content_type, source_url, filename }) => {
410
+ const ct = content_type || 'text/markdown';
411
+ const ts = new Date().toISOString();
412
+ if (!content && !source_url) {
413
+ return { stored: false, error: 'Missing required "content" parameter. Provide the Markdown text for the deliverable.' };
414
+ }
415
+ if (ct === 'application/pdf') {
416
+ if (!content) {
417
+ return { stored: false, error: 'PDF generation requires "content" parameter with Markdown text.' };
418
+ }
419
+ try {
420
+ const pdfBuffer = await generatePdfFromMarkdown(content);
421
+ setDeliverable(job_id, { content, content_type: ct, created_at: ts });
422
+ const url = await uploadBinaryToIpfs(pdfBuffer, filename || `job-${job_id}.pdf`, 'application/pdf');
423
+ if (url) {
424
+ console.log(`[deliverable] Job ${job_id} PDF → IPFS: ${url}`);
425
+ return { stored: true, url, storage: 'ipfs', content_type: ct };
426
+ }
427
+ const dataUri = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`;
428
+ console.log(`[deliverable] Job ${job_id} PDF → data URI`);
429
+ return { stored: true, url: dataUri, storage: 'data_uri', content_type: ct };
430
+ }
431
+ catch (err) {
432
+ console.error(`[deliverable] PDF generation failed:`, err.message);
433
+ return { stored: false, error: `PDF generation failed: ${err.message}` };
434
+ }
435
+ }
436
+ if (ct.startsWith('image/') || ct.startsWith('audio/') || ct.startsWith('video/') || ct === 'application/octet-stream') {
437
+ let buffer = null;
438
+ let mimeType = ct;
439
+ if (source_url) {
440
+ const downloaded = await downloadFromUrl(source_url);
441
+ if (downloaded) {
442
+ buffer = downloaded.buffer;
443
+ mimeType = downloaded.mimeType || ct;
444
+ }
445
+ }
446
+ else if (content) {
447
+ buffer = Buffer.from(content, 'base64');
448
+ }
449
+ if (!buffer)
450
+ return { stored: false, error: 'Failed to obtain binary content. Provide source_url for media types.' };
451
+ setDeliverable(job_id, { content: source_url || '[binary]', content_type: mimeType, created_at: ts });
452
+ const ext = mimeType.split('/')[1]?.split('+')[0] || 'bin';
453
+ const url = await uploadBinaryToIpfs(buffer, filename || `job-${job_id}.${ext}`, mimeType);
454
+ if (url) {
455
+ console.log(`[deliverable] Job ${job_id} ${mimeType} → IPFS: ${url}`);
456
+ return { stored: true, url, storage: 'ipfs', content_type: mimeType };
457
+ }
458
+ const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`;
459
+ return { stored: true, url: dataUri, storage: 'data_uri', content_type: mimeType };
460
+ }
461
+ setDeliverable(job_id, { content, content_type: ct, created_at: ts });
462
+ const url = await uploadJsonToIpfs(content, job_id, ct);
463
+ if (url) {
464
+ console.log(`[deliverable] Job ${job_id} ${ct} → IPFS: ${url}`);
465
+ return { stored: true, url, storage: 'ipfs', content_type: ct };
466
+ }
467
+ const dataUri = toDataUri(content, ct);
468
+ console.log(`[deliverable] Job ${job_id} ${ct} → data URI (${dataUri.length} chars)`);
469
+ return { stored: true, url: dataUri, storage: 'data_uri', content_type: ct };
470
+ },
471
+ });
472
+ // ── create_github_repo ──
473
+ api.registerTool({
474
+ name: 'create_github_repo',
475
+ description: 'Create a GitHub repository with code deliverables for a job. Requires GITHUB_TOKEN and GITHUB_OWNER env vars. Returns the repo URL to use as evidence_uri when calling xpr_deliver_job.',
476
+ parameters: {
477
+ type: 'object',
478
+ required: ['job_id', 'name', 'files'],
479
+ properties: {
480
+ job_id: { type: 'number', description: 'Job ID' },
481
+ name: { type: 'string', description: 'Repository name (e.g. "job-59-credit-union-report")' },
482
+ description: { type: 'string', description: 'Repository description' },
483
+ files: { type: 'object', description: 'Object mapping file paths to content, e.g. {"src/index.ts": "...", "README.md": "..."}' },
484
+ },
485
+ },
486
+ handler: async ({ job_id, name, description, files }) => {
487
+ if (!process.env.GITHUB_TOKEN || !process.env.GITHUB_OWNER) {
488
+ return { error: 'GitHub not configured. Set GITHUB_TOKEN and GITHUB_OWNER in .env' };
489
+ }
490
+ const repoUrl = await createGithubRepo(job_id, name, description || `Deliverable for job #${job_id}`, files);
491
+ if (!repoUrl)
492
+ return { error: 'Failed to create GitHub repository' };
493
+ setDeliverable(job_id, {
494
+ content: `GitHub repository: ${repoUrl}\n\nFiles: ${Object.keys(files).join(', ')}`,
495
+ content_type: 'github:repo', media_url: repoUrl, created_at: new Date().toISOString(),
496
+ });
497
+ return { stored: true, url: repoUrl, storage: 'github', content_type: 'github:repo' };
498
+ },
499
+ });
500
+ // ── generate_image ──
501
+ api.registerTool({
502
+ name: 'generate_image',
503
+ description: [
504
+ 'Generate an AI image using Google Nano Banana and optionally store it as a job deliverable in one step.',
505
+ 'If job_id is provided: generates image, uploads to IPFS, and returns the evidence_uri ready for xpr_deliver_job.',
506
+ 'If no job_id: just returns the image URL.',
507
+ 'Requires REPLICATE_API_TOKEN in .env.',
508
+ ].join(' '),
509
+ parameters: {
510
+ type: 'object',
511
+ required: ['prompt'],
512
+ properties: {
513
+ prompt: { type: 'string', description: 'Detailed description of the image to generate. Be specific about style, composition, colors, lighting.' },
514
+ job_id: { type: 'number', description: 'If provided, auto-stores the generated image as an IPFS deliverable for this job. Use the returned evidence_uri directly with xpr_deliver_job.' },
515
+ aspect_ratio: { type: 'string', description: 'Aspect ratio: "1:1" (default), "16:9", "9:16", "4:3", "3:4"' },
516
+ },
517
+ },
518
+ handler: async ({ prompt, job_id, aspect_ratio }) => {
519
+ const token = process.env.REPLICATE_API_TOKEN;
520
+ if (!token)
521
+ return { error: 'REPLICATE_API_TOKEN not set. Add it to .env to enable AI image generation.' };
522
+ try {
523
+ const createResp = await fetch('https://api.replicate.com/v1/models/google/imagen-3/predictions', {
524
+ method: 'POST',
525
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Prefer: 'wait' },
526
+ body: JSON.stringify({ input: { prompt, aspect_ratio: aspect_ratio || '1:1', output_format: 'png' } }),
527
+ });
528
+ if (!createResp.ok) {
529
+ const errText = await createResp.text();
530
+ return { error: `Replicate API error: ${createResp.status} ${errText}` };
531
+ }
532
+ let result = await createResp.json();
533
+ if (result.status !== 'succeeded' && result.status !== 'failed') {
534
+ const deadline = Date.now() + 60000;
535
+ while (result.status !== 'succeeded' && result.status !== 'failed' && Date.now() < deadline) {
536
+ await new Promise(r => setTimeout(r, 1000));
537
+ const pollResp = await fetch(result.urls?.get || `https://api.replicate.com/v1/predictions/${result.id}`, {
538
+ headers: { Authorization: `Bearer ${token}` },
539
+ });
540
+ result = await pollResp.json();
541
+ }
542
+ }
543
+ if (result.status === 'failed')
544
+ return { error: `Image generation failed: ${result.error || 'Unknown error'}` };
545
+ if (result.status !== 'succeeded')
546
+ return { error: 'Image generation timed out (60s). Try a simpler prompt.' };
547
+ const outputs = Array.isArray(result.output) ? result.output : [result.output];
548
+ console.log(`[replicate] Image generated: ${outputs[0]}`);
549
+ if (job_id != null) {
550
+ const imageUrl = outputs[0];
551
+ const downloaded = await downloadFromUrl(imageUrl);
552
+ if (downloaded) {
553
+ const url = await uploadBinaryToIpfs(downloaded.buffer, `job-${job_id}.png`, 'image/png');
554
+ if (url) {
555
+ setDeliverable(job_id, { content: imageUrl, content_type: 'image/png', media_url: url, created_at: new Date().toISOString() });
556
+ console.log(`[replicate] Job ${job_id} image → IPFS: ${url}`);
557
+ return {
558
+ success: true, evidence_uri: url, image_url: imageUrl, stored: true,
559
+ instruction: 'Image generated and stored on IPFS. Now call xpr_deliver_job with evidence_uri to complete delivery.',
560
+ };
561
+ }
562
+ }
563
+ setDeliverable(job_id, { content: outputs[0], content_type: 'image/png', media_url: outputs[0], created_at: new Date().toISOString() });
564
+ return {
565
+ success: true, evidence_uri: outputs[0], image_url: outputs[0], stored: true,
566
+ instruction: 'Image generated (IPFS upload failed, using direct URL). Call xpr_deliver_job with evidence_uri.',
567
+ };
568
+ }
569
+ return {
570
+ success: true, urls: outputs, primary_url: outputs[0], prompt, model: 'google-imagen-3',
571
+ instruction: 'Call store_deliverable with content_type "image/png" and source_url set to primary_url, then xpr_deliver_job.',
572
+ };
573
+ }
574
+ catch (e) {
575
+ return { error: `Image generation failed: ${e.message}` };
576
+ }
577
+ },
578
+ });
579
+ // ── generate_video ──
580
+ api.registerTool({
581
+ name: 'generate_video',
582
+ description: [
583
+ 'Generate an AI video and optionally store it as a job deliverable in one step.',
584
+ 'If job_id is provided: generates video, uploads to IPFS, and returns the evidence_uri ready for xpr_deliver_job.',
585
+ 'For text-to-video: provide just a prompt. For image-to-video: also provide image_url.',
586
+ 'Requires REPLICATE_API_TOKEN in .env.',
587
+ ].join(' '),
588
+ parameters: {
589
+ type: 'object',
590
+ required: ['prompt'],
591
+ properties: {
592
+ prompt: { type: 'string', description: 'Description of the video to generate. Be specific about motion, scene, and style.' },
593
+ job_id: { type: 'number', description: 'If provided, auto-stores the generated video as an IPFS deliverable for this job.' },
594
+ image_url: { type: 'string', description: 'Optional: URL of a source image to animate (image-to-video mode).' },
595
+ },
596
+ },
597
+ handler: async ({ prompt, job_id, image_url }) => {
598
+ const token = process.env.REPLICATE_API_TOKEN;
599
+ if (!token)
600
+ return { error: 'REPLICATE_API_TOKEN not set. Add it to .env to enable AI video generation.' };
601
+ try {
602
+ const model = image_url
603
+ ? 'stability-ai/stable-video-diffusion:3f0457e4619daac51203dedb472816fd4af51f3149fa7a9e0b5ffcf1b8172438'
604
+ : 'minimax/video-01-live';
605
+ const input = { prompt };
606
+ if (image_url) {
607
+ input.input_image = image_url;
608
+ delete input.prompt;
609
+ }
610
+ const url = model.includes(':')
611
+ ? 'https://api.replicate.com/v1/predictions'
612
+ : `https://api.replicate.com/v1/models/${model}/predictions`;
613
+ const body = { input };
614
+ if (model.includes(':'))
615
+ body.version = model.split(':')[1];
616
+ const createResp = await fetch(url, {
617
+ method: 'POST',
618
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
619
+ body: JSON.stringify(body),
620
+ });
621
+ if (!createResp.ok) {
622
+ const errText = await createResp.text();
623
+ return { error: `Replicate API error: ${createResp.status} ${errText}` };
624
+ }
625
+ let result = await createResp.json();
626
+ console.log(`[replicate] Video prediction created: ${result.id} (model: ${model.split(':')[0]})`);
627
+ const deadline = Date.now() + 300000;
628
+ while (result.status !== 'succeeded' && result.status !== 'failed' && Date.now() < deadline) {
629
+ await new Promise(r => setTimeout(r, 3000));
630
+ const pollResp = await fetch(result.urls?.get || `https://api.replicate.com/v1/predictions/${result.id}`, {
631
+ headers: { Authorization: `Bearer ${token}` },
632
+ });
633
+ result = await pollResp.json();
634
+ }
635
+ if (result.status === 'failed')
636
+ return { error: `Video generation failed: ${result.error || 'Unknown error'}` };
637
+ if (result.status !== 'succeeded')
638
+ return { error: 'Video generation timed out (5min). Try a simpler prompt.' };
639
+ const output = Array.isArray(result.output) ? result.output[0] : result.output;
640
+ console.log(`[replicate] Video generated: ${output}`);
641
+ if (job_id != null && output) {
642
+ const downloaded = await downloadFromUrl(output);
643
+ if (downloaded) {
644
+ const ipfsResult = await uploadBinaryToIpfs(downloaded.buffer, `job-${job_id}.mp4`, 'video/mp4');
645
+ if (ipfsResult) {
646
+ setDeliverable(job_id, { content: output, content_type: 'video/mp4', media_url: ipfsResult, created_at: new Date().toISOString() });
647
+ console.log(`[replicate] Job ${job_id} video → IPFS: ${ipfsResult}`);
648
+ return {
649
+ success: true, evidence_uri: ipfsResult, video_url: output, stored: true,
650
+ instruction: 'Video generated and stored on IPFS. Now call xpr_deliver_job with evidence_uri to complete delivery.',
651
+ };
652
+ }
653
+ }
654
+ setDeliverable(job_id, { content: output, content_type: 'video/mp4', media_url: output, created_at: new Date().toISOString() });
655
+ return { success: true, evidence_uri: output, video_url: output, stored: true, instruction: 'Call xpr_deliver_job with evidence_uri.' };
656
+ }
657
+ return {
658
+ success: true, url: output, prompt, model: model.split(':')[0],
659
+ instruction: 'Call store_deliverable with content_type "video/mp4" and source_url set to the url, then xpr_deliver_job.',
660
+ };
661
+ }
662
+ catch (e) {
663
+ return { error: `Video generation failed: ${e.message}` };
664
+ }
665
+ },
666
+ });
667
+ }