create-panal-agent 0.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@panal/sdk": "^0.5.0",
13
+ "@panal/sdk": "^0.6.0",
14
14
  "dotenv": "^17.0.0",
15
15
  "tsx": "^4.19.0",
16
16
  "viem": "^2.21.0"
@@ -25,6 +25,27 @@ export interface TaskContext {
25
25
  deadline: bigint;
26
26
  }
27
27
 
28
+ /** Un archivo que entregas junto al texto. */
29
+ export interface TaskFile {
30
+ /** Cómo se va a llamar. Sin rutas: `informe.pdf`, no `salida/informe.pdf`. */
31
+ name: string;
32
+ /** El contenido. Un Buffer/Uint8Array para binario, un string para texto. */
33
+ data: Uint8Array | string;
34
+ /** Tipo MIME, si lo sabes: `application/pdf`, `image/png`… */
35
+ mime?: string;
36
+ }
37
+
38
+ /**
39
+ * Lo que devuelve tu agente: un texto, o un texto con archivos.
40
+ *
41
+ * Los archivos no viajan a la cadena —no cabrían—, pero SU HASH sí: el motor
42
+ * lo mete en el texto de la entrega antes de anclarlo. Así el cliente puede
43
+ * descargarlos y demostrar que son exactamente los que le entregaste. Un
44
+ * enlace a secas no daría eso: quien lo aloja podría cambiar el archivo
45
+ * después de cobrar y no habría con qué demostrarlo.
46
+ */
47
+ export type TaskResult = string | { text: string; files?: TaskFile[] };
48
+
28
49
  /** Cómo se llama esto en los logs: `#31` si viene del escrow, `x402` si no. */
29
50
  function etiqueta(ctx: TaskContext): string {
30
51
  return ctx.taskId === null ? 'x402' : `#${ctx.taskId}`;
@@ -35,9 +56,20 @@ function etiqueta(ctx: TaskContext): string {
35
56
  *
36
57
  * @param brief El encargo, tal y como lo escribió el cliente.
37
58
  * @param ctx Datos de la tarea, por si te sirven.
38
- * @returns El trabajo terminado.
59
+ * @returns El trabajo terminado: un texto, o `{ text, files }` si además
60
+ * entregas archivos. Por ejemplo:
61
+ *
62
+ * return {
63
+ * text: 'Aquí tienes el informe que pediste.',
64
+ * files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }],
65
+ * };
66
+ *
67
+ * No tienes que calcular ningún hash ni servir ninguna descarga:
68
+ * de eso se ocupa `server.ts`. Ojo con una cosa, y solo con una:
69
+ * si construyes el nombre a partir del encargo, límpialo antes,
70
+ * porque lo escribe quien te contrató.
39
71
  */
40
- export async function handleTask(brief: string, ctx: TaskContext): Promise<string> {
72
+ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskResult> {
41
73
  // ──────────────────────────────────────────────────────────────────────────
42
74
  // EJEMPLO: un agente que responde con un LLM.
43
75
  //
@@ -25,20 +25,24 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import {
28
+ appendFilesManifest,
28
29
  buildQuote,
29
30
  createPanalClient,
30
31
  MAINNET_ADDRESSES,
31
32
  parsePaymentHeader,
32
33
  permitNonce,
33
34
  readPermitDomain,
35
+ sanitizeFileName,
34
36
  TaskStatus,
35
37
  verifyAndSettle,
38
+ type DeliveredFile,
36
39
  type PermitDomain,
37
40
  } from '@panal/sdk';
38
41
  import { privateKeyToAccount } from 'viem/accounts';
39
42
  import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
40
43
  import type { Address } from 'viem';
41
44
  import { handleTask } from './agent.js';
45
+ import type { TaskFile, TaskResult } from './agent.js';
42
46
 
43
47
  const PORT = Number(process.env.PORT ?? 8787);
44
48
  const DATA_DIR = process.env.DATA_DIR ?? './data';
@@ -113,6 +117,8 @@ async function dominioPermit(): Promise<PermitDomain> {
113
117
 
114
118
  mkdirSync(DATA_DIR, { recursive: true });
115
119
  const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
120
+ /** Carpeta de los archivos de una tarea. Una por tarea, para no mezclarlas. */
121
+ const filesDir = (taskId: bigint) => join(DATA_DIR, 'files', taskId.toString());
116
122
 
117
123
  function saveResult(taskId: bigint, text: string): void {
118
124
  writeFileSync(resultPath(taskId), text, 'utf8');
@@ -125,6 +131,46 @@ function loadResult(taskId: bigint): string | null {
125
131
  }
126
132
  }
127
133
 
134
+ /**
135
+ * Guarda en disco los archivos de una entrega y devuelve su manifiesto.
136
+ *
137
+ * El nombre se limpia con `sanitizeFileName` ANTES de tocar el disco: llega en
138
+ * lo que devuelve `handleTask`, y un agente que construya el nombre a partir
139
+ * del encargo del cliente estaría dejando que un desconocido elija dónde
140
+ * escribir. Un `../../.env` acabaría en la raíz del proyecto.
141
+ */
142
+ function saveFiles(taskId: bigint, files: TaskFile[]): DeliveredFile[] {
143
+ const dir = filesDir(taskId);
144
+ mkdirSync(dir, { recursive: true });
145
+
146
+ return files.map((f) => {
147
+ const name = sanitizeFileName(f.name);
148
+ const bytes = typeof f.data === 'string' ? new TextEncoder().encode(f.data) : new Uint8Array(f.data);
149
+ writeFileSync(join(dir, name), bytes);
150
+ return {
151
+ name,
152
+ size: bytes.byteLength,
153
+ ...(f.mime ? { mime: f.mime } : {}),
154
+ // El hash de los BYTES, no del enlace: es lo único que sobrevive a que
155
+ // alguien cambie el archivo después de haber cobrado.
156
+ hash: keccak256(bytes),
157
+ path: `/files/${taskId}/${encodeURIComponent(name)}`,
158
+ };
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Deja lo que devolvió `handleTask` en una forma sola.
164
+ *
165
+ * Se acepta un string a secas porque es lo que devuelve el 95 % de los agentes
166
+ * y obligarles a envolverlo en un objeto sería cobrarles la complejidad de una
167
+ * función que no usan.
168
+ */
169
+ function normalizarSalida(salida: TaskResult): { text: string; files: TaskFile[] } {
170
+ if (typeof salida === 'string') return { text: salida, files: [] };
171
+ return { text: salida.text, files: salida.files ?? [] };
172
+ }
173
+
128
174
  /** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
129
175
  const inFlight = new Set<string>();
130
176
 
@@ -154,13 +200,19 @@ async function work(taskId: bigint, brief: string): Promise<void> {
154
200
  inFlight.add(key);
155
201
  try {
156
202
  const task = await panal.getTask(taskId);
157
- const text = await handleTask(brief, {
203
+ const salida = await handleTask(brief, {
158
204
  taskId,
159
205
  client: task.client,
160
206
  amount: task.amount,
161
207
  deadline: task.deadline,
162
208
  });
163
209
 
210
+ // Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
211
+ // archivos. Los archivos se escriben en disco y su hash se cuela en el
212
+ // texto: lo que se ancla en la cadena pasa a cubrirlos también.
213
+ const { text: cuerpo, files } = normalizarSalida(salida);
214
+ const text = files.length ? appendFilesManifest(cuerpo, saveFiles(taskId, files)) : cuerpo;
215
+
164
216
  // Primero se guarda y luego se entrega: si el orden fuera al revés y el
165
217
  // proceso muriera entre medias, el hash estaría anclado on-chain y el texto
166
218
  // perdido, o sea una entrega imposible de cumplir.
@@ -379,12 +431,23 @@ const server = createServer((req, res) => {
379
431
  // Ya está cobrado: pase lo que pase a partir de aquí, hay que responder
380
432
  // algo. Si el modelo revienta, se dice; callarse sería quedarse el dinero.
381
433
  try {
382
- const answer = await handleTask(prompt, {
434
+ const salida = await handleTask(prompt, {
383
435
  taskId: null,
384
436
  client: leido.payment.payer,
385
437
  amount: cobro.amount,
386
438
  deadline: 0n,
387
439
  });
440
+ // En una llamada x402 no hay tarea, así que no hay nada que anclar ni
441
+ // ninguna firma con la que proteger una descarga: los archivos no
442
+ // tienen dónde agarrarse. Se responde el texto y se avisa en el log en
443
+ // vez de callarlo, que si no el autor busca el fallo donde no está.
444
+ const { text: answer, files } = normalizarSalida(salida);
445
+ if (files.length) {
446
+ console.error(
447
+ `[x402] tu handleTask devolvió ${files.length} archivo(s) y una llamada x402 no puede entregarlos: ` +
448
+ 'no hay tarea que los ancle ni firma que proteja la descarga. Solo va el texto.',
449
+ );
450
+ }
388
451
  res.setHeader('x-payment-tx', cobro.txHash);
389
452
  json(res, 200, { answer, paid: { txHash: cobro.txHash, amount: cobro.amount.toString(), asset: X402_TOKEN } });
390
453
  } catch (err) {
@@ -495,6 +558,60 @@ const server = createServer((req, res) => {
495
558
  return;
496
559
  }
497
560
 
561
+ // ---- El cliente se baja los archivos de su entrega ----------------------
562
+ //
563
+ // Se protege igual que el resultado, y con LA MISMA firma: `Panal resultado
564
+ // #<id>` abre el texto y todos sus archivos. Firmar una vez por archivo
565
+ // sería pedirle al cliente cuatro firmas por una entrega de cuatro PDFs.
566
+ const archivo = /^\/files\/(\d+)\/([^/]+)$/.exec(url.pathname);
567
+ if (archivo && req.method === 'GET') {
568
+ const taskId = BigInt(archivo[1]!);
569
+ const address = url.searchParams.get('address');
570
+ const signature = url.searchParams.get('signature');
571
+ if (!address || !signature) {
572
+ json(res, 400, { error: 'faltan address y signature' });
573
+ return;
574
+ }
575
+ const task = await panal.getTask(taskId);
576
+ if (address.toLowerCase() !== task.client.toLowerCase()) {
577
+ json(res, 403, { error: 'solo el cliente de la tarea puede descargar sus archivos' });
578
+ return;
579
+ }
580
+ if (!(await signedBy(resultSignMessage(taskId), signature, task.client))) {
581
+ json(res, 401, { error: 'firma inválida' });
582
+ return;
583
+ }
584
+
585
+ // El nombre viene de la URL, o sea de fuera: se limpia igual que al
586
+ // escribirlo. Sin esto, `/files/31/..%2F..%2F.env` leería el .env.
587
+ let nombre: string;
588
+ try {
589
+ nombre = sanitizeFileName(decodeURIComponent(archivo[2]!));
590
+ } catch {
591
+ json(res, 400, { error: 'nombre de archivo inválido' });
592
+ return;
593
+ }
594
+
595
+ let bytes: Buffer;
596
+ try {
597
+ bytes = readFileSync(join(filesDir(taskId), nombre));
598
+ } catch {
599
+ json(res, 404, { error: 'esa tarea no tiene ese archivo' });
600
+ return;
601
+ }
602
+
603
+ res.writeHead(200, {
604
+ 'content-type': 'application/octet-stream',
605
+ 'content-length': bytes.byteLength,
606
+ // `attachment` a propósito: lo que hay dentro lo eligió el agente, y no
607
+ // se le deja que el navegador del cliente lo ejecute como una página.
608
+ 'content-disposition': `attachment; filename="${nombre}"`,
609
+ 'x-content-type-options': 'nosniff',
610
+ });
611
+ res.end(bytes);
612
+ return;
613
+ }
614
+
498
615
  json(res, 404, { error: 'no existe' });
499
616
  })().catch((err) => {
500
617
  console.error(`[http] ${err instanceof Error ? err.message : err}`);