create-panal-agent 0.10.0 → 0.12.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.
@@ -25,6 +25,7 @@ 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
+ MAX_FILE_BYTES,
28
29
  appendFilesManifest,
29
30
  assertCanServe,
30
31
  buildQuote,
@@ -32,6 +33,8 @@ import {
32
33
  LoopDetected,
33
34
  MAINNET_ADDRESSES,
34
35
  monad,
36
+ matchAttachment,
37
+ parseAttachmentsManifest,
35
38
  parseEnvelope,
36
39
  parsePaymentHeader,
37
40
  permitNonce,
@@ -39,6 +42,7 @@ import {
39
42
  sanitizeFileName,
40
43
  TaskStatus,
41
44
  verifyAndSettle,
45
+ type AttachedFile,
42
46
  type CallEnvelope,
43
47
  type DeliveredFile,
44
48
  type PermitDomain,
@@ -47,8 +51,9 @@ import { privateKeyToAccount } from 'viem/accounts';
47
51
  import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
48
52
  import type { Address } from 'viem';
49
53
  import { handleTask } from './agent.js';
50
- import type { TaskContext, TaskFile, TaskResult } from './agent.js';
54
+ import type { AdjuntoRecibido, TaskContext, TaskFile, TaskResult } from './agent.js';
51
55
  import { arrancarVigilante } from './vigilante.js';
56
+ import { historialParaElModelo, recordarTurno, type Turno } from './memoria.js';
52
57
 
53
58
  const PORT = Number(process.env.PORT ?? 8787);
54
59
  const DATA_DIR = process.env.DATA_DIR ?? './data';
@@ -193,6 +198,14 @@ mkdirSync(DATA_DIR, { recursive: true });
193
198
  const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
194
199
  /** Carpeta de los archivos de una tarea. Una por tarea, para no mezclarlas. */
195
200
  const filesDir = (taskId: bigint) => join(DATA_DIR, 'files', taskId.toString());
201
+ /**
202
+ * Carpeta de lo que MANDA el cliente, separada de lo que entrega el agente.
203
+ *
204
+ * Mezclarlas sería servir por `/files/:id/:name` un archivo que subió el
205
+ * cliente como si fuera parte de la entrega, con su hash anclado y todo. No lo
206
+ * es: son las dos direcciones del mismo mecanismo y no se tocan.
207
+ */
208
+ const inboxDir = (taskId: bigint) => join(DATA_DIR, 'inbox', taskId.toString());
196
209
 
197
210
  function saveResult(taskId: bigint, text: string): void {
198
211
  writeFileSync(resultPath(taskId), text, 'utf8');
@@ -271,6 +284,76 @@ function normalizarSalida(salida: TaskResult): { text: string; files: TaskFile[]
271
284
  return { text: salida.text, files: salida.files ?? [] };
272
285
  }
273
286
 
287
+ // ---------------------------------------------------------------------------
288
+ // Adjuntos: lo que el cliente manda CON el encargo
289
+ // ---------------------------------------------------------------------------
290
+ //
291
+ // El brief queda cerrado al contratar —el escrow ancla su keccak256 y más
292
+ // abajo se rechaza cualquier texto que no lo dé—, así que una foto no puede
293
+ // viajar dentro. Lo que viaja dentro es su HASH, anunciado en un bloque
294
+ // `[panal-attach/1]`. Los bytes suben después, por `POST /upload/:taskId`.
295
+ //
296
+ // De ahí sale la única regla que hay que recordar aquí: SÓLO SE ESCRIBE LO QUE
297
+ // EL ENCARGO ANUNCIÓ. Cualquier otro byte se rechaza sin llegar al disco. El
298
+ // número de una tarea es público, y sin esa guarda tu agente sería un almacén
299
+ // gratis para cualquiera que sepa contar.
300
+
301
+ const adjuntoPath = (taskId: bigint, nombre: string) => join(inboxDir(taskId), nombre);
302
+
303
+ /**
304
+ * Repasa qué adjuntos anunciados están ya en disco y cuáles faltan.
305
+ *
306
+ * El hash se comprueba AL LEER y no sólo al escribir. Entre las dos cosas hay
307
+ * un disco, a veces un reinicio y a veces un volumen que se vuelve a montar; y
308
+ * un trabajo hecho a partir de un archivo corrupto es peor que un trabajo sin
309
+ * hacer, porque se entrega y se ancla.
310
+ */
311
+ function repasarAdjuntos(
312
+ taskId: bigint,
313
+ brief: string,
314
+ ): { recibidos: AdjuntoRecibido[]; faltan: AttachedFile[] } {
315
+ const recibidos: AdjuntoRecibido[] = [];
316
+ const faltan: AttachedFile[] = [];
317
+
318
+ for (const anunciado of parseAttachmentsManifest(brief)) {
319
+ let bytes: Buffer;
320
+ try {
321
+ bytes = readFileSync(adjuntoPath(taskId, anunciado.name));
322
+ } catch {
323
+ faltan.push(anunciado);
324
+ continue;
325
+ }
326
+ if (!matchAttachment([anunciado], bytes, anunciado.name)) {
327
+ console.error(`[panal] #${taskId} el adjunto "${anunciado.name}" en disco no da su hash: se pide de nuevo`);
328
+ faltan.push(anunciado);
329
+ continue;
330
+ }
331
+ recibidos.push({
332
+ name: anunciado.name,
333
+ ...(anunciado.mime ? { mime: anunciado.mime } : {}),
334
+ bytes: new Uint8Array(bytes),
335
+ });
336
+ }
337
+ return { recibidos, faltan };
338
+ }
339
+
340
+ /** Escribe un adjunto ya verificado. */
341
+ function guardarAdjunto(taskId: bigint, nombre: string, bytes: Uint8Array): void {
342
+ mkdirSync(inboxDir(taskId), { recursive: true });
343
+ writeFileSync(adjuntoPath(taskId, nombre), bytes);
344
+ }
345
+
346
+ /**
347
+ * El sobre de una tarea que espera adjuntos.
348
+ *
349
+ * Cuando el encargo viene de otro agente y trae adjuntos, entre el brief y la
350
+ * última subida hay un rato en el que no se puede trabajar. El sobre lleva el
351
+ * presupuesto y el camino de la cadena, y perderlo significaría reanudar sin
352
+ * ellos. En memoria a propósito: si el proceso muere, la cadena que lo trajo
353
+ * murió con él, y reanudar sin sobre es exactamente lo que hace el vigilante.
354
+ */
355
+ const sobrePendiente = new Map<string, CallEnvelope>();
356
+
274
357
  /** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
275
358
  const inFlight = new Set<string>();
276
359
 
@@ -415,7 +498,14 @@ async function credencialValida(
415
498
  * necesita ver en los logs.
416
499
  */
417
500
  function contexto(
418
- base: { taskId: bigint | null; client: string; amount: bigint; deadline: bigint },
501
+ base: {
502
+ taskId: bigint | null;
503
+ client: string;
504
+ amount: bigint;
505
+ deadline: bigint;
506
+ adjuntos: AdjuntoRecibido[];
507
+ historial: Turno[];
508
+ },
419
509
  sobre: CallEnvelope | null,
420
510
  ): TaskContext {
421
511
  return {
@@ -453,10 +543,39 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
453
543
  // Lo PRIMERO, antes de trabajar: si el proceso muere a mitad, esto es lo
454
544
  // único que permite retomarlo. Guardarlo después sería guardarlo nunca.
455
545
  saveBrief(taskId, brief);
546
+
547
+ // Si el encargo anuncia adjuntos, no se empieza hasta tenerlos todos.
548
+ //
549
+ // La guarda va AQUÍ y no en la ruta HTTP porque el vigilante también llama
550
+ // a `work` —al retomar una tarea tras un reinicio— y ahí no hay petición
551
+ // que mirar. Sin esto, un agente que se reinicia entre el brief y la
552
+ // subida se pondría a trabajar sin la foto, entregaría lo que pudiera y
553
+ // anclaría ese resultado a medias en la cadena.
554
+ const { recibidos, faltan } = repasarAdjuntos(taskId, brief);
555
+ if (faltan.length > 0) {
556
+ console.log(
557
+ `[panal] #${taskId} en espera de ${faltan.length} adjunto(s): ${faltan.map((f) => f.name).join(', ')}`,
558
+ );
559
+ return;
560
+ }
561
+ if (recibidos.length > 0) console.log(`[panal] #${taskId} con ${recibidos.length} adjunto(s) del cliente`);
562
+
456
563
  const task = await leerTarea(taskId);
457
564
  const salida = await handleTask(
458
565
  brief,
459
- contexto({ taskId, client: task.client, amount: task.amount, deadline: task.deadline }, sobre),
566
+ contexto(
567
+ {
568
+ taskId,
569
+ client: task.client,
570
+ amount: task.amount,
571
+ deadline: task.deadline,
572
+ adjuntos: recibidos,
573
+ // Un encargo del escrow no arrastra conversación: se paga, se
574
+ // entrega una vez y se aprueba. La memoria es de los chats.
575
+ historial: [],
576
+ },
577
+ sobre,
578
+ ),
460
579
  );
461
580
 
462
581
  // Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
@@ -563,6 +682,25 @@ function json(res: ServerResponse, status: number, body: unknown): void {
563
682
  res.end(JSON.stringify(body));
564
683
  }
565
684
 
685
+ /**
686
+ * El cuerpo en crudo, con su propio tope.
687
+ *
688
+ * Va aparte de `readBody` a propósito: los 256 KB de MAX_BODY protegen las
689
+ * rutas de texto y tienen que seguir siendo pequeños. Una foto no cabe ahí, y
690
+ * subirle el tope a todas las rutas para que quepa sería abrir la puerta que
691
+ * ese límite cierra.
692
+ */
693
+ async function readBodyBytes(req: IncomingMessage, max: number): Promise<Buffer> {
694
+ const chunks: Buffer[] = [];
695
+ let total = 0;
696
+ for await (const chunk of req) {
697
+ total += (chunk as Buffer).length;
698
+ if (total > max) throw new Error(`cuerpo demasiado grande (tope ${max} bytes)`);
699
+ chunks.push(chunk as Buffer);
700
+ }
701
+ return Buffer.concat(chunks);
702
+ }
703
+
566
704
  async function readBody(req: IncomingMessage): Promise<string> {
567
705
  const chunks: Buffer[] = [];
568
706
  let total = 0;
@@ -744,9 +882,15 @@ const server = createServer((req, res) => {
744
882
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
745
883
  // Las credenciales van en cabeceras propias, y esas NO son simples: sin
746
884
  // declararlas aquí el navegador bloquea la descarga en el preflight.
885
+ //
886
+ // Cada cabecera nueva hay que añadirla A ESTA LISTA. Se olvidó con
887
+ // `x-panal-filename` al añadir los adjuntos, y el efecto es de los que
888
+ // no se ven leyendo el código: el servidor está bien, la ruta está bien,
889
+ // y el navegador se niega a hacer la petición sin dejar rastro en el log
890
+ // del agente.
747
891
  res.setHeader(
748
892
  'access-control-allow-headers',
749
- 'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-payment, x-payment-payer',
893
+ 'content-type, x-panal-address, x-panal-signature, x-panal-expira, x-panal-filename, x-payment, x-payment-payer',
750
894
  );
751
895
  res.setHeader('access-control-max-age', '86400');
752
896
  res.writeHead(204).end();
@@ -789,6 +933,15 @@ const server = createServer((req, res) => {
789
933
  body: `{"brief": string (máx. ${MAX_BRIEF_CHARS} chars), "address": "0x…", "signature": "0x…"}`,
790
934
  maxBriefChars: MAX_BRIEF_CHARS,
791
935
  },
936
+ postAttachment: {
937
+ method: 'POST',
938
+ path: '/upload/:taskId',
939
+ signMessage: 'Panal brief #<taskId> (la MISMA firma que el encargo, no hace falta otra)',
940
+ body: 'los bytes en crudo; el nombre en la cabecera X-Panal-Filename',
941
+ howTo:
942
+ 'anuncia cada adjunto en el brief con un bloque [panal-attach/1] ANTES de contratar, y sube los bytes aquí después. Sólo se aceptan los que el encargo anuncie.',
943
+ maxAttachmentBytes: MAX_FILE_BYTES,
944
+ },
792
945
  getResult: {
793
946
  method: 'GET',
794
947
  path: '/result/:taskId',
@@ -888,7 +1041,23 @@ const server = createServer((req, res) => {
888
1041
  try {
889
1042
  const salida = await handleTask(
890
1043
  prompt,
891
- contexto({ taskId: null, client: leido.payment.payer, amount: cobro.amount, deadline: 0n }, sobre),
1044
+ contexto(
1045
+ {
1046
+ taskId: null,
1047
+ client: leido.payment.payer,
1048
+ amount: cobro.amount,
1049
+ deadline: 0n,
1050
+ // Una llamada x402 es una pregunta y una respuesta: no hay tarea
1051
+ // donde anclar un adjunto, así que tampoco hay adjuntos.
1052
+ adjuntos: [],
1053
+ // Lo que ya se habló con ESTA persona. Quién es lo dice el pago:
1054
+ // firmó un permiso y el cobro se ejecutó en la cadena, así que
1055
+ // nadie puede continuar la conversación de otro sin pagar como
1056
+ // él. Por eso no hace falta autenticar nada aquí.
1057
+ historial: historialParaElModelo(DATA_DIR, leido.payment.payer),
1058
+ },
1059
+ sobre,
1060
+ ),
892
1061
  );
893
1062
  // En una llamada x402 no hay tarea, así que no hay nada que anclar ni
894
1063
  // ninguna firma con la que proteger una descarga: los archivos no
@@ -903,6 +1072,12 @@ const server = createServer((req, res) => {
903
1072
  }
904
1073
  res.setHeader('x-payment-tx', cobro.txHash);
905
1074
  json(res, 200, { answer, paid: { txHash: cobro.txHash, amount: cobro.amount.toString(), asset: X402_TOKEN } });
1075
+
1076
+ // El turno se guarda AQUÍ, con las dos mitades y sólo si hubo
1077
+ // respuesta. Guardarlo antes de trabajar dejaría preguntas sin
1078
+ // contestar en la memoria, y la siguiente vez el modelo leería una
1079
+ // conversación en la que él se quedó callado.
1080
+ recordarTurno(DATA_DIR, leido.payment.payer, { pregunta: prompt, respuesta: answer, cuando: Date.now() });
906
1081
  } catch (err) {
907
1082
  console.error(`[x402] cobrado pero falló al responder: ${err instanceof Error ? err.message : err}`);
908
1083
  json(res, 502, {
@@ -1006,12 +1181,143 @@ const server = createServer((req, res) => {
1006
1181
  return;
1007
1182
  }
1008
1183
 
1184
+ // El encargo se guarda YA, antes de contestar: la subida que viene
1185
+ // detrás lo necesita en disco para saber qué bytes puede aceptar.
1186
+ saveBrief(taskId, body.brief);
1187
+ const { faltan } = repasarAdjuntos(taskId, body.brief);
1188
+ if (faltan.length > 0) {
1189
+ // No es un error: es la otra mitad del encargo, que aún viene de
1190
+ // camino. Se contesta exactamente qué se espera para que el cliente lo
1191
+ // suba sin tener que adivinarlo.
1192
+ if (sobre) sobrePendiente.set(taskId.toString(), sobre);
1193
+ json(res, 202, {
1194
+ ok: true,
1195
+ faltanAdjuntos: faltan.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
1196
+ subirA: `/upload/${taskId}`,
1197
+ });
1198
+ return;
1199
+ }
1200
+
1009
1201
  json(res, 202, { ok: true });
1010
1202
  // Sin await: el cliente no debería esperar a que termines de trabajar.
1011
1203
  void work(taskId, body.brief, sobre);
1012
1204
  return;
1013
1205
  }
1014
1206
 
1207
+ // ---- El cliente sube los adjuntos que su encargo anunció ----------------
1208
+ //
1209
+ // Se firma UNA vez, con el mismo `Panal brief #<id>` que abrió el encargo.
1210
+ // Pedir una firma por archivo sería pedirle tres popups a alguien que ya
1211
+ // pagó, y no compraría nada: lo que decide qué entra no es la firma, es el
1212
+ // manifiesto que la cadena ya cubre.
1213
+ const subida = /^\/upload\/(\d+)$/.exec(url.pathname);
1214
+ if (subida && req.method === 'POST') {
1215
+ const taskId = BigInt(subida[1]!);
1216
+ /** Rechaza vaciando el cuerpo: si no, el cliente ve un reset en vez del motivo. */
1217
+ const rechazar = (status: number, cuerpo: unknown): void => {
1218
+ req.resume();
1219
+ json(res, status, cuerpo);
1220
+ };
1221
+
1222
+ // Lo local primero, que no cuesta ni RPC ni ancho de banda.
1223
+ const brief = loadBrief(taskId);
1224
+ if (!brief) {
1225
+ rechazar(409, { error: 'manda antes el encargo a POST /brief/' + taskId });
1226
+ return;
1227
+ }
1228
+ const anunciados = parseAttachmentsManifest(brief);
1229
+ if (anunciados.length === 0) {
1230
+ rechazar(409, { error: 'ese encargo no anuncia ningún adjunto' });
1231
+ return;
1232
+ }
1233
+
1234
+ const cred = credencialesDe(req, url);
1235
+ if (!cred.address || !cred.signature) {
1236
+ rechazar(400, { error: 'faltan address y signature (cabeceras x-panal-address / x-panal-signature)' });
1237
+ return;
1238
+ }
1239
+
1240
+ const task = await leerTarea(taskId);
1241
+ if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
1242
+ rechazar(403, { error: 'esa tarea no es de este agente' });
1243
+ return;
1244
+ }
1245
+ if (task.status !== TaskStatus.Open) {
1246
+ rechazar(409, { error: `la tarea está ${TaskStatus[task.status]}` });
1247
+ return;
1248
+ }
1249
+ if (cred.address.toLowerCase() !== task.client.toLowerCase()) {
1250
+ rechazar(403, { error: 'solo el cliente de la tarea puede subirle adjuntos' });
1251
+ return;
1252
+ }
1253
+ if (!(await signedBy(briefSignMessage(taskId), cred.signature, task.client))) {
1254
+ rechazar(401, { error: 'la firma no es del cliente de esta tarea' });
1255
+ return;
1256
+ }
1257
+
1258
+ // Nada puede pesar más que el mayor de los adjuntos anunciados: el
1259
+ // tamaño va DENTRO del manifiesto, o sea dentro de lo que la cadena
1260
+ // cubre. Se mira antes de leer para no tragarse los bytes de nadie.
1261
+ const tope = Math.min(MAX_FILE_BYTES, Math.max(...anunciados.map((f) => f.size)));
1262
+ const declarado = Number(req.headers['content-length'] ?? 0);
1263
+ if (declarado > tope) {
1264
+ rechazar(413, { error: `ese archivo son ${declarado} bytes y el mayor que anunciaste mide ${tope}` });
1265
+ return;
1266
+ }
1267
+
1268
+ let bytes: Buffer;
1269
+ try {
1270
+ bytes = await readBodyBytes(req, tope);
1271
+ } catch (err) {
1272
+ json(res, 413, { error: err instanceof Error ? err.message : 'cuerpo demasiado grande' });
1273
+ return;
1274
+ }
1275
+
1276
+ // La guarda. Se busca por hash, así que el nombre que venga en la
1277
+ // cabecera no decide nada: sólo desempata si el mismo archivo se
1278
+ // adjuntó dos veces.
1279
+ // El nombre viene percent-encoded: una cabecera HTTP no admite
1280
+ // caracteres fuera de latin-1, y «recibo ñ.png» es un nombre normal.
1281
+ let nombre: string | undefined;
1282
+ const cabecera = req.headers['x-panal-filename'];
1283
+ if (typeof cabecera === 'string') {
1284
+ try {
1285
+ nombre = decodeURIComponent(cabecera);
1286
+ } catch {
1287
+ nombre = cabecera;
1288
+ }
1289
+ }
1290
+ const anunciado = matchAttachment(anunciados, bytes, nombre);
1291
+ if (!anunciado) {
1292
+ json(res, 403, {
1293
+ error: 'esos bytes no son ninguno de los adjuntos que anuncia el encargo',
1294
+ esperados: anunciados.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
1295
+ });
1296
+ return;
1297
+ }
1298
+
1299
+ guardarAdjunto(taskId, anunciado.name, bytes);
1300
+ const { faltan: pendientes } = repasarAdjuntos(taskId, brief);
1301
+ console.log(
1302
+ `[panal] #${taskId} adjunto "${anunciado.name}" recibido (${bytes.byteLength} bytes) · faltan ${pendientes.length}`,
1303
+ );
1304
+
1305
+ json(res, 202, {
1306
+ ok: true,
1307
+ guardado: anunciado.name,
1308
+ faltanAdjuntos: pendientes.map((f) => ({ name: f.name, size: f.size, hash: f.hash })),
1309
+ });
1310
+
1311
+ // Con el último adjunto ya se puede trabajar. El encargo estaba en
1312
+ // espera desde que llegó; esto es lo que lo suelta.
1313
+ if (pendientes.length === 0) {
1314
+ const sobreGuardado = sobrePendiente.get(taskId.toString()) ?? null;
1315
+ sobrePendiente.delete(taskId.toString());
1316
+ void work(taskId, brief, sobreGuardado);
1317
+ }
1318
+ return;
1319
+ }
1320
+
1015
1321
  // ---- El cliente recoge su resultado -------------------------------------
1016
1322
  const match = /^\/result\/(\d+)$/.exec(url.pathname);
1017
1323
  if (match && req.method === 'GET') {