create-panal-agent 0.4.0 → 0.5.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.4.0",
3
+ "version": "0.5.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",
@@ -11,6 +11,8 @@
11
11
  * anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
12
12
  */
13
13
 
14
+ import { textoAPdf } from './pdf.js';
15
+
14
16
  export interface TaskContext {
15
17
  /**
16
18
  * El id de la tarea en el escrow, o `null` si esto es una llamada x402: ahí
@@ -98,7 +100,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
98
100
  const problema = revisar(brief, texto);
99
101
  if (!problema) {
100
102
  console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
101
- return texto;
103
+ return conPdfSiLoPidio(brief, texto, ctx);
102
104
  }
103
105
  console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
104
106
  // A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
@@ -106,7 +108,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
106
108
  // entregar algo imperfecto y que él decida, que dejarlo sin nada.
107
109
  if (intento === 2) {
108
110
  console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
109
- return texto;
111
+ return conPdfSiLoPidio(brief, texto, ctx);
110
112
  }
111
113
  queja = problema;
112
114
  }
@@ -144,9 +146,42 @@ function revisar(brief: string, resultado: string): string | null {
144
146
  return 'la respuesta lleva Markdown (**, # o ```) y el cliente lo verá en crudo: devuélvela en texto plano';
145
147
  }
146
148
 
149
+ // Si el cliente pide "y mándamelo en PDF", el modelo tiende a contestar que
150
+ // él no genera archivos — mientras el archivo va adjunto. Prohibírselo en el
151
+ // prompt reduce el problema pero no lo cierra: llegó a salir "no menciono el
152
+ // PDF porque no debo". Se comprueba, y se reintenta.
153
+ //
154
+ // Se busca la palabra JUNTO A una frase autorreferencial, no la palabra
155
+ // suelta: alguien puede encargar de verdad trabajo sobre PDFs, y ahí la
156
+ // palabra es el encargo.
157
+ const meta = /(no (puedo|incluyo|voy a|debo|menciono|se adjunt|se genera)|el sistema (se encarg|lo adjunt)|i (cannot|can't|am not)|the system will)/i;
158
+ for (const linea of resultado.split('\n')) {
159
+ if (/\b(pdf|archivo|fichero|adjunto|attachment|file)\b/i.test(linea) && meta.test(linea)) {
160
+ return 'has comentado que no generas archivos; el agente los adjunta solo. Reescríbelo sin ninguna referencia a archivos, PDFs ni adjuntos: solo el trabajo';
161
+ }
162
+ }
163
+
147
164
  return null;
148
165
  }
149
166
 
167
+ /**
168
+ * Adjunta el trabajo en PDF si el encargo lo pedía. El texto se entrega igual.
169
+ *
170
+ * Se mira el encargo en vez de mandarlo siempre: a un agente también lo llaman
171
+ * otros programas, y adjuntarles un archivo que no pidieron es peso muerto.
172
+ *
173
+ * Bórralo si tu agente no entrega PDFs, o cámbialo por lo que tú generes: una
174
+ * imagen, un CSV, un ZIP. El motor calcula el hash de lo que devuelvas aquí y
175
+ * lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
176
+ * se baja es exactamente el que le entregaste.
177
+ */
178
+ function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
179
+ if (!/\bpdf\b/i.test(brief)) return texto;
180
+ const pdf = textoAPdf(`Panal - entrega ${etiqueta(ctx)}`, texto);
181
+ console.log(`[agente] ${etiqueta(ctx)} PDF de ${pdf.byteLength} bytes adjunto`);
182
+ return { text: texto, files: [{ name: 'entrega.pdf', data: pdf, mime: 'application/pdf' }] };
183
+ }
184
+
150
185
  async function pedirAlModelo(brief: string, apiKey: string, queja: string | null): Promise<string> {
151
186
  const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
152
187
  method: 'POST',
@@ -171,7 +206,11 @@ async function pedirAlModelo(brief: string, apiKey: string, queja: string | null
171
206
  'You are a professional agent on the Panal marketplace. ' +
172
207
  'RULE 1: detect the language of the request and reply in that exact same language; never switch. ' +
173
208
  'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
174
- 'RULE 3: deliver finished professional work, with no preamble or meta-commentary.',
209
+ 'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
210
+ // El agente adjunta el archivo por su cuenta; el modelo no se entera
211
+ // y, sin esta regla, se disculpa por no poder generarlo.
212
+ 'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
213
+ 'you answer. Never mention files, PDFs or attachments — not even to say you cannot make them.',
175
214
  },
176
215
  { role: 'user', content: brief },
177
216
  // La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Un PDF de verdad, sin dependencias. Puedes borrar este archivo si tu agente
3
+ * no entrega PDFs.
4
+ *
5
+ * No es una plantilla ni un HTML impreso: se escriben los objetos del PDF a
6
+ * mano, con su tabla xref y sus offsets en bytes. Sale un archivo que abre
7
+ * cualquier lector, y no añade ni un paquete a tus dependencias — meter una
8
+ * librería de 4 MB para pintar texto monoespaciado en un A4 no sale a cuenta.
9
+ *
10
+ * Se usa desde `agent.ts`:
11
+ *
12
+ * const pdf = textoAPdf('Mi informe', texto);
13
+ * return { text: texto, files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }] };
14
+ *
15
+ * El motor calcula su hash y lo ancla en la cadena; tú no tocas nada de eso.
16
+ *
17
+ * Lo que hace bien y cuesta acertar a mano: parte las líneas largas para que no
18
+ * se salgan del papel, pagina solo, y traduce los símbolos que la codificación
19
+ * del PDF no tiene en vez de destrozarlos en silencio.
20
+ */
21
+
22
+ /** A4 en puntos, que es la unidad del PDF. */
23
+ const ANCHO = 595;
24
+ const ALTO = 842;
25
+ const MARGEN = 50;
26
+ const CUERPO = 9.5;
27
+ const INTERLINEA = 12.5;
28
+ /** Cuántas líneas caben en una página con estos márgenes. */
29
+ const LINEAS_POR_PAGINA = Math.floor((ALTO - MARGEN * 2) / INTERLINEA);
30
+ /** Ancho de caracteres a 9.5pt en Courier: 0.6 em, redondeado a la baja. */
31
+ const COLUMNAS = Math.floor((ANCHO - MARGEN * 2) / (CUERPO * 0.6));
32
+
33
+ /**
34
+ * Escapa un texto para meterlo entre paréntesis en un PDF.
35
+ *
36
+ * Los paréntesis delimitan las cadenas, así que uno sin escapar rompe el
37
+ * archivo entero — y un JSON viene lleno de ellos.
38
+ */
39
+ function escapar(texto: string): string {
40
+ return texto.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
41
+ }
42
+
43
+ /**
44
+ * Parte las líneas largas para que no se salgan del papel.
45
+ *
46
+ * Un PDF no ajusta el texto solo: lo que no cabe, sencillamente no se ve. Con
47
+ * un JSON de una sola línea eso significa entregar una hoja casi en blanco.
48
+ */
49
+ function ajustar(lineas: string[]): string[] {
50
+ const out: string[] = [];
51
+ for (const linea of lineas) {
52
+ if (linea.length <= COLUMNAS) {
53
+ out.push(linea);
54
+ continue;
55
+ }
56
+ // Se conserva la sangría al partir: en un JSON es lo que deja ver la
57
+ // estructura, y sin ella el corte lo vuelve ilegible.
58
+ const sangria = /^\s*/.exec(linea)![0].slice(0, 20);
59
+ let resto = linea;
60
+ let primera = true;
61
+ while (resto.length > 0) {
62
+ const ancho = primera ? COLUMNAS : COLUMNAS - sangria.length;
63
+ out.push((primera ? '' : sangria) + resto.slice(0, ancho));
64
+ resto = resto.slice(ancho);
65
+ primera = false;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Sustitutos ASCII de los símbolos que WinAnsiEncoding no tiene.
73
+ *
74
+ * Sin esto, `Buffer.from(txt, 'latin1')` los recorta al byte bajo y salen
75
+ * caracteres que no significan nada: un "≠" acababa impreso como "`", así que
76
+ * un caso de prueba que decía "b ≠ 0" pasaba a decir "b ` 0". Silencioso, y
77
+ * dentro de un entregable que se cobra.
78
+ */
79
+ const SUSTITUTOS: Record<string, string> = {
80
+ '≠': '!=', '≤': '<=', '≥': '>=', '≈': '~=', '±': '+/-', '×': 'x', '÷': '/',
81
+ '→': '->', '←': '<-', '⇒': '=>', '∞': 'infinito', '∅': 'vacio',
82
+ '“': '"', '”': '"', '„': '"', '‘': "'", '’': "'", '‹': '<', '›': '>',
83
+ '–': '-', '—': '-', '…': '...', '•': '-', '·': '·', '™': '(TM)', '€': 'EUR',
84
+ };
85
+
86
+ /**
87
+ * Latin-1, que es lo que entiende WinAnsiEncoding —la codificación de las
88
+ * fuentes base del PDF—, sustituyendo antes lo que no cabe.
89
+ *
90
+ * Lo que no tiene sustituto se marca con "?" a propósito: un interrogante
91
+ * avisa de que ahí faltaba algo; un carácter aleatorio miente.
92
+ */
93
+ function aLatin1(texto: string): Buffer {
94
+ const convertido = [...texto]
95
+ .map((c) => {
96
+ if (SUSTITUTOS[c]) return SUSTITUTOS[c];
97
+ return c.codePointAt(0)! <= 0xff ? c : '?';
98
+ })
99
+ .join('');
100
+ return Buffer.from(convertido, 'latin1');
101
+ }
102
+
103
+ /** Construye el PDF. Devuelve los bytes, listos para escribir o entregar. */
104
+ export function textoAPdf(titulo: string, contenido: string): Uint8Array {
105
+ const lineas = ajustar([titulo, '', ...contenido.split('\n')]);
106
+
107
+ // Se reparte en páginas antes de escribir nada: hay que saber cuántas son
108
+ // para numerar los objetos, y en un PDF los objetos se referencian por número.
109
+ const paginas: string[][] = [];
110
+ for (let i = 0; i < lineas.length; i += LINEAS_POR_PAGINA) {
111
+ paginas.push(lineas.slice(i, i + LINEAS_POR_PAGINA));
112
+ }
113
+ if (paginas.length === 0) paginas.push(['(sin contenido)']);
114
+
115
+ // Numeración: 1 catálogo, 2 árbol de páginas, 3 fuente, y luego cada página
116
+ // con su flujo de contenido, dos objetos por página.
117
+ const FUENTE = 3;
118
+ const primeraPagina = 4;
119
+ const idPagina = (i: number) => primeraPagina + i * 2;
120
+ const idContenido = (i: number) => primeraPagina + i * 2 + 1;
121
+
122
+ const objetos: Buffer[] = [];
123
+ const add = (n: number, cuerpo: string | Buffer) => {
124
+ objetos[n] = Buffer.concat([
125
+ aLatin1(`${n} 0 obj\n`),
126
+ typeof cuerpo === 'string' ? aLatin1(cuerpo) : cuerpo,
127
+ aLatin1('\nendobj\n'),
128
+ ]);
129
+ };
130
+
131
+ const kids = paginas.map((_, i) => `${idPagina(i)} 0 R`).join(' ');
132
+ add(1, '<< /Type /Catalog /Pages 2 0 R >>');
133
+ add(2, `<< /Type /Pages /Kids [${kids}] /Count ${paginas.length} >>`);
134
+ add(FUENTE, '<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
135
+
136
+ paginas.forEach((lineasPagina, i) => {
137
+ add(
138
+ idPagina(i),
139
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${ANCHO} ${ALTO}] ` +
140
+ `/Resources << /Font << /F1 ${FUENTE} 0 R >> >> /Contents ${idContenido(i)} 0 R >>`,
141
+ );
142
+
143
+ const flujo = aLatin1(
144
+ [
145
+ 'BT',
146
+ `/F1 ${CUERPO} Tf`,
147
+ `${INTERLINEA} TL`,
148
+ `${MARGEN} ${ALTO - MARGEN} Td`,
149
+ ...lineasPagina.map((l) => `(${escapar(l)}) Tj T*`),
150
+ 'ET',
151
+ ].join('\n'),
152
+ );
153
+ // /Length va en BYTES, no en caracteres: con acentos no es lo mismo, y un
154
+ // lector estricto rechaza el archivo si no cuadra.
155
+ add(idContenido(i), Buffer.concat([aLatin1(`<< /Length ${flujo.length} >>\nstream\n`), flujo, aLatin1('\nendstream')]));
156
+ });
157
+
158
+ // Ensamblado: hay que ir apuntando el offset en bytes de cada objeto, porque
159
+ // la tabla xref del final los indexa por posición absoluta en el archivo.
160
+ const total = objetos.length - 1;
161
+ const partes: Buffer[] = [aLatin1('%PDF-1.4\n')];
162
+ const offsets: number[] = [];
163
+ let cursor = partes[0]!.length;
164
+
165
+ for (let n = 1; n <= total; n++) {
166
+ offsets[n] = cursor;
167
+ partes.push(objetos[n]!);
168
+ cursor += objetos[n]!.length;
169
+ }
170
+
171
+ const xref = [
172
+ 'xref',
173
+ `0 ${total + 1}`,
174
+ '0000000000 65535 f ',
175
+ ...Array.from({ length: total }, (_, i) => `${String(offsets[i + 1]).padStart(10, '0')} 00000 n `),
176
+ ].join('\n');
177
+
178
+ partes.push(aLatin1(`${xref}\ntrailer\n<< /Size ${total + 1} /Root 1 0 R >>\nstartxref\n${cursor}\n%%EOF\n`));
179
+ return new Uint8Array(Buffer.concat(partes));
180
+ }