create-panal-agent 0.12.0 → 0.13.1

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.12.0",
3
+ "version": "0.13.1",
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",
@@ -30,9 +30,11 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "devDependencies": {
33
- "typescript": "^5.6.0",
33
+ "@types/node": "^22.0.0",
34
34
  "tsx": "^4.19.0",
35
- "@types/node": "^22.0.0"
35
+ "typescript": "^5.6.0",
36
+ "unpdf": "^1.8.1",
37
+ "xlsx": "^0.18.5"
36
38
  },
37
39
  "dependencies": {
38
40
  "viem": "^2.21.0"
@@ -40,6 +42,6 @@
40
42
  "scripts": {
41
43
  "build": "tsc -p tsconfig.json",
42
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "tsx test/i18n.test.ts && tsx test/memoria.test.ts && tsx test/reintento.test.ts && tsx test/scaffold.test.ts"
45
+ "test": "tsx test/i18n.test.ts && tsx test/memoria.test.ts && tsx test/reintento.test.ts && tsx test/zip.test.ts && tsx test/adjuntos.test.ts && tsx test/salida.test.ts && tsx test/vigilante.test.ts && tsx test/scaffold.test.ts"
44
46
  }
45
47
  }
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@panal/sdk": "^0.13.0",
14
+ "unpdf": "^1.8.1",
14
15
  "dotenv": "^17.0.0",
15
16
  "tsx": "^4.19.0",
16
17
  "viem": "^2.21.0"
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Lo que el cliente adjuntó, convertido en algo que un modelo pueda usar.
3
+ *
4
+ * Un encargo puede traer cinco archivos de hasta 25 MB, y llegan verificados:
5
+ * su hash se anunció DENTRO del encargo antes de que se pagara, así que son
6
+ * exactamente los que el escrow cubre. Lo que hace este archivo es la otra
7
+ * mitad: abrirlos.
8
+ *
9
+ * Cada tipo entra por donde puede:
10
+ *
11
+ * imagen (png/jpeg/gif/webp) se le ENSEÑA al modelo, no se describe
12
+ * texto (código, md, csv…) tal cual
13
+ * PDF se le extrae el texto
14
+ * .docx es un ZIP con XML dentro
15
+ * .zip se abre y se leen los que sí se puedan
16
+ * cualquier otra cosa se NOMBRA, y se dice que no se pudo abrir
17
+ *
18
+ * ESE ÚLTIMO CASO NO ES UN DESCUIDO. Callar un archivo que no se pudo abrir
19
+ * hace que el modelo conteste como si el cliente no hubiera mandado nada, y el
20
+ * cliente recibe una respuesta que ignora la mitad de lo que pidió sin decir
21
+ * por qué. Decirlo cuesta una línea y convierte un fallo en una explicación.
22
+ *
23
+ * EL TIPO SE MIRA POR LOS BYTES, no por el nombre ni por el `content-type` que
24
+ * mandó el cliente: los dos los escribe él, y un `.txt` que en realidad es un
25
+ * PDF es un accidente normal, no un ataque.
26
+ */
27
+
28
+ import { esZip, leerZip } from './zip.js';
29
+
30
+ export interface AdjuntoRecibido {
31
+ name: string;
32
+ mime?: string;
33
+ bytes: Uint8Array;
34
+ }
35
+
36
+ /** Una imagen lista para enseñársela al modelo. */
37
+ export interface ImagenAdjunta {
38
+ mime: string;
39
+ bytes: Uint8Array;
40
+ }
41
+
42
+ export interface AdjuntosLeidos {
43
+ /** Ya etiquetado y listo para pegar al encargo. Vacío si no hay nada. */
44
+ texto: string;
45
+ /** Las que el modelo puede mirar de verdad. */
46
+ imagenes: ImagenAdjunta[];
47
+ }
48
+
49
+ /** Tope de caracteres que aporta UN adjunto. El encargo lo paga el agente. */
50
+ const MAX_CHARS_POR_ADJUNTO = 8_000;
51
+ /** Y el de todos juntos, porque cinco archivos al tope son demasiado. */
52
+ const MAX_CHARS_TOTAL = 24_000;
53
+ /** Archivos que se miran dentro de un ZIP. */
54
+ const MAX_DENTRO_DEL_ZIP = 40;
55
+
56
+ const MIMES_IMAGEN: Record<string, string> = {
57
+ png: 'image/png',
58
+ jpeg: 'image/jpeg',
59
+ gif: 'image/gif',
60
+ webp: 'image/webp',
61
+ };
62
+
63
+ /** Qué es esto, mirando los primeros bytes. */
64
+ export function tipoDe(bytes: Uint8Array): 'png' | 'jpeg' | 'gif' | 'webp' | 'pdf' | 'zip' | 'otro' {
65
+ const b = bytes;
66
+ if (b.length < 4) return 'otro';
67
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png';
68
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg';
69
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'gif';
70
+ if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[8] === 0x57 && b[9] === 0x45) return 'webp';
71
+ // El %PDF- puede venir tras unos bytes de basura; el estándar tolera hasta
72
+ // 1024, y hay generadores que se aprovechan.
73
+ const cabeza = new TextDecoder('latin1').decode(b.subarray(0, Math.min(1024, b.length)));
74
+ if (cabeza.includes('%PDF-')) return 'pdf';
75
+ if (esZip(b)) return 'zip';
76
+ return 'otro';
77
+ }
78
+
79
+ /**
80
+ * ¿Esto es texto?
81
+ *
82
+ * Se decodifica en modo estricto, así un binario LANZA en vez de colarse como
83
+ * un reguero de caracteres de reemplazo. Y aun decodificando bien, se rechaza
84
+ * lo que trae bytes de control: hay binarios que pasan por UTF-8 válido y
85
+ * mandárselos a un modelo es gastar el encargo en basura.
86
+ */
87
+ export function comoTexto(bytes: Uint8Array): string | null {
88
+ try {
89
+ const texto = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
90
+ for (const ch of texto) {
91
+ const c = ch.codePointAt(0)!;
92
+ if (c < 0x20 && c !== 0x0a && c !== 0x0d && c !== 0x09) return null;
93
+ }
94
+ return texto;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * El texto de un `.docx`.
102
+ *
103
+ * Un .docx es un ZIP con `word/document.xml` dentro. No hace falta entender
104
+ * Word: cada `</w:p>` cierra un párrafo y cada `<w:t>` envuelve un trozo de
105
+ * texto. Se quitan las etiquetas y quedan las palabras.
106
+ *
107
+ * Los `<w:t>` se conservan PEGADOS entre sí a propósito: Word parte una misma
108
+ * frase en varios cuando cambia algo del formato, y meter espacios entre ellos
109
+ * escribiría «contra tante» donde pone «contratante».
110
+ */
111
+ export function textoDeDocx(bytes: Uint8Array): string | null {
112
+ const doc = leerZip(bytes).find((e) => e.nombre === 'word/document.xml');
113
+ if (!doc) return null;
114
+ const xml = comoTexto(doc.bytes);
115
+ if (!xml) return null;
116
+ return xml
117
+ .replace(/<w:p\b[^>]*\/>/g, '\n')
118
+ .replace(/<\/w:p>/g, '\n')
119
+ .replace(/<w:tab\b[^>]*\/>/g, '\t')
120
+ .replace(/<w:br\b[^>]*\/>/g, '\n')
121
+ .replace(/<[^>]+>/g, '')
122
+ .replace(/&lt;/g, '<')
123
+ .replace(/&gt;/g, '>')
124
+ .replace(/&amp;/g, '&')
125
+ .replace(/&quot;/g, '"')
126
+ .replace(/&apos;/g, "'")
127
+ .replace(/\n{3,}/g, '\n\n')
128
+ .trim();
129
+ }
130
+
131
+ /** `A1` → 0, `B1` → 1, `AA7` → 26. Sirve para no descolocar una fila con huecos. */
132
+ function columnaDe(ref: string): number {
133
+ const letras = /^([A-Z]+)/.exec(ref.toUpperCase())?.[1];
134
+ if (!letras) return 0;
135
+ let n = 0;
136
+ for (const c of letras) n = n * 26 + (c.charCodeAt(0) - 64);
137
+ return n - 1;
138
+ }
139
+
140
+ /** Lo que el XML trae escapado, de vuelta a texto. */
141
+ function desescapar(s: string): string {
142
+ return s
143
+ .replace(/&lt;/g, '<')
144
+ .replace(/&gt;/g, '>')
145
+ .replace(/&quot;/g, '"')
146
+ .replace(/&apos;/g, "'")
147
+ .replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)))
148
+ .replace(/&amp;/g, '&');
149
+ }
150
+
151
+ /**
152
+ * El contenido de un `.xlsx`, como filas de texto.
153
+ *
154
+ * Un Excel es otro ZIP con XML, como el .docx, pero con una vuelta de tuerca:
155
+ * las celdas de texto no suelen guardar el texto. Guardan un ÍNDICE a
156
+ * `sharedStrings.xml`, donde cada cadena aparece una sola vez aunque se repita
157
+ * en mil celdas. Sin resolver esa tabla, una hoja llena de nombres se lee como
158
+ * una lista de números.
159
+ *
160
+ * Y hay cuatro maneras de que una celda tenga texto, según quién escribiera el
161
+ * archivo: `t="s"` (la tabla), `t="str"` (resultado de fórmula), `t="inlineStr"`
162
+ * (el texto ahí mismo) y sin `t` (un número). Se contemplan las cuatro porque
163
+ * Excel, LibreOffice y las librerías no escogen la misma.
164
+ *
165
+ * Las filas salen separadas por tabuladores: es lo que un modelo lee como
166
+ * tabla sin tener que adivinar dónde acaba una celda, y no exige entrecomillar
167
+ * nada.
168
+ */
169
+ export function textoDeXlsx(bytes: Uint8Array): string | null {
170
+ const partes = leerZip(bytes);
171
+ const hojas = partes
172
+ .filter((e) => /^xl\/worksheets\/sheet\d*\.xml$/.test(e.nombre))
173
+ .sort((a, b) => a.nombre.localeCompare(b.nombre));
174
+ if (hojas.length === 0) return null;
175
+
176
+ // La tabla de cadenas compartidas, si la hay.
177
+ const compartidas: string[] = [];
178
+ const tabla = partes.find((e) => e.nombre === 'xl/sharedStrings.xml');
179
+ if (tabla) {
180
+ const xml = comoTexto(tabla.bytes) ?? '';
181
+ for (const si of xml.match(/<si>[\s\S]*?<\/si>/g) ?? []) {
182
+ // Una cadena puede venir partida en varios <t> cuando lleva formato
183
+ // dentro; se pegan sin separador, como en Word.
184
+ compartidas.push(desescapar([...si.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1] ?? '').join('')));
185
+ }
186
+ }
187
+
188
+ const salida: string[] = [];
189
+ for (const hoja of hojas) {
190
+ const xml = comoTexto(hoja.bytes);
191
+ if (!xml) continue;
192
+ const filas: string[] = [];
193
+
194
+ for (const fila of xml.match(/<row[^>]*>[\s\S]*?<\/row>/g) ?? []) {
195
+ const celdas: string[] = [];
196
+ for (const m of fila.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
197
+ const attrs = m[1] ?? '';
198
+ const cuerpo = m[2] ?? '';
199
+ const tipo = /\bt="([^"]+)"/.exec(attrs)?.[1];
200
+ const ref = /\br="([^"]+)"/.exec(attrs)?.[1] ?? '';
201
+
202
+ let valor = '';
203
+ if (tipo === 'inlineStr') {
204
+ valor = desescapar([...cuerpo.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => x[1] ?? '').join(''));
205
+ } else {
206
+ const v = /<v[^>]*>([\s\S]*?)<\/v>/.exec(cuerpo)?.[1] ?? '';
207
+ if (tipo === 's') valor = compartidas[Number(v)] ?? '';
208
+ else if (tipo === 'b') valor = v === '1' ? 'VERDADERO' : 'FALSO';
209
+ else valor = desescapar(v);
210
+ }
211
+
212
+ // Una fila puede saltarse columnas: `A1` y luego `D1`. Colocar cada
213
+ // valor en SU columna es lo que evita que una tabla con huecos salga
214
+ // desplazada y el modelo lea el dato de otra cabecera.
215
+ const col = ref ? columnaDe(ref) : celdas.length;
216
+ while (celdas.length < col) celdas.push('');
217
+ celdas[col] = valor;
218
+ }
219
+ if (celdas.some((c) => c !== '')) filas.push(celdas.join('\t'));
220
+ }
221
+
222
+ if (filas.length > 0) {
223
+ salida.push(hojas.length > 1 ? `[hoja ${salida.length + 1}]\n${filas.join('\n')}` : filas.join('\n'));
224
+ }
225
+ }
226
+
227
+ return salida.length > 0 ? salida.join('\n\n') : null;
228
+ }
229
+
230
+ /**
231
+ * El texto de un PDF.
232
+ *
233
+ * Se apoya en `unpdf`, que es el motor de pdf.js empaquetado. Se hizo así tras
234
+ * medir: un extractor a mano lee bien los PDF que genera este mismo agente y
235
+ * se atraganta con los de Word o Chrome, que son justo los que manda un
236
+ * cliente. Las codificaciones de fuente son el problema, y resolverlas es lo
237
+ * que hace pdf.js.
238
+ *
239
+ * Devuelve null si no sale texto: un PDF escaneado es una imagen dentro de un
240
+ * PDF, y sin OCR no hay nada que leer. Decirlo es mejor que entregar el vacío.
241
+ */
242
+ export async function textoDePdf(bytes: Uint8Array): Promise<string | null> {
243
+ try {
244
+ const { extractText, getDocumentProxy } = await import('unpdf');
245
+ const doc = await getDocumentProxy(bytes);
246
+ const { text } = await extractText(doc, { mergePages: true });
247
+ const limpio = (Array.isArray(text) ? text.join('\n') : text).trim();
248
+ return limpio.length > 0 ? limpio : null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /** Recorta y avisa de que ha recortado, que es lo que evita una cita a medias. */
255
+ function acotar(texto: string, tope: number): string {
256
+ if (texto.length <= tope) return texto;
257
+ return `${texto.slice(0, tope)}\n--- (recortado: son ${texto.length} caracteres) ---`;
258
+ }
259
+
260
+ /**
261
+ * Abre todos los adjuntos de un encargo.
262
+ *
263
+ * Es `async` porque leer un PDF lo es. Los agentes ya trabajaban en `async`,
264
+ * así que lo único que cambia para quien llama es un `await`.
265
+ */
266
+ export async function leerAdjuntos(adjuntos: AdjuntoRecibido[]): Promise<AdjuntosLeidos> {
267
+ const partes: string[] = [];
268
+ const imagenes: ImagenAdjunta[] = [];
269
+ let gastado = 0;
270
+
271
+ const anadir = (cabecera: string, cuerpo: string): void => {
272
+ const queda = MAX_CHARS_TOTAL - gastado;
273
+ if (queda <= 200) return;
274
+ const trozo = acotar(cuerpo, Math.min(MAX_CHARS_POR_ADJUNTO, queda));
275
+ gastado += trozo.length;
276
+ partes.push(`--- ${cabecera} ---\n${trozo}\n--- fin ---`);
277
+ };
278
+
279
+ const noSePudo = (a: AdjuntoRecibido, motivo: string): void => {
280
+ partes.push(
281
+ `[Archivo adjunto "${a.name}"${a.mime ? ` (${a.mime})` : ''}: ${motivo}. ` +
282
+ `Dilo con claridad en la respuesta en vez de ignorarlo.]`,
283
+ );
284
+ };
285
+
286
+ for (const a of adjuntos) {
287
+ const tipo = tipoDe(a.bytes);
288
+
289
+ if (tipo in MIMES_IMAGEN) {
290
+ // La imagen no se describe: se le enseña. El agente decide si su modelo
291
+ // puede mirarla; aquí sólo se separa de lo que es texto.
292
+ imagenes.push({ mime: MIMES_IMAGEN[tipo]!, bytes: a.bytes });
293
+ partes.push(`[Imagen adjunta "${a.name}": va con este mensaje, míralo.]`);
294
+ continue;
295
+ }
296
+
297
+ if (tipo === 'pdf') {
298
+ const texto = await textoDePdf(a.bytes);
299
+ if (texto) anadir(`PDF adjunto: ${a.name}`, texto);
300
+ else noSePudo(a, 'es un PDF del que no se pudo sacar texto (probablemente escaneado)');
301
+ continue;
302
+ }
303
+
304
+ if (tipo === 'zip') {
305
+ // Word y Excel son ZIPs también, así que se prueban antes de tratarlo
306
+ // como una carpeta: leer un .xlsx entrada por entrada devolvería su XML
307
+ // en crudo, que para un modelo es ruido caro.
308
+ const docx = textoDeDocx(a.bytes);
309
+ if (docx) {
310
+ anadir(`Documento adjunto: ${a.name}`, docx);
311
+ continue;
312
+ }
313
+ const xlsx = textoDeXlsx(a.bytes);
314
+ if (xlsx) {
315
+ anadir(`Hoja de cálculo adjunta (columnas separadas por tabulador): ${a.name}`, xlsx);
316
+ continue;
317
+ }
318
+ // Un ZIP normal: una carpeta. Se leen los que sí sean texto y se
319
+ // NOMBRAN los que no, para que el modelo sepa qué había dentro.
320
+ const dentro = leerZip(a.bytes).slice(0, MAX_DENTRO_DEL_ZIP);
321
+ if (dentro.length === 0) {
322
+ noSePudo(a, 'es un archivo comprimido que no se pudo abrir');
323
+ continue;
324
+ }
325
+ const ilegibles: string[] = [];
326
+ for (const e of dentro) {
327
+ const texto = comoTexto(e.bytes);
328
+ if (texto === null) ilegibles.push(e.nombre);
329
+ else anadir(`${a.name} → ${e.nombre}`, texto);
330
+ }
331
+ if (ilegibles.length > 0) {
332
+ partes.push(`[Dentro de "${a.name}" hay ${ilegibles.length} archivo(s) que no son texto: ${ilegibles.join(', ')}.]`);
333
+ }
334
+ continue;
335
+ }
336
+
337
+ const texto = comoTexto(a.bytes);
338
+ if (texto !== null) anadir(`Archivo adjunto: ${a.name}`, texto);
339
+ else noSePudo(a, 'no es texto y este agente no puede abrirlo');
340
+ }
341
+
342
+ return { texto: partes.join('\n\n'), imagenes };
343
+ }
344
+
345
+ /**
346
+ * La imagen en el formato que espera `/chat/completions`.
347
+ *
348
+ * Vive aquí y no en cada agente porque los cuatro la necesitan igual, y una
349
+ * data-url mal montada falla con un error del proveedor que no dice nada.
350
+ */
351
+ export function parteDeImagen(img: ImagenAdjunta): {
352
+ type: 'image_url';
353
+ image_url: { url: string };
354
+ } {
355
+ return {
356
+ type: 'image_url',
357
+ image_url: { url: `data:${img.mime};base64,${Buffer.from(img.bytes).toString('base64')}` },
358
+ };
359
+ }
@@ -11,19 +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';
16
17
  import { historialComoTexto, type Turno } from './memoria.js';
17
18
 
18
- /** Un archivo que el CLIENTE te mandó con el encargo. */
19
- export interface AdjuntoRecibido {
20
- /** Nombre ya limpio, tal y como lo anunció el encargo. */
21
- name: string;
22
- /** Tipo MIME, si lo declaró: `image/png`, `application/pdf`… */
23
- mime?: string;
24
- /** El contenido. Su hash ya se comprobó contra lo que la cadena cubre. */
25
- bytes: Uint8Array;
26
- }
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 };
27
26
 
28
27
  export interface TaskContext {
29
28
  /**
@@ -45,8 +44,11 @@ export interface TaskContext {
45
44
  * pagara, así que estos bytes son exactamente los que la cadena cubre — si
46
45
  * alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
47
46
  *
48
- * Las imágenes se le pasan solas al modelo, si el tuyo sabe mirarlas. El
49
- * 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.
50
52
  *
51
53
  * Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
52
54
  */
@@ -174,14 +176,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
174
176
  );
175
177
  }
176
178
 
177
- // Lo que el cliente adjuntó y un modelo puede MIRAR. El resto de adjuntos
178
- // sigue en `ctx.adjuntos` para que hagas con ellos lo que sepas hacer.
179
- const imagenes = ctx.adjuntos
180
- .filter((a) => esImagenSoportada(a.mime))
181
- .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);
182
182
  if (ctx.adjuntos.length > 0) {
183
183
  console.log(
184
- `[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: ` +
185
185
  ctx.adjuntos.map((a) => a.name).join(', '),
186
186
  );
187
187
  }
@@ -195,11 +195,11 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
195
195
  // que entregues queda anclado en la cadena y ya no se puede rectificar.
196
196
  let queja: string | null = null;
197
197
  for (let intento = 1; intento <= 2; intento++) {
198
- const texto = await pedirAlModelo(brief, cfg, queja, ayuda, imagenes, ctx.adjuntos, ctx.historial);
198
+ const texto = await pedirAlModelo(brief, cfg, queja, ayuda, leido, ctx.historial);
199
199
  const problema = revisar(brief, texto);
200
200
  if (!problema) {
201
201
  console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
202
- return conPdfSiLoPidio(brief, texto, ctx);
202
+ return conArchivoSiLoPidio(brief, texto, ctx);
203
203
  }
204
204
  console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
205
205
  // A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
@@ -207,7 +207,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
207
207
  // entregar algo imperfecto y que él decida, que dejarlo sin nada.
208
208
  if (intento === 2) {
209
209
  console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
210
- return conPdfSiLoPidio(brief, texto, ctx);
210
+ return conArchivoSiLoPidio(brief, texto, ctx);
211
211
  }
212
212
  queja = problema;
213
213
  }
@@ -372,11 +372,15 @@ function revisar(brief: string, resultado: string): string | null {
372
372
  * lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
373
373
  * se baja es exactamente el que le entregaste.
374
374
  */
375
- function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
376
- if (!/\bpdf\b/i.test(brief)) return texto;
377
- const pdf = textoAPdf(`Panal - entrega ${etiqueta(ctx)}`, texto);
378
- console.log(`[agente] ${etiqueta(ctx)} PDF de ${pdf.byteLength} bytes adjunto`);
379
- 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] };
380
384
  }
381
385
 
382
386
  async function pedirAlModelo(
@@ -384,8 +388,7 @@ async function pedirAlModelo(
384
388
  cfg: LlmConfig,
385
389
  queja: string | null,
386
390
  ayuda: string | null,
387
- imagenes: { mime: string; bytes: Uint8Array }[],
388
- adjuntos: AdjuntoRecibido[],
391
+ leido: AdjuntosLeidos,
389
392
  historial: Turno[],
390
393
  ): Promise<string> {
391
394
  // Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
@@ -404,17 +407,11 @@ async function pedirAlModelo(
404
407
 
405
408
  partes.push(historial.length > 0 ? `Ahora te pide:\n${brief}` : brief);
406
409
 
407
- // Los adjuntos que el modelo NO puede mirar. Se nombran para que sepa que
408
- // existen: sin esto contesta como si el cliente no hubiera mandado nada, y
409
- // el cliente ve una respuesta que ignora la mitad de lo que pidió.
410
- const noMirables = adjuntos.filter((a) => !imagenes.some((i) => i.bytes === a.bytes));
411
- if (noMirables.length > 0) {
412
- partes.push(
413
- `[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
414
- .map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
415
- .join(', ')}. El agente los tiene y los procesa aparte.]`,
416
- );
417
- }
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);
418
415
 
419
416
  // Lo que contestó el especialista, si se le preguntó. Va marcado como
420
417
  // material de apoyo y no como parte del encargo: sin esa aclaración el
@@ -465,7 +462,7 @@ async function pedirAlModelo(
465
462
  'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
466
463
  'and should refer to, because the client knows they sent them.',
467
464
  user: partes.join('\n\n'),
468
- ...(imagenes.length > 0 ? { imagenes } : {}),
465
+ ...(leido.imagenes.length > 0 ? { imagenes: leido.imagenes } : {}),
469
466
  },
470
467
  );
471
468
  }