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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Devolverle al cliente el archivo que pidió.
3
+ *
4
+ * Un agente entrega TEXTO: es lo que se le enseña al cliente y lo que se ancla
5
+ * en la cadena. Pero mucha gente no quiere texto en una caja, quiere un archivo
6
+ * que abrir, reenviar o imprimir. Esto convierte lo uno en lo otro.
7
+ *
8
+ * QUÉ SE ENTREGA SIGUE SIENDO EL TEXTO. El archivo va ADEMÁS, nunca en lugar
9
+ * de él: su hash se cuela en la entrega y acaba en la cadena, así que el
10
+ * cliente puede demostrar que el archivo que se baja es exactamente el que se
11
+ * le entregó. Sustituir el texto por el archivo rompería eso.
12
+ *
13
+ * Y NO SE ADJUNTA SI NO LO PIDIÓ. A varios de estos agentes los llama otro
14
+ * programa que va a leer la respuesta; colgarle un PDF que nadie va a abrir es
15
+ * peso y confusión.
16
+ */
17
+
18
+ import { textoAPdf } from './pdf.js';
19
+ import { escribirZip } from './zip.js';
20
+
21
+ export type Formato = 'pdf' | 'docx' | 'xlsx' | 'md' | 'txt' | 'csv' | 'json';
22
+
23
+ export interface ArchivoDeSalida {
24
+ name: string;
25
+ data: Uint8Array | string;
26
+ mime?: string;
27
+ }
28
+
29
+ /**
30
+ * Qué formato pidió, si es que pidió alguno.
31
+ *
32
+ * Se mira el ENCARGO, no la respuesta: es donde la persona lo dice. Y se busca
33
+ * en varios idiomas, porque el mercado no es sólo hispanohablante — un encargo
34
+ * en inglés que pide «as a Word document» tiene que salir en Word.
35
+ *
36
+ * Devuelve `null` cuando no pide nada, que es el caso normal.
37
+ */
38
+ export function formatoPedido(brief: string): Formato | null {
39
+ const t = brief.toLowerCase();
40
+ const mencion: { formato: Formato; en: number }[] = [];
41
+
42
+ for (const [formato, patron] of PATRONES) {
43
+ for (const m of t.matchAll(patron)) mencion.push({ formato, en: m.index });
44
+ }
45
+ if (mencion.length === 0) return null;
46
+
47
+ // Las que hablan del archivo que ENTRÓ no cuentan. Sin esto, «lee el PDF
48
+ // adjunto y devuélvemelo en Word» entregaba un PDF: el primer formato que
49
+ // aparecía era el de la entrada. Pasó en una prueba de punta a punta, que es
50
+ // donde se ve y no en una frase inventada.
51
+ const deSalida = mencion.filter((x) => !esDeEntrada(t, x.en));
52
+ if (deSalida.length === 0) return null;
53
+
54
+ // Si alguna viene precedida de un verbo de entrega, ésa es la buena.
55
+ const pedida = deSalida.find((x) => ENTREGA.test(t.slice(Math.max(0, x.en - 40), x.en)));
56
+ if (pedida) return pedida.formato;
57
+
58
+ // Y si no, la ÚLTIMA: el formato de salida se suele decir al final.
59
+ return deSalida[deSalida.length - 1]!.formato;
60
+ }
61
+
62
+ /** Cómo se nombra cada formato, en los idiomas del mercado. */
63
+ const PATRONES: [Formato, RegExp][] = [
64
+ ['pdf', /\bpdfs?\b/g],
65
+ ['docx', /\bdocx?\b|\bword\b/g],
66
+ // «hoja de cálculo» y «spreadsheet» van a Excel, no a CSV: quien lo pide así
67
+ // quiere abrirlo y sumar, no un archivo de texto con comas.
68
+ ['xlsx', /\bxlsx?\b|\bexcel\b|hoja de c[aá]lculo|\bspreadsheet\b/g],
69
+ ['csv', /\bcsvs?\b/g],
70
+ ['json', /\bjson\b/g],
71
+ ['md', /\bmarkdown\b|\bmd\b/g],
72
+ ['txt', /\btxt\b|texto plano|plain text|archivo de texto|text file/g],
73
+ ];
74
+
75
+ /** Que se lo den a uno: lo que distingue pedir un formato de nombrarlo. */
76
+ const ENTREGA =
77
+ /\b(devu[eé]lve|dame|d[aá]melo|entr[eé]ga|env[ií]a|quiero|genera|crea|exporta|conviert|p[aá]sa|as an?|in|into|return|output|format[oe]?|como)\b[^.]{0,30}$/;
78
+
79
+ /** Y lo que delata que se habla del archivo que MANDÓ el cliente. */
80
+ const ENTRADA = /\b(adjunt\w*|attach\w*|subid\w*|uploaded|este|esta|el|la|mi|my|the)\b/;
81
+
82
+ function esDeEntrada(t: string, en: number): boolean {
83
+ const antes = t.slice(Math.max(0, en - 18), en);
84
+ const despues = t.slice(en, en + 30);
85
+ // «el PDF adjunto», «the attached pdf», «mi word»: se habla de lo que entró.
86
+ return /\badjunt|attach|\bsub[ií]|uploaded|que te (mand|pas|envi)/.test(despues) || (ENTRADA.test(antes) && /\badjunt|attach/.test(despues));
87
+ }
88
+
89
+ /** La extensión y el tipo de cada formato. */
90
+ const TIPOS: Record<Formato, { ext: string; mime: string }> = {
91
+ pdf: { ext: 'pdf', mime: 'application/pdf' },
92
+ docx: {
93
+ ext: 'docx',
94
+ mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
95
+ },
96
+ xlsx: {
97
+ ext: 'xlsx',
98
+ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
99
+ },
100
+ md: { ext: 'md', mime: 'text/markdown; charset=utf-8' },
101
+ txt: { ext: 'txt', mime: 'text/plain; charset=utf-8' },
102
+ csv: { ext: 'csv', mime: 'text/csv; charset=utf-8' },
103
+ json: { ext: 'json', mime: 'application/json; charset=utf-8' },
104
+ };
105
+
106
+ /**
107
+ * Lo que XML no admite tal cual.
108
+ *
109
+ * Los caracteres de control se quitan además de escapar: uno solo hace que
110
+ * Word se niegue a abrir el archivo ENTERO, sin decir cuál era.
111
+ */
112
+ function escaparXml(s: string): string {
113
+ return s
114
+ .replace(/&/g, '&amp;')
115
+ .replace(/</g, '&lt;')
116
+ .replace(/>/g, '&gt;')
117
+ .replace(/"/g, '&quot;')
118
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
119
+ }
120
+
121
+ /**
122
+ * Un `.docx` de verdad, con lo mínimo que Word exige para abrirlo.
123
+ *
124
+ * Un .docx es un ZIP con tres archivos dentro. No hace falta ninguna librería:
125
+ * cada línea del texto es un `<w:p>` y ya está.
126
+ */
127
+ export function textoADocx(titulo: string, texto: string): Uint8Array {
128
+ const parrafo = (linea: string, negrita = false): string =>
129
+ `<w:p><w:r>${negrita ? '<w:rPr><w:b/></w:rPr>' : ''}` +
130
+ `<w:t xml:space="preserve">${escaparXml(linea)}</w:t></w:r></w:p>`;
131
+
132
+ const cuerpo = [parrafo(titulo, true), ...texto.split(/\r?\n/).map((l) => parrafo(l))].join('');
133
+
134
+ const documento =
135
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
136
+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
137
+ `<w:body>${cuerpo}</w:body></w:document>`;
138
+
139
+ const tipos =
140
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
141
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
142
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
143
+ '<Default Extension="xml" ContentType="application/xml"/>' +
144
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
145
+ '</Types>';
146
+
147
+ const rels =
148
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
149
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
150
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
151
+ '</Relationships>';
152
+
153
+ const b = (s: string): Uint8Array => new TextEncoder().encode(s);
154
+ return escribirZip([
155
+ { nombre: '[Content_Types].xml', bytes: b(tipos) },
156
+ { nombre: '_rels/.rels', bytes: b(rels) },
157
+ { nombre: 'word/document.xml', bytes: b(documento) },
158
+ ]);
159
+ }
160
+
161
+ /**
162
+ * Cómo está separada una tabla en texto.
163
+ *
164
+ * Se decide mirando TODAS las líneas y no la primera: una tabla cuya cabecera
165
+ * lleva una coma en un título —«Ventas, por región»— haría creer que el
166
+ * separador es la coma cuando en realidad es el tabulador.
167
+ */
168
+ function separadorDe(lineas: string[]): '\t' | ',' | null {
169
+ const conTab = lineas.filter((l) => l.includes('\t')).length;
170
+ if (conTab >= lineas.length / 2) return '\t';
171
+ const conComa = lineas.filter((l) => l.includes(',')).length;
172
+ if (conComa >= lineas.length / 2) return ',';
173
+ return null;
174
+ }
175
+
176
+ /** Un CSV puede traer campos entrecomillados con comas dentro. */
177
+ function partirCsv(linea: string): string[] {
178
+ const campos: string[] = [];
179
+ let actual = '';
180
+ let dentro = false;
181
+ for (let i = 0; i < linea.length; i++) {
182
+ const c = linea[i]!;
183
+ if (c === '"') {
184
+ if (dentro && linea[i + 1] === '"') {
185
+ actual += '"';
186
+ i++;
187
+ } else dentro = !dentro;
188
+ } else if (c === ',' && !dentro) {
189
+ campos.push(actual);
190
+ actual = '';
191
+ } else actual += c;
192
+ }
193
+ campos.push(actual);
194
+ return campos;
195
+ }
196
+
197
+ /** `0` → A, `26` → AA. */
198
+ function letraDe(col: number): string {
199
+ let s = '';
200
+ let n = col + 1;
201
+ while (n > 0) {
202
+ const r = (n - 1) % 26;
203
+ s = String.fromCharCode(65 + r) + s;
204
+ n = Math.floor((n - 1) / 26);
205
+ }
206
+ return s;
207
+ }
208
+
209
+ /**
210
+ * Un `.xlsx` con lo mínimo que Excel exige.
211
+ *
212
+ * Los números se escriben COMO NÚMEROS y no como texto. Es la diferencia entre
213
+ * una hoja con la que se puede sumar y una en la que cada celda lleva el
214
+ * triangulito verde de «esto parece un número guardado como texto» — que es
215
+ * justo lo que va a hacer quien pide un Excel: sumar.
216
+ *
217
+ * Las cadenas van en línea (`inlineStr`) en vez de en una tabla compartida:
218
+ * ocupa algo más y ahorra una parte entera del archivo, y aquí el tamaño no es
219
+ * el problema.
220
+ */
221
+ export function textoAXlsx(titulo: string, texto: string): Uint8Array {
222
+ const lineas = texto.split(/\r?\n/).filter((l, i, a) => l !== '' || i < a.length - 1);
223
+ const sep = separadorDe(lineas);
224
+ const filas = lineas.map((l) => (sep === ',' ? partirCsv(l) : sep === '\t' ? l.split('\t') : [l]));
225
+
226
+ const celdas = (fila: string[], nFila: number): string =>
227
+ fila
228
+ .map((valor, col) => {
229
+ const ref = `${letraDe(col)}${nFila}`;
230
+ if (valor === '') return '';
231
+ // Un número es un número; todo lo demás, texto.
232
+ return /^-?\d+([.,]\d+)?$/.test(valor.trim())
233
+ ? `<c r="${ref}"><v>${valor.trim().replace(',', '.')}</v></c>`
234
+ : `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escaparXml(valor)}</t></is></c>`;
235
+ })
236
+ .join('');
237
+
238
+ const sheet =
239
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
240
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>' +
241
+ filas.map((f, i) => `<row r="${i + 1}">${celdas(f, i + 1)}</row>`).join('') +
242
+ '</sheetData></worksheet>';
243
+
244
+ const workbook =
245
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
246
+ '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' +
247
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
248
+ `<sheets><sheet name="${escaparXml(titulo).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
249
+
250
+ const workbookRels =
251
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
252
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
253
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
254
+ '</Relationships>';
255
+
256
+ const tipos =
257
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
258
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
259
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
260
+ '<Default Extension="xml" ContentType="application/xml"/>' +
261
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
262
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
263
+ '</Types>';
264
+
265
+ const rels =
266
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
267
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
268
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
269
+ '</Relationships>';
270
+
271
+ const b = (s: string): Uint8Array => new TextEncoder().encode(s);
272
+ return escribirZip([
273
+ { nombre: '[Content_Types].xml', bytes: b(tipos) },
274
+ { nombre: '_rels/.rels', bytes: b(rels) },
275
+ { nombre: 'xl/workbook.xml', bytes: b(workbook) },
276
+ { nombre: 'xl/_rels/workbook.xml.rels', bytes: b(workbookRels) },
277
+ { nombre: 'xl/worksheets/sheet1.xml', bytes: b(sheet) },
278
+ ]);
279
+ }
280
+
281
+ /**
282
+ * Una tabla, si el texto entregado la lleva dentro.
283
+ *
284
+ * Nace de un resultado real y malo: se le pidió a un agente una hoja de
285
+ * cálculo, entregó su JSON de siempre —correcto— y el Excel salió con UNA
286
+ * columna de frases, porque el texto no traía ni comas ni tabuladores. Válido
287
+ * y sin ningún valor: quien pide un Excel quiere columnas para sumarlas.
288
+ *
289
+ * Así que antes de montar un xlsx o un csv se mira si lo entregado es JSON con
290
+ * una lista de objetos planos. Si lo es, sus claves son la cabecera. Si no, se
291
+ * sigue como antes.
292
+ */
293
+ export function comoTabla(texto: string): string | null {
294
+ let dato: unknown;
295
+ try {
296
+ dato = JSON.parse(texto);
297
+ } catch {
298
+ return null;
299
+ }
300
+
301
+ // La lista puede ser la raíz, o estar dentro bajo cualquier nombre —los
302
+ // agentes la llaman `hallazgos`, `entries`, `puertos`…
303
+ const lista = Array.isArray(dato)
304
+ ? dato
305
+ : dato && typeof dato === 'object'
306
+ ? Object.values(dato as Record<string, unknown>).find(
307
+ (v): v is unknown[] => Array.isArray(v) && v.length > 0,
308
+ )
309
+ : undefined;
310
+ if (!lista || lista.length === 0) return null;
311
+
312
+ const filas = lista.filter(
313
+ (x): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x),
314
+ );
315
+ if (filas.length !== lista.length) return null;
316
+
317
+ // La cabecera es la unión de las claves, en el orden en que aparecen: una
318
+ // fila a la que le falte un campo no puede descolocar a las demás.
319
+ const columnas: string[] = [];
320
+ for (const f of filas) for (const k of Object.keys(f)) if (!columnas.includes(k)) columnas.push(k);
321
+ if (columnas.length === 0) return null;
322
+
323
+ const celda = (v: unknown): string => {
324
+ if (v === null || v === undefined) return '';
325
+ // Un objeto anidado no cabe en una celda; se pone su JSON antes que
326
+ // «[object Object]», que no le sirve a nadie.
327
+ if (typeof v === 'object') return JSON.stringify(v);
328
+ return String(v).replace(/[\t\r\n]+/g, ' ');
329
+ };
330
+
331
+ return [
332
+ columnas.map((c) => c.replace(/[_-]+/g, ' ')).join('\t'),
333
+ ...filas.map((f) => columnas.map((c) => celda(f[c])).join('\t')),
334
+ ].join('\n');
335
+ }
336
+
337
+ /**
338
+ * El archivo listo para adjuntar a la entrega.
339
+ *
340
+ * `paraLeer` es la versión legible del contenido, y existe por un caso real:
341
+ * hay agentes cuyo texto entregado es JSON —bueno para una máquina, ilegible
342
+ * dentro de un PDF—, y ahí se les pasa aparte lo que debe ver una persona. Si
343
+ * no se da, se usa el texto tal cual.
344
+ */
345
+ export function comoArchivo(
346
+ formato: Formato,
347
+ nombreBase: string,
348
+ titulo: string,
349
+ texto: string,
350
+ paraLeer?: string,
351
+ ): ArchivoDeSalida {
352
+ const { ext, mime } = TIPOS[formato];
353
+ const name = `${nombreBase}.${ext}`;
354
+ // Para un Excel o un CSV se busca primero una TABLA dentro de lo entregado:
355
+ // la versión en prosa daría una sola columna de frases, que es un archivo
356
+ // válido y sin ningún valor para quien lo pidió para sumar.
357
+ const tabla = formato === 'xlsx' || formato === 'csv' ? comoTabla(texto) : null;
358
+ const legible = tabla ?? paraLeer ?? texto;
359
+
360
+ switch (formato) {
361
+ case 'pdf':
362
+ return { name, data: textoAPdf(titulo, legible), mime };
363
+ case 'docx':
364
+ return { name, data: textoADocx(titulo, legible), mime };
365
+ case 'xlsx':
366
+ return { name, data: textoAXlsx(titulo, legible), mime };
367
+ // El markdown lleva el título como encabezado, porque es lo que un `.md`
368
+ // hace. Los demás van tal cual: un CSV con un `#` delante deja de ser CSV.
369
+ case 'md':
370
+ return { name, data: `# ${titulo}\n\n${legible}\n`, mime };
371
+ case 'csv':
372
+ // Una tabla en tabuladores se convierte a comas; si no había tabla, el
373
+ // texto va tal cual, que es lo que ya hacía.
374
+ return { name, data: tabla ? aCsv(tabla) : texto, mime };
375
+ default:
376
+ return { name, data: texto, mime };
377
+ }
378
+ }
379
+
380
+ /** Tabuladores a comas, entrecomillando sólo lo que lo necesita. */
381
+ function aCsv(tabla: string): string {
382
+ return tabla
383
+ .split('\n')
384
+ .map((fila) =>
385
+ fila
386
+ .split('\t')
387
+ .map((c) => (/[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c))
388
+ .join(','),
389
+ )
390
+ .join('\n');
391
+ }
@@ -53,6 +53,7 @@ import type { Address } from 'viem';
53
53
  import { handleTask } from './agent.js';
54
54
  import type { AdjuntoRecibido, TaskContext, TaskFile, TaskResult } from './agent.js';
55
55
  import { arrancarVigilante } from './vigilante.js';
56
+ import { historialParaElModelo, recordarTurno, type Turno } from './memoria.js';
56
57
 
57
58
  const PORT = Number(process.env.PORT ?? 8787);
58
59
  const DATA_DIR = process.env.DATA_DIR ?? './data';
@@ -503,6 +504,7 @@ function contexto(
503
504
  amount: bigint;
504
505
  deadline: bigint;
505
506
  adjuntos: AdjuntoRecibido[];
507
+ historial: Turno[];
506
508
  },
507
509
  sobre: CallEnvelope | null,
508
510
  ): TaskContext {
@@ -568,6 +570,9 @@ async function work(taskId: bigint, brief: string, sobre: CallEnvelope | null):
568
570
  amount: task.amount,
569
571
  deadline: task.deadline,
570
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: [],
571
576
  },
572
577
  sobre,
573
578
  ),
@@ -1045,6 +1050,11 @@ const server = createServer((req, res) => {
1045
1050
  // Una llamada x402 es una pregunta y una respuesta: no hay tarea
1046
1051
  // donde anclar un adjunto, así que tampoco hay adjuntos.
1047
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),
1048
1058
  },
1049
1059
  sobre,
1050
1060
  ),
@@ -1062,6 +1072,12 @@ const server = createServer((req, res) => {
1062
1072
  }
1063
1073
  res.setHeader('x-payment-tx', cobro.txHash);
1064
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() });
1065
1081
  } catch (err) {
1066
1082
  console.error(`[x402] cobrado pero falló al responder: ${err instanceof Error ? err.message : err}`);
1067
1083
  json(res, 502, {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Leer un ZIP sin dependencias.
3
+ *
4
+ * Hace falta para DOS cosas que parecen distintas y son la misma: una carpeta
5
+ * que el cliente comprimió, y un `.docx` —que es un ZIP con XML dentro—. Con
6
+ * un lector se resuelven las dos.
7
+ *
8
+ * Node trae `zlib`, que es el 90% del trabajo. Lo que falta es entender la
9
+ * estructura del archivo, y es poca cosa: se lee el directorio central del
10
+ * final, no las cabeceras locales, porque el directorio central es el índice
11
+ * fiable —las locales pueden traer los tamaños a cero y remitir a un
12
+ * descriptor que va DETRÁS de los datos—.
13
+ *
14
+ * ───────────────────────────────────────────────────────────────────────────
15
+ * ESTO LO MANDA UN DESCONOCIDO Y HAY QUE TRATARLO COMO TAL.
16
+ *
17
+ * El ZIP llega de un cliente que pagó, pero pagar no vuelve a nadie de fiar.
18
+ * Tres defensas, y las tres importan:
19
+ *
20
+ * - Una bomba zip: 42 kB que se descomprimen en petabytes. Se mira el tamaño
21
+ * DECLARADO antes de descomprimir y se lleva un total acumulado, así que
22
+ * se corta antes de reservar la memoria, no después.
23
+ * - Rutas con `..` o absolutas: aquí nada se escribe en disco, pero el
24
+ * nombre viaja al modelo y acaba en logs. Se normaliza igual.
25
+ * - Un ZIP con cien mil entradas vacías, que no infla memoria pero sí tiempo.
26
+ * ───────────────────────────────────────────────────────────────────────────
27
+ */
28
+
29
+ import { inflateRawSync } from 'node:zlib';
30
+
31
+ /** Un archivo dentro del ZIP, ya descomprimido. */
32
+ export interface EntradaZip {
33
+ /** La ruta dentro del ZIP, ya normalizada. */
34
+ nombre: string;
35
+ bytes: Uint8Array;
36
+ }
37
+
38
+ export interface LimitesZip {
39
+ /** Cuántas entradas se miran como mucho. */
40
+ maxEntradas: number;
41
+ /** Tope del total descomprimido, sumando todas. */
42
+ maxTotalBytes: number;
43
+ /** Tope de una sola entrada. */
44
+ maxEntradaBytes: number;
45
+ }
46
+
47
+ export const LIMITES_ZIP: LimitesZip = {
48
+ maxEntradas: 200,
49
+ maxTotalBytes: 32 * 1024 * 1024,
50
+ maxEntradaBytes: 8 * 1024 * 1024,
51
+ };
52
+
53
+ /** Los cuatro bytes con los que empieza todo ZIP (y todo .docx, .xlsx, .odt). */
54
+ export function esZip(bytes: Uint8Array): boolean {
55
+ return bytes.length > 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
56
+ }
57
+
58
+ const u16 = (b: Uint8Array, i: number): number => b[i]! | (b[i + 1]! << 8);
59
+ const u32 = (b: Uint8Array, i: number): number =>
60
+ (b[i]! | (b[i + 1]! << 8) | (b[i + 2]! << 16) | (b[i + 3]! << 24)) >>> 0;
61
+
62
+ /**
63
+ * Quita lo que haría daño de una ruta interna.
64
+ *
65
+ * No se escribe nada en disco, así que esto no evita un escape de directorio:
66
+ * evita que un nombre inventado —`../../etc/passwd`— llegue al modelo y a los
67
+ * logs como si fuera un archivo de verdad del cliente.
68
+ */
69
+ function rutaLimpia(nombre: string): string {
70
+ return nombre
71
+ .replace(/\\/g, '/')
72
+ .split('/')
73
+ .filter((p) => p && p !== '.' && p !== '..')
74
+ .join('/')
75
+ .slice(0, 200);
76
+ }
77
+
78
+ /** Dónde empieza el directorio central. Se busca desde el final. */
79
+ function buscarDirectorio(b: Uint8Array): number | null {
80
+ // El comentario final puede ocupar hasta 64 kB, así que no basta con mirar
81
+ // los últimos 22 bytes.
82
+ const desde = Math.max(0, b.length - 22 - 0xffff);
83
+ for (let i = b.length - 22; i >= desde; i--) {
84
+ if (u32(b, i) === 0x06054b50) return u32(b, i + 16);
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Los archivos de un ZIP, descomprimidos y acotados.
91
+ *
92
+ * Las carpetas y las entradas vacías se saltan solas: lo que interesa es el
93
+ * contenido. Una entrada que no se puede descomprimir se OMITE en vez de
94
+ * tumbar la lectura entera — un ZIP con un archivo roto sigue teniendo diez
95
+ * buenos, y el cliente ya pagó por que se mire lo que sí se puede.
96
+ */
97
+ export function leerZip(datos: Uint8Array, limites: LimitesZip = LIMITES_ZIP): EntradaZip[] {
98
+ const inicio = buscarDirectorio(datos);
99
+ if (inicio === null || inicio >= datos.length) return [];
100
+
101
+ const salida: EntradaZip[] = [];
102
+ let total = 0;
103
+ let cursor = inicio;
104
+
105
+ while (cursor + 46 <= datos.length && u32(datos, cursor) === 0x02014b50) {
106
+ const metodo = u16(datos, cursor + 10);
107
+ const comprimido = u32(datos, cursor + 20);
108
+ const sinComprimir = u32(datos, cursor + 24);
109
+ const nLargo = u16(datos, cursor + 28);
110
+ const extraLargo = u16(datos, cursor + 30);
111
+ const comentarioLargo = u16(datos, cursor + 32);
112
+ const offsetLocal = u32(datos, cursor + 42);
113
+ const nombre = rutaLimpia(new TextDecoder().decode(datos.subarray(cursor + 46, cursor + 46 + nLargo)));
114
+
115
+ cursor += 46 + nLargo + extraLargo + comentarioLargo;
116
+
117
+ if (salida.length >= limites.maxEntradas) break;
118
+ // El tamaño se comprueba ANTES de descomprimir: es lo único que separa
119
+ // esto de reservar los petabytes que la bomba pide.
120
+ if (!nombre || sinComprimir === 0) continue;
121
+ if (sinComprimir > limites.maxEntradaBytes) continue;
122
+ if (total + sinComprimir > limites.maxTotalBytes) break;
123
+
124
+ // Ahora sí hay que mirar la cabecera local, sólo para saber dónde
125
+ // empiezan los datos: su longitud de extra puede ser distinta de la del
126
+ // directorio central, y darlo por hecho desplaza la lectura.
127
+ if (offsetLocal + 30 > datos.length || u32(datos, offsetLocal) !== 0x04034b50) continue;
128
+ const datosEn = offsetLocal + 30 + u16(datos, offsetLocal + 26) + u16(datos, offsetLocal + 28);
129
+ if (datosEn + comprimido > datos.length) continue;
130
+ const crudo = datos.subarray(datosEn, datosEn + comprimido);
131
+
132
+ try {
133
+ const bytes = metodo === 0 ? crudo : metodo === 8 ? new Uint8Array(inflateRawSync(crudo)) : null;
134
+ if (!bytes) continue;
135
+ total += bytes.length;
136
+ salida.push({ nombre, bytes });
137
+ } catch {
138
+ // Entrada corrupta o cifrada: se omite y se sigue con las demás.
139
+ continue;
140
+ }
141
+ }
142
+
143
+ return salida;
144
+ }
145
+
146
+ /* ══════════════════════════════════════════════════════════════════════════
147
+ * ESCRIBIR
148
+ *
149
+ * Hace falta para devolver un `.docx`, que es un ZIP con XML dentro. El mismo
150
+ * formato que se lee arriba, al revés.
151
+ * ══════════════════════════════════════════════════════════════════════════ */
152
+
153
+ import { deflateRawSync } from 'node:zlib';
154
+
155
+ /** CRC-32, que el ZIP exige por entrada. Tabla al vuelo: son 256 valores. */
156
+ function crc32(bytes: Uint8Array): number {
157
+ let c: number;
158
+ const tabla: number[] = [];
159
+ for (let n = 0; n < 256; n++) {
160
+ c = n;
161
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
162
+ tabla[n] = c >>> 0;
163
+ }
164
+ let crc = 0xffffffff;
165
+ for (const b of bytes) crc = tabla[(crc ^ b) & 0xff]! ^ (crc >>> 8);
166
+ return (crc ^ 0xffffffff) >>> 0;
167
+ }
168
+
169
+ function escribirU16(v: number): number[] {
170
+ return [v & 0xff, (v >>> 8) & 0xff];
171
+ }
172
+ function escribirU32(v: number): number[] {
173
+ return [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff];
174
+ }
175
+
176
+ /**
177
+ * Monta un ZIP.
178
+ *
179
+ * Se comprime todo con deflate. Sin fecha real —se pone la del epoch de MS-DOS
180
+ * y ya— porque un archivo que cambia de bytes cada vez que se genera rompe una
181
+ * propiedad que aquí importa: el hash de la entrega se ancla en la cadena, y
182
+ * generar dos veces lo mismo tiene que dar exactamente lo mismo.
183
+ */
184
+ export function escribirZip(entradas: { nombre: string; bytes: Uint8Array }[]): Uint8Array {
185
+ const local: number[] = [];
186
+ const central: number[] = [];
187
+ let offset = 0;
188
+
189
+ for (const e of entradas) {
190
+ const nombre = [...new TextEncoder().encode(e.nombre)];
191
+ const comprimido = [...new Uint8Array(deflateRawSync(e.bytes))];
192
+ const crc = crc32(e.bytes);
193
+
194
+ const cabecera = [
195
+ ...escribirU32(0x04034b50),
196
+ ...escribirU16(20), // versión mínima
197
+ ...escribirU16(0),
198
+ ...escribirU16(8), // deflate
199
+ ...escribirU16(0), // hora
200
+ ...escribirU16(0x21), // fecha: 1980-01-01
201
+ ...escribirU32(crc),
202
+ ...escribirU32(comprimido.length),
203
+ ...escribirU32(e.bytes.length),
204
+ ...escribirU16(nombre.length),
205
+ ...escribirU16(0),
206
+ ...nombre,
207
+ ];
208
+ local.push(...cabecera, ...comprimido);
209
+
210
+ central.push(
211
+ ...escribirU32(0x02014b50),
212
+ ...escribirU16(20), // versión que lo creó
213
+ ...escribirU16(20),
214
+ ...escribirU16(0),
215
+ ...escribirU16(8),
216
+ ...escribirU16(0),
217
+ ...escribirU16(0x21),
218
+ ...escribirU32(crc),
219
+ ...escribirU32(comprimido.length),
220
+ ...escribirU32(e.bytes.length),
221
+ ...escribirU16(nombre.length),
222
+ ...escribirU16(0),
223
+ ...escribirU16(0), // comentario
224
+ ...escribirU16(0), // disco
225
+ ...escribirU16(0), // atributos internos
226
+ ...escribirU32(0), // atributos externos
227
+ ...escribirU32(offset),
228
+ ...nombre,
229
+ );
230
+ offset += cabecera.length + comprimido.length;
231
+ }
232
+
233
+ const fin = [
234
+ ...escribirU32(0x06054b50),
235
+ ...escribirU16(0),
236
+ ...escribirU16(0),
237
+ ...escribirU16(entradas.length),
238
+ ...escribirU16(entradas.length),
239
+ ...escribirU32(central.length),
240
+ ...escribirU32(local.length),
241
+ ...escribirU16(0),
242
+ ];
243
+
244
+ return new Uint8Array([...local, ...central, ...fin]);
245
+ }