create-panal-agent 0.11.0 → 0.13.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.
@@ -11,18 +11,18 @@
11
11
  * anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
12
12
  */
13
13
 
14
- import { esImagenSoportada, llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
15
- import { textoAPdf } from './pdf.js';
14
+ import { llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
15
+ import { leerAdjuntos, type AdjuntoRecibido, type AdjuntosLeidos } from './adjuntos.js';
16
+ import { comoArchivo, formatoPedido } from './salida.js';
17
+ import { historialComoTexto, type Turno } from './memoria.js';
16
18
 
17
- /** Un archivo que el CLIENTE te mandó con el encargo. */
18
- export interface AdjuntoRecibido {
19
- /** Nombre ya limpio, tal y como lo anunció el encargo. */
20
- name: string;
21
- /** Tipo MIME, si lo declaró: `image/png`, `application/pdf`… */
22
- mime?: string;
23
- /** El contenido. Su hash ya se comprobó contra lo que la cadena cubre. */
24
- bytes: Uint8Array;
25
- }
19
+ /**
20
+ * Un archivo que el CLIENTE te mandó con el encargo.
21
+ *
22
+ * El tipo vive en `adjuntos.js`, que es quien sabe abrirlos; se reexporta aquí
23
+ * para tenerlo a mano sin salir de este archivo.
24
+ */
25
+ export type { AdjuntoRecibido };
26
26
 
27
27
  export interface TaskContext {
28
28
  /**
@@ -44,8 +44,11 @@ export interface TaskContext {
44
44
  * pagara, así que estos bytes son exactamente los que la cadena cubre — si
45
45
  * alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
46
46
  *
47
- * Las imágenes se le pasan solas al modelo, si el tuyo sabe mirarlas. El
48
- * resto lo tienes aquí en crudo para hacer lo que sepas hacer con ello.
47
+ * El motor los ABRE por ti antes de llamarte: las imágenes se le enseñan al
48
+ * modelo, y de un PDF, un Word, un Excel o una carpeta comprimida se saca el
49
+ * texto y entra en el encargo. Lo que no se pueda abrir se le NOMBRA al
50
+ * modelo en vez de callarlo. Aquí los tienes en crudo por si tu agente sabe
51
+ * hacer algo más con ellos.
49
52
  *
50
53
  * Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
51
54
  */
@@ -85,6 +88,23 @@ export interface TaskContext {
85
88
  * `consultar` ya lo respeta por su cuenta.
86
89
  */
87
90
  envelope: CallEnvelope | null;
91
+
92
+ /**
93
+ * LO QUE YA SE HABLÓ CON ESTA PERSONA.
94
+ *
95
+ * Sólo en llamadas x402, que son las que forman una conversación. En un
96
+ * encargo del escrow va vacío: un encargo tiene principio y fin, y
97
+ * arrastrarle memoria sería confundir dos cosas distintas.
98
+ *
99
+ * Quién es "esta persona" lo dice el PAGO, no una cabecera: firmó un permiso
100
+ * y el cobro se ejecutó en la cadena, así que nadie puede continuar la
101
+ * conversación de otro sin haber pagado como él.
102
+ *
103
+ * Va acotado por turnos y por caracteres (`MEMORIA_TURNOS`, `MEMORIA_CHARS`)
104
+ * porque entra en el prompt, y el prompt lo pagas TÚ mientras el cliente
105
+ * paga un precio fijo por mensaje.
106
+ */
107
+ historial: Turno[];
88
108
  }
89
109
 
90
110
  /** Un archivo que entregas junto al texto. */
@@ -156,14 +176,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
156
176
  );
157
177
  }
158
178
 
159
- // Lo que el cliente adjuntó y un modelo puede MIRAR. El resto de adjuntos
160
- // sigue en `ctx.adjuntos` para que hagas con ellos lo que sepas hacer.
161
- const imagenes = ctx.adjuntos
162
- .filter((a) => esImagenSoportada(a.mime))
163
- .map((a) => ({ mime: a.mime!, bytes: a.bytes }));
179
+ // Se abre TODO lo que mandó el cliente: imágenes para que las mire el
180
+ // modelo, y el texto de los PDF, Word, Excel y carpetas que vengan dentro.
181
+ const leido = await leerAdjuntos(ctx.adjuntos);
164
182
  if (ctx.adjuntos.length > 0) {
165
183
  console.log(
166
- `[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
184
+ `[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${leido.imagenes.length} para el modelo: ` +
167
185
  ctx.adjuntos.map((a) => a.name).join(', '),
168
186
  );
169
187
  }
@@ -177,11 +195,11 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
177
195
  // que entregues queda anclado en la cadena y ya no se puede rectificar.
178
196
  let queja: string | null = null;
179
197
  for (let intento = 1; intento <= 2; intento++) {
180
- const texto = await pedirAlModelo(brief, cfg, queja, ayuda, imagenes, ctx.adjuntos);
198
+ const texto = await pedirAlModelo(brief, cfg, queja, ayuda, leido, ctx.historial);
181
199
  const problema = revisar(brief, texto);
182
200
  if (!problema) {
183
201
  console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
184
- return conPdfSiLoPidio(brief, texto, ctx);
202
+ return conArchivoSiLoPidio(brief, texto, ctx);
185
203
  }
186
204
  console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
187
205
  // A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
@@ -189,7 +207,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
189
207
  // entregar algo imperfecto y que él decida, que dejarlo sin nada.
190
208
  if (intento === 2) {
191
209
  console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
192
- return conPdfSiLoPidio(brief, texto, ctx);
210
+ return conArchivoSiLoPidio(brief, texto, ctx);
193
211
  }
194
212
  queja = problema;
195
213
  }
@@ -354,11 +372,15 @@ function revisar(brief: string, resultado: string): string | null {
354
372
  * lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
355
373
  * se baja es exactamente el que le entregaste.
356
374
  */
357
- function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
358
- if (!/\bpdf\b/i.test(brief)) return texto;
359
- const pdf = textoAPdf(`Panal - entrega ${etiqueta(ctx)}`, texto);
360
- console.log(`[agente] ${etiqueta(ctx)} PDF de ${pdf.byteLength} bytes adjunto`);
361
- return { text: texto, files: [{ name: 'entrega.pdf', data: pdf, mime: 'application/pdf' }] };
375
+ function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
376
+ const formato = formatoPedido(brief);
377
+ if (!formato) return texto;
378
+ const archivo = comoArchivo(formato, 'entrega', `Panal - entrega ${etiqueta(ctx)}`, texto);
379
+ const bytes = typeof archivo.data === 'string' ? archivo.data.length : archivo.data.byteLength;
380
+ console.log(`[agente] ${etiqueta(ctx)} ${archivo.name} de ${bytes} bytes adjunto`);
381
+ // El TEXTO se sigue entregando: es lo que se ancla en la cadena. El archivo
382
+ // va además, y su hash viaja dentro de la entrega.
383
+ return { text: texto, files: [archivo] };
362
384
  }
363
385
 
364
386
  async function pedirAlModelo(
@@ -366,27 +388,31 @@ async function pedirAlModelo(
366
388
  cfg: LlmConfig,
367
389
  queja: string | null,
368
390
  ayuda: string | null,
369
- imagenes: { mime: string; bytes: Uint8Array }[],
370
- adjuntos: AdjuntoRecibido[],
391
+ leido: AdjuntosLeidos,
392
+ historial: Turno[],
371
393
  ): Promise<string> {
372
394
  // Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
373
395
  // dialectos aceptan varios turnos, pero cada uno los cuenta a su manera, y
374
396
  // lo que aquí importa no es de quién es cada mensaje: es que el modelo no
375
397
  // confunda el material de apoyo con el encargo.
376
- const partes = [brief];
398
+ const partes: string[] = [];
377
399
 
378
- // Los adjuntos que el modelo NO puede mirar. Se nombran para que sepa que
379
- // existen: sin esto contesta como si el cliente no hubiera mandado nada, y
380
- // el cliente ve una respuesta que ignora la mitad de lo que pidió.
381
- const noMirables = adjuntos.filter((a) => !imagenes.some((i) => i.bytes === a.bytes));
382
- if (noMirables.length > 0) {
383
- partes.push(
384
- `[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
385
- .map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
386
- .join(', ')}. El agente los tiene y los procesa aparte.]`,
387
- );
400
+ // Lo hablado antes va PRIMERO y marcado como tal. Sin la etiqueta, el modelo
401
+ // confunde una pregunta vieja con la de ahora y contesta a la equivocada;
402
+ // con ella entiende que es contexto y que lo último es lo que se le pide.
403
+ const antes = historialComoTexto(historial);
404
+ if (antes) {
405
+ partes.push(`Conversación anterior con este cliente, para que sepas de qué habláis:\n\n${antes}`);
388
406
  }
389
407
 
408
+ partes.push(historial.length > 0 ? `Ahora te pide:\n${brief}` : brief);
409
+
410
+ // Lo que se pudo sacar de los adjuntos, ya etiquetado: el texto de un PDF,
411
+ // las filas de un Excel, los archivos de una carpeta. Y lo que NO se pudo
412
+ // abrir, nombrado — callarlo hace que el modelo conteste como si el cliente
413
+ // no hubiera mandado nada.
414
+ if (leido.texto) partes.push(leido.texto);
415
+
390
416
  // Lo que contestó el especialista, si se le preguntó. Va marcado como
391
417
  // material de apoyo y no como parte del encargo: sin esa aclaración el
392
418
  // modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
@@ -436,7 +462,7 @@ async function pedirAlModelo(
436
462
  'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
437
463
  'and should refer to, because the client knows they sent them.',
438
464
  user: partes.join('\n\n'),
439
- ...(imagenes.length > 0 ? { imagenes } : {}),
465
+ ...(leido.imagenes.length > 0 ? { imagenes: leido.imagenes } : {}),
440
466
  },
441
467
  );
442
468
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * La memoria de una conversación.
3
+ *
4
+ * Sin esto, cada llamada x402 es independiente: el cliente pregunta algo, tu
5
+ * agente contesta, y a la siguiente no sabe de qué se hablaba. Eso no es un
6
+ * chat, es un buscador que pasa factura — y quien lo usa lo nota al segundo
7
+ * mensaje, cuando tiene que repetir el contexto entero.
8
+ *
9
+ * QUIÉN HABLA LO DICE EL PAGO. La conversación se guarda por la dirección del
10
+ * pagador, y esa dirección no la afirma nadie: firmó un permiso y el cobro se
11
+ * ejecutó en la cadena. Nadie puede leer ni continuar la conversación de otro
12
+ * sin haber pagado como él, así que no hace falta ninguna autenticación
13
+ * aparte. Es la propiedad más útil de cobrar por llamada.
14
+ *
15
+ * SOLO EN x402, NO EN EL ESCROW. Un encargo del escrow es un trabajo con
16
+ * principio y fin: se paga, se entrega una vez y se aprueba. Arrastrarle
17
+ * memoria sería confundir dos cosas distintas.
18
+ *
19
+ * LO QUE CUESTA, porque conviene tenerlo delante: el historial va dentro del
20
+ * prompt, y el prompt lo pagas TÚ mientras el cliente paga un precio fijo por
21
+ * mensaje. Una conversación larga es cada vez más cara de contestar por lo
22
+ * mismo. De ahí los dos topes de abajo, y de ahí que se puedan bajar.
23
+ */
24
+
25
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ /** Un intercambio ya cerrado: lo que preguntaron y lo que contestaste. */
29
+ export interface Turno {
30
+ pregunta: string;
31
+ respuesta: string;
32
+ /** Epoch en milisegundos. */
33
+ cuando: number;
34
+ }
35
+
36
+ /**
37
+ * Cuántos turnos se recuerdan. `MEMORIA_TURNOS=0` apaga la memoria.
38
+ *
39
+ * Seis es un número corto a propósito: cubre una conversación normal y deja
40
+ * el coste acotado. Súbelo si tu agente necesita hilos largos y te sale a
41
+ * cuenta; bájalo a cero si lo tuyo son preguntas sueltas.
42
+ */
43
+ const TURNOS = (() => {
44
+ const n = Number(process.env.MEMORIA_TURNOS?.trim() || '6');
45
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 6;
46
+ })();
47
+
48
+ /**
49
+ * Tope de caracteres del historial que entra en el prompt.
50
+ *
51
+ * El tope de turnos por sí solo no acota nada: seis turnos pueden ser seis
52
+ * líneas o seis pantallas de código pegado.
53
+ */
54
+ const MAX_CHARS = (() => {
55
+ const n = Number(process.env.MEMORIA_CHARS?.trim() || '4000');
56
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 4000;
57
+ })();
58
+
59
+ /** Cuántos turnos se guardan en disco, que es más de lo que se manda. */
60
+ const GUARDADOS = 60;
61
+
62
+ /**
63
+ * El archivo de una conversación.
64
+ *
65
+ * El nombre sale de la dirección, y aunque venga ya validada como tal se
66
+ * limpia igual: es lo que decide una ruta en disco, y una comprobación de más
67
+ * en un sitio así no cuesta nada.
68
+ */
69
+ function archivo(dataDir: string, quien: string): string {
70
+ const limpio = quien.toLowerCase().replace(/[^a-z0-9x]/g, '');
71
+ return join(dataDir, 'chats', `${limpio}.json`);
72
+ }
73
+
74
+ /** Todo lo que se recuerda de esa persona, de lo más viejo a lo más nuevo. */
75
+ export function leerConversacion(dataDir: string, quien: string): Turno[] {
76
+ try {
77
+ const turnos = JSON.parse(readFileSync(archivo(dataDir, quien), 'utf8')) as Turno[];
78
+ return Array.isArray(turnos) ? turnos : [];
79
+ } catch {
80
+ // Sin archivo, o con un archivo ilegible: se empieza de cero. Una memoria
81
+ // rota no puede impedir contestar a alguien que acaba de pagar.
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Guarda un intercambio.
88
+ *
89
+ * Se llama DESPUÉS de contestar, con las dos mitades: un turno con pregunta y
90
+ * sin respuesta ensucia la memoria de la siguiente vez, y es justo lo que
91
+ * pasaría si se guardara antes de trabajar y el modelo fallara.
92
+ */
93
+ export function recordarTurno(dataDir: string, quien: string, turno: Turno): void {
94
+ if (TURNOS === 0) return;
95
+ try {
96
+ const previos = leerConversacion(dataDir, quien);
97
+ mkdirSync(join(dataDir, 'chats'), { recursive: true });
98
+ writeFileSync(archivo(dataDir, quien), JSON.stringify([...previos, turno].slice(-GUARDADOS)), 'utf8');
99
+ } catch (err) {
100
+ // Perder la memoria no puede tumbar una respuesta ya cobrada.
101
+ console.error(`[memoria] no se pudo guardar el turno: ${err instanceof Error ? err.message : err}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * El historial que se le pasa al modelo, ya acotado.
107
+ *
108
+ * Se recorta desde el final: lo reciente es lo que da contexto, y lo viejo es
109
+ * lo primero que sobra. Devuelve vacío con la memoria apagada.
110
+ */
111
+ export function historialParaElModelo(dataDir: string, quien: string): Turno[] {
112
+ if (TURNOS === 0) return [];
113
+
114
+ const recientes = leerConversacion(dataDir, quien).slice(-TURNOS);
115
+ const salida: Turno[] = [];
116
+ let chars = 0;
117
+
118
+ // Se recorren del más nuevo al más viejo para que, si hay que dejar fuera
119
+ // algo, sea lo antiguo. Luego se devuelve en orden cronológico.
120
+ for (let i = recientes.length - 1; i >= 0; i--) {
121
+ const t = recientes[i]!;
122
+ const coste = t.pregunta.length + t.respuesta.length;
123
+ if (chars + coste > MAX_CHARS) break;
124
+ chars += coste;
125
+ salida.unshift(t);
126
+ }
127
+ return salida;
128
+ }
129
+
130
+ /** Cómo se le cuenta el historial al modelo. Vacío si no hay nada que contar. */
131
+ export function historialComoTexto(turnos: Turno[]): string {
132
+ if (turnos.length === 0) return '';
133
+ return turnos
134
+ .map((t) => `Cliente: ${t.pregunta}\nTú: ${t.respuesta}`)
135
+ .join('\n\n');
136
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Reintentar cuando el modelo dice «ahora no».
3
+ *
4
+ * POR QUÉ EXISTE. Un proveedor devuelve 429 cuando está saturado o cuando has
5
+ * ido demasiado deprisa, y 5xx cuando le pasa algo a él. Ninguna de las dos es
6
+ * culpa tuya y las dos se arreglan solas en segundos. Sin esto, cualquiera de
7
+ * ellas mata la tarea: el cliente ya pagó, el pago se queda bloqueado, y no
8
+ * recupera nada hasta que vence el plazo.
9
+ *
10
+ * Medido contra Moonshot en agosto de 2026: dos llamadas seguidas y la segunda
11
+ * volvió con `engine_overloaded_error`. No es un caso raro que haya que
12
+ * imaginar, es lo normal en una tarde con tráfico.
13
+ *
14
+ * QUÉ **NO** SE REINTENTA, que importa igual: los demás 4xx. Un 401 es una
15
+ * clave mala, un 400 es una petición mal formada y un 404 es un modelo que no
16
+ * existe. Repetirlos gasta tiempo y dinero para llegar a la misma respuesta, y
17
+ * retrasa la única noticia útil —que hay algo mal configurado— hasta que se
18
+ * agotan los intentos.
19
+ *
20
+ * La espera CRECE entre intentos. Volver a preguntar de inmediato a un motor
21
+ * saturado es pedirle el mismo 429: empuja más carga justo cuando menos puede
22
+ * con ella.
23
+ */
24
+
25
+ /** Esperas entre intentos, en milisegundos. Cuatro llamadas como mucho. */
26
+ const ESPERAS = [1_000, 4_000, 10_000];
27
+
28
+ /** Los códigos que se vuelven a intentar. El resto son respuestas firmes. */
29
+ export function esReintentable(status: number): boolean {
30
+ return status === 429 || status >= 500;
31
+ }
32
+
33
+ /**
34
+ * `fetch` que aguanta un proveedor con hipo.
35
+ *
36
+ * Devuelve la última respuesta, reintentable o no: quien llama decide qué
37
+ * hacer con ella, como con un `fetch` normal. Los fallos de RED sí se
38
+ * propagan al agotar los intentos, porque ahí no hay respuesta que devolver.
39
+ */
40
+ export async function fetchModelo(
41
+ url: string,
42
+ init: RequestInit,
43
+ dormir: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
44
+ ): Promise<Response> {
45
+ let ultimoFallo: unknown;
46
+
47
+ for (let intento = 0; intento <= ESPERAS.length; intento++) {
48
+ if (intento > 0) {
49
+ const espera = ESPERAS[intento - 1]!;
50
+ console.warn(`[modelo] reintento ${intento} de ${ESPERAS.length} dentro de ${espera} ms`);
51
+ await dormir(espera);
52
+ }
53
+ try {
54
+ const res = await fetch(url, init);
55
+ if (!esReintentable(res.status) || intento === ESPERAS.length) return res;
56
+ console.warn(`[modelo] el proveedor respondió ${res.status}`);
57
+ } catch (err) {
58
+ // Un corte de red o el timeout del AbortSignal. Se reintenta igual: son
59
+ // tan pasajeros como un 503, y con el pago bloqueado no rendirse al
60
+ // primer tropiezo es lo que separa entregar de no entregar.
61
+ ultimoFallo = err;
62
+ if (intento === ESPERAS.length) throw err;
63
+ console.warn(`[modelo] la llamada falló: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ }
66
+
67
+ throw ultimoFallo ?? new Error('inalcanzable');
68
+ }