@xpr-agents/openclaw 0.6.0 → 0.7.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.
@@ -0,0 +1,770 @@
1
+ /**
2
+ * Block Art Skill — fulfils "Block Art" service purchases.
3
+ *
4
+ * A Block Art buyer gets a 2048px render of the XPR Network atom emblem whose
5
+ * every visual trait is derived, deterministically and checkably, from the
6
+ * block their payment confirmed in. The derivation below is a published promise
7
+ * to buyers: the trait tables, their order, and the byte offsets used to index
8
+ * them must not change, or an existing block silently starts mapping to a
9
+ * different piece.
10
+ *
11
+ * Two tools, deliberately split:
12
+ * blockart_plan — read chain, recover the seed block, derive traits, build
13
+ * the prompt, persist the plan. No spend, no signing.
14
+ * blockart_render — render, pin to IPFS, hand back a delivery manifest.
15
+ * Never signs and never delivers: the agent delivers by
16
+ * passing the manifest to the audited xpr_deliver_job tool.
17
+ *
18
+ * Ported from the reference Python implementation (fulfil.py) that has already
19
+ * fulfilled real orders. Behaviour is kept byte-for-byte where it is visible to
20
+ * a buyer.
21
+ */
22
+
23
+ import { createHash } from 'crypto';
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+
27
+ // ── Types ────────────────────────────────────────
28
+
29
+ interface ToolDef {
30
+ name: string;
31
+ description: string;
32
+ parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
33
+ handler: (params: any) => Promise<unknown>;
34
+ }
35
+
36
+ interface SkillApi {
37
+ registerTool(tool: ToolDef): void;
38
+ getConfig(): Record<string, unknown>;
39
+ }
40
+
41
+ export interface Traits {
42
+ material: string;
43
+ setting: string;
44
+ light: string;
45
+ palette: string;
46
+ motion: string;
47
+ scale: string;
48
+ time_of_day: string;
49
+ patina: string;
50
+ }
51
+
52
+ export interface BuyerIdentity {
53
+ account: string;
54
+ display_name: string | null;
55
+ verified: boolean;
56
+ }
57
+
58
+ export interface BlockArtPlan {
59
+ job_id: number;
60
+ service: string;
61
+ client: string;
62
+ buyer: BuyerIdentity;
63
+ seed_block: number;
64
+ block_id: string;
65
+ block_time: string;
66
+ producer: string;
67
+ buyer_steer: string;
68
+ traits: Traits;
69
+ account_age_days: number;
70
+ prompt: string;
71
+ how_to_verify: string;
72
+ }
73
+
74
+ // ── Constants ────────────────────────────────────
75
+
76
+ const ESCROW = 'agentescrow';
77
+ const GLYPH = 'https://xpragents.com/xpr-glyph-black.png';
78
+ const REPLICATE_MODEL = 'google/nano-banana-2';
79
+ const DEFAULT_RPC = 'https://proton.eosusa.io';
80
+ const DEFAULT_HYPERION = 'https://proton.eosusa.io';
81
+ const DEFAULT_GATEWAY = 'https://agent.mypinata.cloud';
82
+ const DEFAULT_WORK_DIR = './blockart-work';
83
+
84
+ const HTTP_TIMEOUT = 30000;
85
+ const RENDER_DEADLINE_MS = 300000;
86
+ const MAX_DOWNLOAD_SIZE = 25 * 1024 * 1024;
87
+ const MAX_EVIDENCE_LEN = 2048;
88
+
89
+ // ── Trait tables ─────────────────────────────────
90
+ // PUBLISHED DERIVATION — do not reorder, insert, or reword. Buyers verify a
91
+ // finished piece against these tables; an edit rewrites history.
92
+
93
+ export const MATERIAL: string[] = [
94
+ 'woven brass wire, purple-anodised',
95
+ 'hand-blown violet glass tubing',
96
+ 'brushed titanium tube with a violet anodised sheen',
97
+ 'polished amethyst rod',
98
+ 'liquid mercury drawn into slender rings',
99
+ 'spun indigo carbon fibre',
100
+ 'cold neon tubing, violet-blue',
101
+ 'hammered silver wire with a blue patina',
102
+ 'lapis lazuli veined with gold',
103
+ 'frosted acrylic lit from within',
104
+ 'braided optical fibre carrying blue light',
105
+ 'wet ink drawn into perfect loops',
106
+ 'blued gun steel, mirror finished',
107
+ 'iridescent beetle-shell chitin',
108
+ 'clear ice with violet trapped inside',
109
+ 'knurled anodised aluminium',
110
+ ];
111
+
112
+ export const SETTING: string[] = [
113
+ 'a still black water surface',
114
+ 'a salt flat under open sky',
115
+ 'a vast marble hall',
116
+ 'dense volcanic rock',
117
+ 'a field of drifting particles',
118
+ 'the inside of a geode',
119
+ 'a cloud layer seen from above',
120
+ 'deep ocean, no horizon',
121
+ 'a windless dune sea',
122
+ 'a mirrored infinity chamber',
123
+ 'an abandoned observatory dome',
124
+ 'a cracked frozen lake',
125
+ 'a monsoon downpour at night',
126
+ 'a field of tall dry grass',
127
+ 'a limestone cavern floor',
128
+ "low orbit, a planet's edge below",
129
+ ];
130
+
131
+ export const LIGHT: string[] = [
132
+ 'low raking dawn light',
133
+ 'hard directional noon sun',
134
+ 'soft overcast diffusion',
135
+ 'single cold rim light',
136
+ 'warm sodium glow',
137
+ 'caustic light through water',
138
+ 'bioluminescence',
139
+ 'total eclipse corona',
140
+ 'lightning frozen mid-strike',
141
+ 'close flickering candlelight',
142
+ 'a searchlight beam through fog',
143
+ 'low blue moonlight',
144
+ 'light through stained glass',
145
+ 'the glow of a screen just off frame',
146
+ 'aurora light from above',
147
+ 'backlit against a bright sky',
148
+ ];
149
+
150
+ export const PALETTE: string[] = [
151
+ 'deep indigo #4B3ADF and violet #7A68FF against near-black',
152
+ 'electric violet and cold cyan-blue, high contrast',
153
+ 'royal purple and pale ice blue',
154
+ 'indigo and bone white, minimal and clean',
155
+ 'midnight navy and luminous periwinkle',
156
+ 'iridescent purple-to-blue shift, oil-slick sheen',
157
+ 'ultraviolet and deep teal',
158
+ 'amethyst and brushed silver-blue',
159
+ 'violet and warm gold, jewel-toned',
160
+ 'slate blue and lavender, muted and soft',
161
+ 'deep plum and electric blue',
162
+ 'cobalt and pale lilac',
163
+ 'blue-black and neon magenta-violet',
164
+ 'dusty mauve and steel blue',
165
+ 'sapphire and pearl white',
166
+ 'blacklight purple on charcoal',
167
+ ];
168
+
169
+ export const MOTION: string[] = [
170
+ 'perfectly still',
171
+ 'mid-rotation, motion blur on the outer ring',
172
+ 'shattering outward',
173
+ 'slowly dissolving into particles',
174
+ 'reassembling from fragments',
175
+ 'rippling as if seen through heat haze',
176
+ 'spinning fast enough to blur into a shell',
177
+ 'frozen at the instant of impact',
178
+ ];
179
+
180
+ export const SCALE: string[] = [
181
+ 'monumental, filling the frame',
182
+ 'small and distant, dwarfed by the setting',
183
+ "held at arm's length, macro detail",
184
+ 'repeated in receding rows',
185
+ 'tilted off-axis, seen from below',
186
+ 'seen from directly above, flattened',
187
+ 'reflected, the reflection sharper than the object',
188
+ 'partially out of frame, cropped close',
189
+ ];
190
+
191
+ export const TIME_OF_DAY: string[] = [
192
+ 'deep night, before dawn',
193
+ 'first light',
194
+ 'full morning',
195
+ 'high noon',
196
+ 'late afternoon',
197
+ 'golden hour',
198
+ 'dusk',
199
+ 'dead of night',
200
+ ];
201
+
202
+ export const PATINA: string[] = [
203
+ 'factory fresh, not a mark on it',
204
+ 'lightly handled, faint fingerprints',
205
+ 'worn at the edges from use',
206
+ 'aged, oxidised, softened by years',
207
+ 'ancient, pitted and mineral-crusted',
208
+ ];
209
+
210
+ const PATINA_THRESHOLDS = [90, 365, 1095, 1825];
211
+
212
+ export const HOW_TO_VERIFY =
213
+ 'sha256 the block id, then index MATERIAL/SETTING/LIGHT/PALETTE (16 each) ' +
214
+ 'by bytes 0-3 mod 16 and MOTION/SCALE (8 each) by bytes 4-5 mod 8; hour//3 of the block ' +
215
+ 'time gives time_of_day; the client account\'s age gives patina.';
216
+
217
+ // ── Derivation ───────────────────────────────────
218
+
219
+ /** Age in days of the buyer's account, bucketed at 90 / 365 / 1095 / 1825. */
220
+ export function patinaFor(accountAgeDays: number): string {
221
+ let idx = 0;
222
+ for (const threshold of PATINA_THRESHOLDS) if (accountAgeDays >= threshold) idx += 1;
223
+ return PATINA[Math.min(idx, 4)];
224
+ }
225
+
226
+ /**
227
+ * The hour of a chain timestamp, read literally. Chain timestamps are UTC and
228
+ * carry no offset, so the literal hour in the string is the hour that matters —
229
+ * reading it textually keeps the result free of the host's timezone.
230
+ */
231
+ export function blockHour(blockTime: string): number {
232
+ const m = /T(\d{2}):/.exec(blockTime);
233
+ if (m) return parseInt(m[1], 10);
234
+ const parsed = new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(blockTime) ? blockTime : `${blockTime}Z`);
235
+ if (Number.isNaN(parsed.getTime())) throw new Error(`unparseable block time: ${blockTime}`);
236
+ return parsed.getUTCHours();
237
+ }
238
+
239
+ /** Days between an ISO timestamp (UTC, offset optional) and now, floored. */
240
+ export function accountAgeDays(created: string, now: number = Date.now()): number {
241
+ const iso = /[Zz]|[+-]\d{2}:?\d{2}$/.test(created) ? created : `${created}Z`;
242
+ const createdMs = new Date(iso).getTime();
243
+ if (Number.isNaN(createdMs)) throw new Error(`unparseable account creation time: ${created}`);
244
+ return Math.floor((now - createdMs) / 86400000);
245
+ }
246
+
247
+ /**
248
+ * Every trait, from the block id, the block time, and the buyer's account age.
249
+ * sha256 the raw block id bytes; bytes 0-3 mod 16 pick material/setting/light/
250
+ * palette, bytes 4-5 mod 8 pick motion/scale.
251
+ */
252
+ export function deriveTraits(blockId: string, blockTime: string, ageDays: number): Traits {
253
+ if (!/^[0-9a-fA-F]+$/.test(blockId) || blockId.length % 2 !== 0) {
254
+ throw new Error(`block id is not hex: ${blockId}`);
255
+ }
256
+ const h = createHash('sha256').update(Buffer.from(blockId, 'hex')).digest();
257
+ return {
258
+ material: MATERIAL[h[0] % 16],
259
+ setting: SETTING[h[1] % 16],
260
+ light: LIGHT[h[2] % 16],
261
+ palette: PALETTE[h[3] % 16],
262
+ motion: MOTION[h[4] % 8],
263
+ scale: SCALE[h[5] % 8],
264
+ time_of_day: TIME_OF_DAY[Math.floor(blockHour(blockTime) / 3)],
265
+ patina: patinaFor(ageDays),
266
+ };
267
+ }
268
+
269
+ /** The generation prompt. The atom's geometry is fixed; the block sets the rest. */
270
+ export function buildPrompt(t: Traits, steer: string): string {
271
+ let p =
272
+ 'Reproduce the emblem from the reference image EXACTLY: it is a classic ATOM. At the ' +
273
+ 'exact centre sits one solid sphere, the nucleus. Around that nucleus run exactly three ' +
274
+ 'slender elliptical ORBIT rings, all sharing the same centre point, each rotated about ' +
275
+ 'sixty degrees from the next, so they read as orbital paths sweeping around the nucleus. ' +
276
+ 'Each orbit is a thin open hoop with empty space inside it, never a filled disc or shell, ' +
277
+ 'and the background shows through the gaps.\n\n' +
278
+ `The three orbits are made of ${t.material}. The atom is ${t.scale}, set in ` +
279
+ `${t.setting}, at ${t.time_of_day}, lit by ${t.light}. Its surface is ` +
280
+ `${t.patina}. The atom is ${t.motion}. Colour palette strictly ${t.palette}.\n\n` +
281
+ 'Premium 3D render, glossy and dimensional, ray-traced reflections, shallow depth of ' +
282
+ 'field, centred composition with generous negative space.';
283
+ if (steer) {
284
+ // The block fixes the atom's material, setting, light, palette, motion and
285
+ // scale. It says nothing about what else may share the scene, so a buyer's
286
+ // theme is honoured around the atom rather than refused.
287
+ p +=
288
+ `\n\nThe buyer asked for this, and it must be visibly present: ${steer}. ` +
289
+ 'Work their theme into the surroundings — the environment, the sky, the ' +
290
+ 'forms and shadows around the atom — while the atom itself stays the ' +
291
+ 'single clear subject at the centre, unchanged in geometry.';
292
+ }
293
+ return (
294
+ p +
295
+ '\n\nNo text, no letters, no numbers and no watermark. ' +
296
+ 'The atom is the only emblem; any other imagery is scenery, never a logo.'
297
+ );
298
+ }
299
+
300
+ /**
301
+ * A manifest, not a bare link: the job page renders the first file when it is an
302
+ * image, so the buyer sees the piece instead of a gateway directory listing.
303
+ */
304
+ export function buildManifest(plan: BlockArtPlan, base: string): string {
305
+ const t = plan.traits;
306
+ let manifest = JSON.stringify({
307
+ v: 1,
308
+ files: [
309
+ { name: 'blockart.png', uri: `${base}/blockart.png`, type: 'image/png' },
310
+ { name: 'traits.json', uri: `${base}/traits.json`, type: 'application/json' },
311
+ ],
312
+ note:
313
+ `Seeded by block ${plan.seed_block}, produced by ${plan.producer} at ${plan.block_time}Z. ` +
314
+ `${t.material}; ${t.setting}; ${t.light}; ${t.palette}; ${t.motion}; ` +
315
+ `${t.scale}; ${t.time_of_day}; patina ${t.patina}. ` +
316
+ `Every trait derives from the block id (${plan.block_id.slice(0, 16)}...) and the age of ` +
317
+ `${plan.client}; traits.json shows the derivation so you can check it.`,
318
+ });
319
+ if (manifest.length > MAX_EVIDENCE_LEN) manifest = manifest.slice(0, MAX_EVIDENCE_LEN - 8) + '"}]}';
320
+ return manifest;
321
+ }
322
+
323
+ /** One paragraph an operator (or an agent) can read without opening the JSON. */
324
+ export function planSummary(plan: BlockArtPlan, reusedSeed: boolean): string {
325
+ const t = plan.traits;
326
+ const who = plan.buyer.display_name ? `${plan.buyer.display_name} (${plan.client})` : plan.client;
327
+ return [
328
+ `Job ${plan.job_id} is a Block Art purchase by ${who}, seeded by block ${plan.seed_block}`,
329
+ `(${plan.block_id.slice(0, 16)}..., produced by ${plan.producer} at ${plan.block_time}Z)`,
330
+ reusedSeed ? 'reused from the first attempt.' : 'recovered from the payment transfer.',
331
+ `The block gives: three orbits of ${t.material}, ${t.scale}, set in ${t.setting},`,
332
+ `at ${t.time_of_day}, lit by ${t.light}, ${t.motion}, in ${t.palette};`,
333
+ `the account is ${plan.account_age_days} days old, so the surface is ${t.patina}.`,
334
+ plan.buyer_steer
335
+ ? `The buyer's steer, honoured in the surroundings only: ${plan.buyer_steer}`
336
+ : 'The buyer gave no steer, so the block decides the whole piece.',
337
+ ].join(' ');
338
+ }
339
+
340
+ // ── HTTP helpers ─────────────────────────────────
341
+
342
+ async function httpJson(url: string, init: RequestInit = {}, timeout = HTTP_TIMEOUT): Promise<any> {
343
+ const controller = new AbortController();
344
+ const timer = setTimeout(() => controller.abort(), timeout);
345
+ try {
346
+ const resp = await fetch(url, { ...init, signal: controller.signal });
347
+ if (!resp.ok) {
348
+ const text = await resp.text().catch(() => '');
349
+ const err: any = new Error(`${init.method || 'GET'} ${url} failed (${resp.status}): ${text.slice(0, 200)}`);
350
+ err.status = resp.status;
351
+ throw err;
352
+ }
353
+ return await resp.json();
354
+ } finally {
355
+ clearTimeout(timer);
356
+ }
357
+ }
358
+
359
+ function rpcPost(endpoint: string, path: string, body: unknown): Promise<any> {
360
+ return httpJson(`${endpoint}${path}`, {
361
+ method: 'POST',
362
+ headers: { 'Content-Type': 'application/json' },
363
+ body: JSON.stringify(body),
364
+ });
365
+ }
366
+
367
+ /** Hyperion is rate-limited; back off rather than failing the run. */
368
+ async function hyperion(url: string, attempts = 3): Promise<any> {
369
+ for (let i = 0; i < attempts; i++) {
370
+ try {
371
+ return await httpJson(url);
372
+ } catch (e: any) {
373
+ if (e?.status !== 429 || i === attempts - 1) throw e;
374
+ await new Promise(r => setTimeout(r, 2 ** i * 3000));
375
+ }
376
+ }
377
+ throw new Error('unreachable');
378
+ }
379
+
380
+ /** Accept a bare host or a full /v2/history base for HYPERION_URL. */
381
+ function hyperionBase(raw: string): string {
382
+ const trimmed = raw.replace(/\/+$/, '');
383
+ return /\/v2\/history$/.test(trimmed) ? trimmed : `${trimmed}/v2/history`;
384
+ }
385
+
386
+ // ── Chain reads ──────────────────────────────────
387
+
388
+ interface ChainJob {
389
+ id: number;
390
+ client: string;
391
+ agent: string;
392
+ description: string;
393
+ state: number;
394
+ job_hash: string;
395
+ [k: string]: unknown;
396
+ }
397
+
398
+ async function getJob(rpc: string, jobId: number): Promise<ChainJob | null> {
399
+ const res = await rpcPost(rpc, '/v1/chain/get_table_rows', {
400
+ json: true, code: ESCROW, scope: ESCROW, table: 'jobs',
401
+ lower_bound: jobId, upper_bound: jobId, limit: 1,
402
+ });
403
+ return (res.rows && res.rows[0]) || null;
404
+ }
405
+
406
+ /** The block the buyer's payment confirmed in — the seed for the whole piece. */
407
+ async function fundingBlock(hypBase: string, job: ChainJob, svc: string): Promise<number | null> {
408
+ const url = `${hypBase}/get_actions?account=${ESCROW}&filter=eosio.token:transfer&limit=100&sort=desc`;
409
+ const actions = (await hyperion(url)).actions || [];
410
+ for (const a of actions) {
411
+ const d = (a?.act?.data || {}) as Record<string, unknown>;
412
+ if (d.from === job.client && String(d.memo ?? '').startsWith(`buy:${svc}`)) return a.block_num;
413
+ }
414
+ return null;
415
+ }
416
+
417
+ /** Who the buyer is, per the chain itself — never a handle they typed. */
418
+ async function buyerIdentity(rpc: string, account: string): Promise<BuyerIdentity> {
419
+ try {
420
+ const res = await rpcPost(rpc, '/v1/chain/get_table_rows', {
421
+ json: true, code: 'eosio.proton', scope: 'eosio.proton', table: 'usersinfo',
422
+ lower_bound: account, upper_bound: account, limit: 1,
423
+ });
424
+ const row = res.rows && res.rows[0];
425
+ const name = row ? String(row.name || '').trim() : '';
426
+ return { account, display_name: name || null, verified: row ? Boolean(row.verified) : false };
427
+ } catch {
428
+ return { account, display_name: null, verified: false };
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Notes the client attached to any revise action, oldest first.
434
+ *
435
+ * These live in action history, not in the jobmsgs table, so a job that has been
436
+ * sent back looks identical on chain to one never delivered. Ignoring them means
437
+ * re-rendering the same thing and being sent back again.
438
+ */
439
+ async function revisionNotes(hypBase: string, jobId: number): Promise<string[]> {
440
+ try {
441
+ const url = `${hypBase}/get_actions?account=${ESCROW}&filter=${ESCROW}:revise&limit=100&sort=desc`;
442
+ const actions = (await hyperion(url)).actions || [];
443
+ const out: string[] = [];
444
+ for (const a of actions) {
445
+ const d = (a?.act?.data || {}) as Record<string, unknown>;
446
+ if (String(d.job_id) === String(jobId)) out.push(String(d.notes ?? ''));
447
+ }
448
+ return out.reverse().filter(n => n);
449
+ } catch {
450
+ return [];
451
+ }
452
+ }
453
+
454
+ /**
455
+ * What the buyer asked for, from the input form first, notes second.
456
+ *
457
+ * The site sends form answers as a JSON message on the job thread in the same
458
+ * transaction as payment; the memo-notes path is the fallback for a buyer who
459
+ * transferred by hand. Revision notes are appended so a re-render answers the
460
+ * complaint instead of repeating it.
461
+ */
462
+ async function buyerSteer(rpc: string, hypBase: string, job: ChainJob): Promise<string> {
463
+ const parts: string[] = [];
464
+ try {
465
+ const res = await rpcPost(rpc, '/v1/chain/get_table_rows', {
466
+ json: true, code: ESCROW, scope: ESCROW, table: 'jobmsgs',
467
+ index_position: 2, key_type: 'i64',
468
+ lower_bound: job.id, upper_bound: job.id, limit: 30,
469
+ });
470
+ for (const m of res.rows || []) {
471
+ if (m.author !== job.client) continue;
472
+ let ans: any;
473
+ try { ans = JSON.parse(m.text); } catch { continue; }
474
+ if (!ans || typeof ans !== 'object' || Array.isArray(ans)) continue;
475
+ const mood = String(ans.mood ?? '').trim();
476
+ if (mood && !mood.toLowerCase().includes('let the block decide')) parts.push(`mood: ${mood}`);
477
+ const use = String(ans.intended_use ?? '').trim();
478
+ if (use && use !== 'not sure yet') parts.push(`intended use: ${use}`);
479
+ const notes = String(ans.notes ?? '').trim();
480
+ if (notes) parts.push(`the buyer's theme: ${notes}`);
481
+ }
482
+ } catch { /* fall through to the memo-notes path */ }
483
+
484
+ if (parts.length === 0) {
485
+ const m = /Buyer notes:\s*([\s\S]+)/.exec(job.description || '');
486
+ if (m) parts.push(m[1].trim());
487
+ }
488
+
489
+ const seen = new Set<string>();
490
+ for (const n of await revisionNotes(hypBase, job.id)) {
491
+ const key = n.trim().replace(/\.+$/, '').toLowerCase();
492
+ if (key && !seen.has(key)) {
493
+ seen.add(key);
494
+ parts.push(`the buyer rejected the last attempt, saying: ${n.trim()}`);
495
+ }
496
+ }
497
+ return parts.join('; ').slice(0, 600);
498
+ }
499
+
500
+ // ── Plan persistence ─────────────────────────────
501
+ // The seed is immutable once established: a re-render after a revision must use
502
+ // the same block, or the piece no longer matches the block the buyer was told it
503
+ // came from. Reusing it also means a rate-limited history API cannot block a
504
+ // retry.
505
+
506
+ function workDir(): string {
507
+ return path.resolve(process.env.BLOCKART_WORK_DIR || DEFAULT_WORK_DIR);
508
+ }
509
+
510
+ export function planPath(dir: string, jobId: number): string {
511
+ return path.join(dir, `job${jobId}.json`);
512
+ }
513
+
514
+ export function readPlan(dir: string, jobId: number): BlockArtPlan | null {
515
+ try {
516
+ const raw = fs.readFileSync(planPath(dir, jobId), 'utf-8');
517
+ const parsed = JSON.parse(raw);
518
+ return parsed && typeof parsed === 'object' ? (parsed as BlockArtPlan) : null;
519
+ } catch {
520
+ return null;
521
+ }
522
+ }
523
+
524
+ export function writePlan(dir: string, plan: BlockArtPlan): string {
525
+ fs.mkdirSync(dir, { recursive: true });
526
+ const file = planPath(dir, plan.job_id);
527
+ // The persisted plan IS the traits.json that gets pinned alongside the image.
528
+ fs.writeFileSync(file, JSON.stringify(plan, null, 2));
529
+ return file;
530
+ }
531
+
532
+ // ── Render ───────────────────────────────────────
533
+
534
+ /**
535
+ * Replicate, with the XPR glyph as the reference image so the atom's geometry
536
+ * comes from the emblem rather than from the model's imagination.
537
+ */
538
+ async function renderImage(token: string, prompt: string): Promise<string> {
539
+ const create = await httpJson(
540
+ `https://api.replicate.com/v1/models/${REPLICATE_MODEL}/predictions`,
541
+ {
542
+ method: 'POST',
543
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Prefer: 'wait' },
544
+ body: JSON.stringify({
545
+ input: {
546
+ prompt,
547
+ image_input: [GLYPH],
548
+ aspect_ratio: '1:1',
549
+ resolution: '2K',
550
+ output_format: 'png',
551
+ },
552
+ }),
553
+ },
554
+ 120000,
555
+ );
556
+
557
+ let result: any = create;
558
+ const deadline = Date.now() + RENDER_DEADLINE_MS;
559
+ while (result.status !== 'succeeded' && result.status !== 'failed' && result.status !== 'canceled') {
560
+ if (Date.now() > deadline) throw new Error('render timed out after 5 minutes');
561
+ await new Promise(r => setTimeout(r, 2000));
562
+ result = await httpJson(result.urls?.get || `https://api.replicate.com/v1/predictions/${result.id}`, {
563
+ headers: { Authorization: `Bearer ${token}` },
564
+ });
565
+ }
566
+ if (result.status !== 'succeeded') throw new Error(`render ${result.status}: ${result.error || 'no detail'}`);
567
+
568
+ const output = result.output;
569
+ const url = Array.isArray(output) ? output[0] : output;
570
+ if (!url) throw new Error('render succeeded but returned no image url');
571
+ return String(url);
572
+ }
573
+
574
+ async function download(url: string): Promise<Buffer> {
575
+ const controller = new AbortController();
576
+ const timer = setTimeout(() => controller.abort(), 120000);
577
+ try {
578
+ const resp = await fetch(url, { signal: controller.signal, redirect: 'follow' });
579
+ if (!resp.ok) throw new Error(`download failed (${resp.status})`);
580
+ const bytes = await resp.arrayBuffer();
581
+ if (bytes.byteLength > MAX_DOWNLOAD_SIZE) throw new Error(`rendered image too large (${bytes.byteLength} bytes)`);
582
+ return Buffer.from(bytes);
583
+ } finally {
584
+ clearTimeout(timer);
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Pin the image and its traits as one directory, so both live under one CID and
590
+ * traits.json sits beside the piece it explains.
591
+ */
592
+ async function pinDirectory(jwt: string, png: Buffer, traitsJson: string, name: string): Promise<string> {
593
+ const form = new FormData();
594
+ form.append('file', new Blob([new Uint8Array(png)], { type: 'image/png' }), 'ba/blockart.png');
595
+ form.append('file', new Blob([traitsJson], { type: 'application/json' }), 'ba/traits.json');
596
+ form.append('pinataOptions', JSON.stringify({ wrapWithDirectory: false }));
597
+ form.append('pinataMetadata', JSON.stringify({ name }));
598
+
599
+ const data = await httpJson(
600
+ 'https://api.pinata.cloud/pinning/pinFileToIPFS',
601
+ { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form },
602
+ 120000,
603
+ );
604
+ if (!data?.IpfsHash) throw new Error('Pinata returned no IpfsHash');
605
+ return String(data.IpfsHash);
606
+ }
607
+
608
+ // ── Skill entry point ────────────────────────────
609
+
610
+ export default function blockartSkill(api: SkillApi): void {
611
+ const config = api.getConfig();
612
+ const rpcEndpoint =
613
+ ((config.rpcEndpoint as string) || process.env.XPR_RPC_ENDPOINT || DEFAULT_RPC).replace(/\/+$/, '');
614
+ const hypBase = hyperionBase(process.env.HYPERION_URL || DEFAULT_HYPERION);
615
+
616
+ // ── blockart_plan ──
617
+ api.registerTool({
618
+ name: 'blockart_plan',
619
+ description: [
620
+ 'Plan a Block Art piece for a funded job (job_hash "svc:<id>"). Reads the job from chain,',
621
+ 'recovers the block the buyer\'s payment confirmed in, derives every trait from that block,',
622
+ 'reads the buyer\'s form answers and any revision notes, and builds the generation prompt.',
623
+ 'Returns the full plan plus a one-paragraph summary, and persists the plan so a re-render',
624
+ 'after a revision reuses the same seed block. Read-only: it never spends and never signs.',
625
+ 'Call this before blockart_render.',
626
+ ].join(' '),
627
+ parameters: {
628
+ type: 'object',
629
+ required: ['job_id'],
630
+ properties: {
631
+ job_id: { type: 'number', description: 'Escrow job ID of the Block Art purchase' },
632
+ },
633
+ },
634
+ handler: async ({ job_id }: { job_id: number }) => {
635
+ if (!Number.isInteger(job_id) || job_id < 0) return { error: 'job_id must be a non-negative integer' };
636
+ try {
637
+ const job = await getJob(rpcEndpoint, job_id);
638
+ if (!job) return { error: `job ${job_id} not found` };
639
+
640
+ const match = /^svc:(\d+)$/.exec(String(job.job_hash || ''));
641
+ if (!match) {
642
+ return {
643
+ error: `job ${job_id} has job_hash "${job.job_hash}" — Block Art only fulfils service purchases (job_hash "svc:<id>")`,
644
+ };
645
+ }
646
+ const svc = match[1];
647
+
648
+ const me = process.env.XPR_ACCOUNT;
649
+ if (me && job.agent !== me) {
650
+ return { error: `job ${job_id} is assigned to "${job.agent}", not "${me}"` };
651
+ }
652
+
653
+ const dir = workDir();
654
+ const prior = readPlan(dir, job_id);
655
+ const reusedSeed = Boolean(prior?.seed_block);
656
+ const seedBlock = reusedSeed ? Number(prior!.seed_block) : await fundingBlock(hypBase, job, svc);
657
+ if (!seedBlock) {
658
+ return {
659
+ error: `could not find the funding transfer for job ${job_id} (an "eosio.token:transfer" from ${job.client} with memo "buy:${svc}...") in the last 100 escrow transfers`,
660
+ };
661
+ }
662
+
663
+ const block = await rpcPost(rpcEndpoint, '/v1/chain/get_block', { block_num_or_id: seedBlock });
664
+ const account = await rpcPost(rpcEndpoint, '/v1/chain/get_account', { account_name: job.client });
665
+ const ageDays = accountAgeDays(String(account.created));
666
+ const traits = deriveTraits(String(block.id), String(block.timestamp), ageDays);
667
+ const steer = await buyerSteer(rpcEndpoint, hypBase, job);
668
+
669
+ const plan: BlockArtPlan = {
670
+ job_id,
671
+ service: String(job.job_hash),
672
+ client: String(job.client),
673
+ buyer: await buyerIdentity(rpcEndpoint, String(job.client)),
674
+ seed_block: seedBlock,
675
+ block_id: String(block.id),
676
+ block_time: String(block.timestamp),
677
+ producer: String(block.producer),
678
+ buyer_steer: steer,
679
+ traits,
680
+ account_age_days: ageDays,
681
+ prompt: buildPrompt(traits, steer),
682
+ how_to_verify: HOW_TO_VERIFY,
683
+ };
684
+
685
+ const file = writePlan(dir, plan);
686
+ return {
687
+ success: true,
688
+ plan,
689
+ summary: planSummary(plan, reusedSeed),
690
+ reused_seed_block: reusedSeed,
691
+ job_state: job.state,
692
+ plan_file: file,
693
+ instruction: [
694
+ 'Read the summary. If the buyer asked for something the Block Art listing does not offer,',
695
+ 'ask ONE question with xpr_ask_client before rendering. Otherwise call blockart_render',
696
+ 'with the same job_id.',
697
+ ].join(' '),
698
+ };
699
+ } catch (e: any) {
700
+ return { error: `blockart_plan failed: ${e?.message || String(e)}` };
701
+ }
702
+ },
703
+ });
704
+
705
+ // ── blockart_render ──
706
+ api.registerTool({
707
+ name: 'blockart_render',
708
+ description: [
709
+ 'Render the planned Block Art piece and pin it to IPFS with its traits.json, under one CID.',
710
+ 'Requires a plan from blockart_plan for the same job_id (it reuses that plan\'s seed block,',
711
+ 'so a re-render after a revision still matches the block the buyer was told it came from).',
712
+ 'Requires REPLICATE_API_TOKEN and PINATA_JWT.',
713
+ 'Returns a ready-to-use delivery manifest — this tool never signs and never delivers:',
714
+ 'pass the returned evidence_uri to xpr_deliver_job to complete the delivery.',
715
+ ].join(' '),
716
+ parameters: {
717
+ type: 'object',
718
+ required: ['job_id'],
719
+ properties: {
720
+ job_id: { type: 'number', description: 'Escrow job ID previously passed to blockart_plan' },
721
+ },
722
+ },
723
+ handler: async ({ job_id }: { job_id: number }) => {
724
+ if (!Number.isInteger(job_id) || job_id < 0) return { error: 'job_id must be a non-negative integer' };
725
+
726
+ const replicateToken = process.env.REPLICATE_API_TOKEN;
727
+ if (!replicateToken) return { error: 'REPLICATE_API_TOKEN is not set — Block Art cannot render without it' };
728
+ const pinataJwt = process.env.PINATA_JWT;
729
+ if (!pinataJwt) return { error: 'PINATA_JWT is not set — Block Art cannot pin the piece without it' };
730
+ const gateway = (process.env.PINATA_GATEWAY || DEFAULT_GATEWAY).replace(/\/+$/, '');
731
+
732
+ const dir = workDir();
733
+ const plan = readPlan(dir, job_id);
734
+ if (!plan) return { error: `no Block Art plan found for job ${job_id} — call blockart_plan first` };
735
+ if (!plan.prompt || !plan.block_id) return { error: `the stored plan for job ${job_id} is incomplete — re-run blockart_plan` };
736
+
737
+ try {
738
+ const imageUrl = await renderImage(replicateToken, plan.prompt);
739
+ const png = await download(imageUrl);
740
+
741
+ // Keep the rendered piece beside its plan so a failed pin can be retried
742
+ // without paying for a second render.
743
+ fs.mkdirSync(dir, { recursive: true });
744
+ const pngPath = path.join(dir, `job${job_id}.png`);
745
+ fs.writeFileSync(pngPath, png);
746
+
747
+ const traitsJson = JSON.stringify(plan, null, 2);
748
+ const cid = await pinDirectory(pinataJwt, png, traitsJson, `blockart-job${job_id}`);
749
+ const base = `${gateway}/ipfs/${cid}`;
750
+ const manifest = buildManifest(plan, base);
751
+
752
+ return {
753
+ success: true,
754
+ evidence_uri: manifest,
755
+ cid,
756
+ image_url: `${base}/blockart.png`,
757
+ traits_url: `${base}/traits.json`,
758
+ bytes: png.length,
759
+ seed_block: plan.seed_block,
760
+ traits: plan.traits,
761
+ local_png: pngPath,
762
+ instruction:
763
+ 'Rendered and pinned. Now call xpr_deliver_job with evidence_uri set to the manifest string above, exactly as returned.',
764
+ };
765
+ } catch (e: any) {
766
+ return { error: `blockart_render failed: ${e?.message || String(e)}` };
767
+ }
768
+ },
769
+ });
770
+ }