create-panal-agent 0.3.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 +1 -1
- package/template/_package.json +1 -1
- package/template/src/agent.ts +76 -5
- package/template/src/pdf.ts +180 -0
- package/template/src/server.ts +119 -2
package/package.json
CHANGED
package/template/_package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -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í
|
|
@@ -25,6 +27,27 @@ export interface TaskContext {
|
|
|
25
27
|
deadline: bigint;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** Un archivo que entregas junto al texto. */
|
|
31
|
+
export interface TaskFile {
|
|
32
|
+
/** Cómo se va a llamar. Sin rutas: `informe.pdf`, no `salida/informe.pdf`. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** El contenido. Un Buffer/Uint8Array para binario, un string para texto. */
|
|
35
|
+
data: Uint8Array | string;
|
|
36
|
+
/** Tipo MIME, si lo sabes: `application/pdf`, `image/png`… */
|
|
37
|
+
mime?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lo que devuelve tu agente: un texto, o un texto con archivos.
|
|
42
|
+
*
|
|
43
|
+
* Los archivos no viajan a la cadena —no cabrían—, pero SU HASH sí: el motor
|
|
44
|
+
* lo mete en el texto de la entrega antes de anclarlo. Así el cliente puede
|
|
45
|
+
* descargarlos y demostrar que son exactamente los que le entregaste. Un
|
|
46
|
+
* enlace a secas no daría eso: quien lo aloja podría cambiar el archivo
|
|
47
|
+
* después de cobrar y no habría con qué demostrarlo.
|
|
48
|
+
*/
|
|
49
|
+
export type TaskResult = string | { text: string; files?: TaskFile[] };
|
|
50
|
+
|
|
28
51
|
/** Cómo se llama esto en los logs: `#31` si viene del escrow, `x402` si no. */
|
|
29
52
|
function etiqueta(ctx: TaskContext): string {
|
|
30
53
|
return ctx.taskId === null ? 'x402' : `#${ctx.taskId}`;
|
|
@@ -35,9 +58,20 @@ function etiqueta(ctx: TaskContext): string {
|
|
|
35
58
|
*
|
|
36
59
|
* @param brief El encargo, tal y como lo escribió el cliente.
|
|
37
60
|
* @param ctx Datos de la tarea, por si te sirven.
|
|
38
|
-
* @returns El trabajo terminado
|
|
61
|
+
* @returns El trabajo terminado: un texto, o `{ text, files }` si además
|
|
62
|
+
* entregas archivos. Por ejemplo:
|
|
63
|
+
*
|
|
64
|
+
* return {
|
|
65
|
+
* text: 'Aquí tienes el informe que pediste.',
|
|
66
|
+
* files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }],
|
|
67
|
+
* };
|
|
68
|
+
*
|
|
69
|
+
* No tienes que calcular ningún hash ni servir ninguna descarga:
|
|
70
|
+
* de eso se ocupa `server.ts`. Ojo con una cosa, y solo con una:
|
|
71
|
+
* si construyes el nombre a partir del encargo, límpialo antes,
|
|
72
|
+
* porque lo escribe quien te contrató.
|
|
39
73
|
*/
|
|
40
|
-
export async function handleTask(brief: string, ctx: TaskContext): Promise<
|
|
74
|
+
export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskResult> {
|
|
41
75
|
// ──────────────────────────────────────────────────────────────────────────
|
|
42
76
|
// EJEMPLO: un agente que responde con un LLM.
|
|
43
77
|
//
|
|
@@ -66,7 +100,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
|
|
|
66
100
|
const problema = revisar(brief, texto);
|
|
67
101
|
if (!problema) {
|
|
68
102
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
69
|
-
return texto;
|
|
103
|
+
return conPdfSiLoPidio(brief, texto, ctx);
|
|
70
104
|
}
|
|
71
105
|
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
72
106
|
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
@@ -74,7 +108,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
|
|
|
74
108
|
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
75
109
|
if (intento === 2) {
|
|
76
110
|
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
77
|
-
return texto;
|
|
111
|
+
return conPdfSiLoPidio(brief, texto, ctx);
|
|
78
112
|
}
|
|
79
113
|
queja = problema;
|
|
80
114
|
}
|
|
@@ -112,9 +146,42 @@ function revisar(brief: string, resultado: string): string | null {
|
|
|
112
146
|
return 'la respuesta lleva Markdown (**, # o ```) y el cliente lo verá en crudo: devuélvela en texto plano';
|
|
113
147
|
}
|
|
114
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
|
+
|
|
115
164
|
return null;
|
|
116
165
|
}
|
|
117
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
|
+
|
|
118
185
|
async function pedirAlModelo(brief: string, apiKey: string, queja: string | null): Promise<string> {
|
|
119
186
|
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
120
187
|
method: 'POST',
|
|
@@ -139,7 +206,11 @@ async function pedirAlModelo(brief: string, apiKey: string, queja: string | null
|
|
|
139
206
|
'You are a professional agent on the Panal marketplace. ' +
|
|
140
207
|
'RULE 1: detect the language of the request and reply in that exact same language; never switch. ' +
|
|
141
208
|
'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
|
|
142
|
-
'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.',
|
|
143
214
|
},
|
|
144
215
|
{ role: 'user', content: brief },
|
|
145
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
|
+
}
|
package/template/src/server.ts
CHANGED
|
@@ -25,20 +25,24 @@ 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
|
+
appendFilesManifest,
|
|
28
29
|
buildQuote,
|
|
29
30
|
createPanalClient,
|
|
30
31
|
MAINNET_ADDRESSES,
|
|
31
32
|
parsePaymentHeader,
|
|
32
33
|
permitNonce,
|
|
33
34
|
readPermitDomain,
|
|
35
|
+
sanitizeFileName,
|
|
34
36
|
TaskStatus,
|
|
35
37
|
verifyAndSettle,
|
|
38
|
+
type DeliveredFile,
|
|
36
39
|
type PermitDomain,
|
|
37
40
|
} from '@panal/sdk';
|
|
38
41
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
39
42
|
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
40
43
|
import type { Address } from 'viem';
|
|
41
44
|
import { handleTask } from './agent.js';
|
|
45
|
+
import type { TaskFile, TaskResult } from './agent.js';
|
|
42
46
|
|
|
43
47
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
44
48
|
const DATA_DIR = process.env.DATA_DIR ?? './data';
|
|
@@ -113,6 +117,8 @@ async function dominioPermit(): Promise<PermitDomain> {
|
|
|
113
117
|
|
|
114
118
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
115
119
|
const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
|
|
120
|
+
/** Carpeta de los archivos de una tarea. Una por tarea, para no mezclarlas. */
|
|
121
|
+
const filesDir = (taskId: bigint) => join(DATA_DIR, 'files', taskId.toString());
|
|
116
122
|
|
|
117
123
|
function saveResult(taskId: bigint, text: string): void {
|
|
118
124
|
writeFileSync(resultPath(taskId), text, 'utf8');
|
|
@@ -125,6 +131,46 @@ function loadResult(taskId: bigint): string | null {
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Guarda en disco los archivos de una entrega y devuelve su manifiesto.
|
|
136
|
+
*
|
|
137
|
+
* El nombre se limpia con `sanitizeFileName` ANTES de tocar el disco: llega en
|
|
138
|
+
* lo que devuelve `handleTask`, y un agente que construya el nombre a partir
|
|
139
|
+
* del encargo del cliente estaría dejando que un desconocido elija dónde
|
|
140
|
+
* escribir. Un `../../.env` acabaría en la raíz del proyecto.
|
|
141
|
+
*/
|
|
142
|
+
function saveFiles(taskId: bigint, files: TaskFile[]): DeliveredFile[] {
|
|
143
|
+
const dir = filesDir(taskId);
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
return files.map((f) => {
|
|
147
|
+
const name = sanitizeFileName(f.name);
|
|
148
|
+
const bytes = typeof f.data === 'string' ? new TextEncoder().encode(f.data) : new Uint8Array(f.data);
|
|
149
|
+
writeFileSync(join(dir, name), bytes);
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
size: bytes.byteLength,
|
|
153
|
+
...(f.mime ? { mime: f.mime } : {}),
|
|
154
|
+
// El hash de los BYTES, no del enlace: es lo único que sobrevive a que
|
|
155
|
+
// alguien cambie el archivo después de haber cobrado.
|
|
156
|
+
hash: keccak256(bytes),
|
|
157
|
+
path: `/files/${taskId}/${encodeURIComponent(name)}`,
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Deja lo que devolvió `handleTask` en una forma sola.
|
|
164
|
+
*
|
|
165
|
+
* Se acepta un string a secas porque es lo que devuelve el 95 % de los agentes
|
|
166
|
+
* y obligarles a envolverlo en un objeto sería cobrarles la complejidad de una
|
|
167
|
+
* función que no usan.
|
|
168
|
+
*/
|
|
169
|
+
function normalizarSalida(salida: TaskResult): { text: string; files: TaskFile[] } {
|
|
170
|
+
if (typeof salida === 'string') return { text: salida, files: [] };
|
|
171
|
+
return { text: salida.text, files: salida.files ?? [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
128
174
|
/** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
|
|
129
175
|
const inFlight = new Set<string>();
|
|
130
176
|
|
|
@@ -154,13 +200,19 @@ async function work(taskId: bigint, brief: string): Promise<void> {
|
|
|
154
200
|
inFlight.add(key);
|
|
155
201
|
try {
|
|
156
202
|
const task = await panal.getTask(taskId);
|
|
157
|
-
const
|
|
203
|
+
const salida = await handleTask(brief, {
|
|
158
204
|
taskId,
|
|
159
205
|
client: task.client,
|
|
160
206
|
amount: task.amount,
|
|
161
207
|
deadline: task.deadline,
|
|
162
208
|
});
|
|
163
209
|
|
|
210
|
+
// Tu handleTask puede devolver un texto a secas —lo normal— o un texto con
|
|
211
|
+
// archivos. Los archivos se escriben en disco y su hash se cuela en el
|
|
212
|
+
// texto: lo que se ancla en la cadena pasa a cubrirlos también.
|
|
213
|
+
const { text: cuerpo, files } = normalizarSalida(salida);
|
|
214
|
+
const text = files.length ? appendFilesManifest(cuerpo, saveFiles(taskId, files)) : cuerpo;
|
|
215
|
+
|
|
164
216
|
// Primero se guarda y luego se entrega: si el orden fuera al revés y el
|
|
165
217
|
// proceso muriera entre medias, el hash estaría anclado on-chain y el texto
|
|
166
218
|
// perdido, o sea una entrega imposible de cumplir.
|
|
@@ -379,12 +431,23 @@ const server = createServer((req, res) => {
|
|
|
379
431
|
// Ya está cobrado: pase lo que pase a partir de aquí, hay que responder
|
|
380
432
|
// algo. Si el modelo revienta, se dice; callarse sería quedarse el dinero.
|
|
381
433
|
try {
|
|
382
|
-
const
|
|
434
|
+
const salida = await handleTask(prompt, {
|
|
383
435
|
taskId: null,
|
|
384
436
|
client: leido.payment.payer,
|
|
385
437
|
amount: cobro.amount,
|
|
386
438
|
deadline: 0n,
|
|
387
439
|
});
|
|
440
|
+
// En una llamada x402 no hay tarea, así que no hay nada que anclar ni
|
|
441
|
+
// ninguna firma con la que proteger una descarga: los archivos no
|
|
442
|
+
// tienen dónde agarrarse. Se responde el texto y se avisa en el log en
|
|
443
|
+
// vez de callarlo, que si no el autor busca el fallo donde no está.
|
|
444
|
+
const { text: answer, files } = normalizarSalida(salida);
|
|
445
|
+
if (files.length) {
|
|
446
|
+
console.error(
|
|
447
|
+
`[x402] tu handleTask devolvió ${files.length} archivo(s) y una llamada x402 no puede entregarlos: ` +
|
|
448
|
+
'no hay tarea que los ancle ni firma que proteja la descarga. Solo va el texto.',
|
|
449
|
+
);
|
|
450
|
+
}
|
|
388
451
|
res.setHeader('x-payment-tx', cobro.txHash);
|
|
389
452
|
json(res, 200, { answer, paid: { txHash: cobro.txHash, amount: cobro.amount.toString(), asset: X402_TOKEN } });
|
|
390
453
|
} catch (err) {
|
|
@@ -495,6 +558,60 @@ const server = createServer((req, res) => {
|
|
|
495
558
|
return;
|
|
496
559
|
}
|
|
497
560
|
|
|
561
|
+
// ---- El cliente se baja los archivos de su entrega ----------------------
|
|
562
|
+
//
|
|
563
|
+
// Se protege igual que el resultado, y con LA MISMA firma: `Panal resultado
|
|
564
|
+
// #<id>` abre el texto y todos sus archivos. Firmar una vez por archivo
|
|
565
|
+
// sería pedirle al cliente cuatro firmas por una entrega de cuatro PDFs.
|
|
566
|
+
const archivo = /^\/files\/(\d+)\/([^/]+)$/.exec(url.pathname);
|
|
567
|
+
if (archivo && req.method === 'GET') {
|
|
568
|
+
const taskId = BigInt(archivo[1]!);
|
|
569
|
+
const address = url.searchParams.get('address');
|
|
570
|
+
const signature = url.searchParams.get('signature');
|
|
571
|
+
if (!address || !signature) {
|
|
572
|
+
json(res, 400, { error: 'faltan address y signature' });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const task = await panal.getTask(taskId);
|
|
576
|
+
if (address.toLowerCase() !== task.client.toLowerCase()) {
|
|
577
|
+
json(res, 403, { error: 'solo el cliente de la tarea puede descargar sus archivos' });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (!(await signedBy(resultSignMessage(taskId), signature, task.client))) {
|
|
581
|
+
json(res, 401, { error: 'firma inválida' });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// El nombre viene de la URL, o sea de fuera: se limpia igual que al
|
|
586
|
+
// escribirlo. Sin esto, `/files/31/..%2F..%2F.env` leería el .env.
|
|
587
|
+
let nombre: string;
|
|
588
|
+
try {
|
|
589
|
+
nombre = sanitizeFileName(decodeURIComponent(archivo[2]!));
|
|
590
|
+
} catch {
|
|
591
|
+
json(res, 400, { error: 'nombre de archivo inválido' });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let bytes: Buffer;
|
|
596
|
+
try {
|
|
597
|
+
bytes = readFileSync(join(filesDir(taskId), nombre));
|
|
598
|
+
} catch {
|
|
599
|
+
json(res, 404, { error: 'esa tarea no tiene ese archivo' });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
res.writeHead(200, {
|
|
604
|
+
'content-type': 'application/octet-stream',
|
|
605
|
+
'content-length': bytes.byteLength,
|
|
606
|
+
// `attachment` a propósito: lo que hay dentro lo eligió el agente, y no
|
|
607
|
+
// se le deja que el navegador del cliente lo ejecute como una página.
|
|
608
|
+
'content-disposition': `attachment; filename="${nombre}"`,
|
|
609
|
+
'x-content-type-options': 'nosniff',
|
|
610
|
+
});
|
|
611
|
+
res.end(bytes);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
498
615
|
json(res, 404, { error: 'no existe' });
|
|
499
616
|
})().catch((err) => {
|
|
500
617
|
console.error(`[http] ${err instanceof Error ? err.message : err}`);
|