create-panal-agent 0.2.0 → 0.4.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/dist/i18n.js +10 -0
- package/dist/index.js +6 -0
- package/package.json +1 -1
- package/template/_package.json +1 -1
- package/template/src/agent.ts +108 -8
- package/template/src/server.ts +289 -4
package/dist/i18n.js
CHANGED
|
@@ -73,6 +73,7 @@ The language is taken from --lang, then PANAL_LANG, then your system locale.`,
|
|
|
73
73
|
model: 'Your model, if your agent uses one. Any OpenAI-compatible API.',
|
|
74
74
|
rpc: 'Your own RPC, if the public one falls short (it caps at ~15 calls/s).',
|
|
75
75
|
data: 'Where to store delivered results.',
|
|
76
|
+
x402: 'Charge per call (optional). Leave it empty and your agent only takes escrow jobs.\nThe price is per request, in an EIP-2612 token — it cannot be MON, the scheme needs `permit`.',
|
|
76
77
|
},
|
|
77
78
|
readme: `# {name}
|
|
78
79
|
|
|
@@ -162,6 +163,7 @@ El idioma sale de --lang, luego de PANAL_LANG, y si no del locale del sistema.`,
|
|
|
162
163
|
model: 'Tu modelo, si tu agente usa uno. Cualquier API compatible con OpenAI.',
|
|
163
164
|
rpc: 'RPC propio, si el público se te queda corto (limita a ~15 llamadas/s).',
|
|
164
165
|
data: 'Dónde guardar los resultados entregados.',
|
|
166
|
+
x402: 'Cobro por llamada (opcional). Déjalo vacío y tu agente solo acepta encargos del escrow.\nEl precio es por petición, en un token EIP-2612: no puede ser MON, el esquema necesita `permit`.',
|
|
165
167
|
},
|
|
166
168
|
readme: `# {name}
|
|
167
169
|
|
|
@@ -252,6 +254,7 @@ const zh = {
|
|
|
252
254
|
model: '你的模型(如果代理需要)。任何兼容 OpenAI 的 API 均可。',
|
|
253
255
|
rpc: '你自己的 RPC,当公共节点不够用时(其上限约为每秒 15 次调用)。',
|
|
254
256
|
data: '交付结果的存放位置。',
|
|
257
|
+
x402: '按次收费(可选)。留空则你的代理只接受托管订单。\n价格按每次请求计算,使用支持 EIP-2612 的代币——不能是 MON,该方案依赖 `permit`。',
|
|
255
258
|
},
|
|
256
259
|
readme: `# {name}
|
|
257
260
|
|
|
@@ -337,6 +340,7 @@ const hi = {
|
|
|
337
340
|
model: 'आपका मॉडल, अगर एजेंट किसी का उपयोग करता है। OpenAI-संगत कोई भी API।',
|
|
338
341
|
rpc: 'अपना RPC, अगर सार्वजनिक कम पड़े (इसकी सीमा ~15 कॉल/सेकंड है)।',
|
|
339
342
|
data: 'दिए गए परिणाम कहाँ सहेजें।',
|
|
343
|
+
x402: 'प्रति कॉल शुल्क (वैकल्पिक)। खाली छोड़ें तो आपका एजेंट केवल एस्क्रो वाले काम लेगा।\nकीमत प्रति अनुरोध है, EIP-2612 टोकन में — MON नहीं चल सकता, इस योजना को `permit` चाहिए।',
|
|
340
344
|
},
|
|
341
345
|
readme: `# {name}
|
|
342
346
|
|
|
@@ -426,6 +430,7 @@ const ar = {
|
|
|
426
430
|
model: 'نموذجك، إن كان وكيلك يستخدم واحدًا. أي واجهة متوافقة مع OpenAI.',
|
|
427
431
|
rpc: 'عقدة RPC خاصة بك، إن لم تكفِ العامة (حدّها نحو 15 طلبًا في الثانية).',
|
|
428
432
|
data: 'مكان حفظ النتائج المُسلَّمة.',
|
|
433
|
+
x402: 'التحصيل لكل استدعاء (اختياري). اتركه فارغًا فيقبل وكيلك مهام الضمان فقط.\nالسعر لكل طلب، بعملة تدعم EIP-2612 — لا يصلح MON، فالمخطط يحتاج `permit`.',
|
|
429
434
|
},
|
|
430
435
|
readme: `# {name}
|
|
431
436
|
|
|
@@ -514,6 +519,7 @@ La langue vient de --lang, puis de PANAL_LANG, puis de la locale du système.`,
|
|
|
514
519
|
model: 'Votre modèle, si votre agent en utilise un. Toute API compatible OpenAI.',
|
|
515
520
|
rpc: 'Votre propre RPC, si le public ne suffit pas (limité à ~15 appels/s).',
|
|
516
521
|
data: 'Où stocker les résultats livrés.',
|
|
522
|
+
x402: "Facturation à l'appel (facultatif). Laissez vide et votre agent ne prend que des missions sous entiercement.\nLe prix est par requête, dans un jeton EIP-2612 : pas de MON, le schéma exige `permit`.",
|
|
517
523
|
},
|
|
518
524
|
readme: `# {name}
|
|
519
525
|
|
|
@@ -606,6 +612,7 @@ O idioma vem de --lang, depois de PANAL_LANG e, por fim, do locale do sistema.`,
|
|
|
606
612
|
model: 'O seu modelo, se o agente usar um. Qualquer API compatível com OpenAI.',
|
|
607
613
|
rpc: 'RPC próprio, se o público não chegar (limita a ~15 chamadas/s).',
|
|
608
614
|
data: 'Onde guardar os resultados entregues.',
|
|
615
|
+
x402: 'Cobrança por chamada (opcional). Deixe vazio e o seu agente só aceita trabalhos do escrow.\nO preço é por pedido, num token EIP-2612: não pode ser MON, o esquema precisa de `permit`.',
|
|
609
616
|
},
|
|
610
617
|
readme: `# {name}
|
|
611
618
|
|
|
@@ -696,6 +703,7 @@ const ru = {
|
|
|
696
703
|
model: 'Ваша модель, если агент её использует. Любой API, совместимый с OpenAI.',
|
|
697
704
|
rpc: 'Свой RPC, если публичного не хватает (лимит ~15 запросов/с).',
|
|
698
705
|
data: 'Где хранить сданные результаты.',
|
|
706
|
+
x402: 'Плата за вызов (необязательно). Оставьте пустым — агент будет брать только заказы через эскроу.\nЦена за один запрос, в токене с EIP-2612: MON не подходит, схеме нужен `permit`.',
|
|
699
707
|
},
|
|
700
708
|
readme: `# {name}
|
|
701
709
|
|
|
@@ -787,6 +795,7 @@ const bn = {
|
|
|
787
795
|
model: 'আপনার মডেল, যদি এজেন্ট ব্যবহার করে। OpenAI-সঙ্গতিপূর্ণ যেকোনো API।',
|
|
788
796
|
rpc: 'নিজস্ব RPC, যদি সর্বজনীনটি যথেষ্ট না হয় (সীমা প্রায় ১৫ কল/সেকেন্ড)।',
|
|
789
797
|
data: 'সরবরাহ করা ফলাফল কোথায় রাখা হবে।',
|
|
798
|
+
x402: 'প্রতি কলে চার্জ (ঐচ্ছিক)। ফাঁকা রাখলে আপনার এজেন্ট কেবল এসক্রো কাজ নেবে।\nদাম প্রতি অনুরোধে, EIP-2612 টোকেনে — MON চলবে না, স্কিমটির `permit` দরকার।',
|
|
790
799
|
},
|
|
791
800
|
readme: `# {name}
|
|
792
801
|
|
|
@@ -875,6 +884,7 @@ const ur = {
|
|
|
875
884
|
model: 'آپ کا ماڈل، اگر ایجنٹ استعمال کرے۔ OpenAI سے ہم آہنگ کوئی بھی API۔',
|
|
876
885
|
rpc: 'اپنا RPC، اگر عوامی کم پڑ جائے (حد تقریباً 15 کالز فی سیکنڈ)۔',
|
|
877
886
|
data: 'فراہم کردہ نتائج کہاں محفوظ ہوں۔',
|
|
887
|
+
x402: 'فی کال وصولی (اختیاری)۔ خالی چھوڑ دیں تو آپ کا ایجنٹ صرف ایسکرو کے کام لے گا۔\nقیمت فی درخواست ہے، EIP-2612 ٹوکن میں — MON نہیں چل سکتا، اس اسکیم کو `permit` چاہیے۔',
|
|
878
888
|
},
|
|
879
889
|
readme: `# {name}
|
|
880
890
|
|
package/dist/index.js
CHANGED
|
@@ -163,6 +163,12 @@ function writeEnvExample(dest, t) {
|
|
|
163
163
|
bloque(t.env.data),
|
|
164
164
|
'DATA_DIR=./data',
|
|
165
165
|
'',
|
|
166
|
+
bloque(t.env.x402),
|
|
167
|
+
'X402_PRICE=',
|
|
168
|
+
'# X402_TOKEN=0x2e2e44e7fa6178822d4397299f719e89d1a67777',
|
|
169
|
+
'# X402_SYMBOL=$PANAL',
|
|
170
|
+
'# X402_DESCRIPTION=',
|
|
171
|
+
'',
|
|
166
172
|
].join('\n');
|
|
167
173
|
writeFileSync(join(dest, '.env.example'), contenido, 'utf8');
|
|
168
174
|
return contenido;
|
package/package.json
CHANGED
package/template/_package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -12,24 +12,64 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export interface TaskContext {
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* El id de la tarea en el escrow, o `null` si esto es una llamada x402: ahí
|
|
17
|
+
* no hay tarea ni plazo, te pagaron en el momento y respondes en el acto.
|
|
18
|
+
*/
|
|
19
|
+
taskId: bigint | null;
|
|
20
|
+
/** La dirección del cliente que te contrató (o que acaba de pagarte). */
|
|
18
21
|
client: string;
|
|
19
22
|
/** Cuánto vas a cobrar, en unidades mínimas (wei). */
|
|
20
23
|
amount: bigint;
|
|
21
|
-
/** Fecha límite de entrega, en segundos epoch. */
|
|
24
|
+
/** Fecha límite de entrega, en segundos epoch. Cero en una llamada x402. */
|
|
22
25
|
deadline: bigint;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** Un archivo que entregas junto al texto. */
|
|
29
|
+
export interface TaskFile {
|
|
30
|
+
/** Cómo se va a llamar. Sin rutas: `informe.pdf`, no `salida/informe.pdf`. */
|
|
31
|
+
name: string;
|
|
32
|
+
/** El contenido. Un Buffer/Uint8Array para binario, un string para texto. */
|
|
33
|
+
data: Uint8Array | string;
|
|
34
|
+
/** Tipo MIME, si lo sabes: `application/pdf`, `image/png`… */
|
|
35
|
+
mime?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Lo que devuelve tu agente: un texto, o un texto con archivos.
|
|
40
|
+
*
|
|
41
|
+
* Los archivos no viajan a la cadena —no cabrían—, pero SU HASH sí: el motor
|
|
42
|
+
* lo mete en el texto de la entrega antes de anclarlo. Así el cliente puede
|
|
43
|
+
* descargarlos y demostrar que son exactamente los que le entregaste. Un
|
|
44
|
+
* enlace a secas no daría eso: quien lo aloja podría cambiar el archivo
|
|
45
|
+
* después de cobrar y no habría con qué demostrarlo.
|
|
46
|
+
*/
|
|
47
|
+
export type TaskResult = string | { text: string; files?: TaskFile[] };
|
|
48
|
+
|
|
49
|
+
/** Cómo se llama esto en los logs: `#31` si viene del escrow, `x402` si no. */
|
|
50
|
+
function etiqueta(ctx: TaskContext): string {
|
|
51
|
+
return ctx.taskId === null ? 'x402' : `#${ctx.taskId}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
/**
|
|
26
55
|
* Tu agente.
|
|
27
56
|
*
|
|
28
57
|
* @param brief El encargo, tal y como lo escribió el cliente.
|
|
29
58
|
* @param ctx Datos de la tarea, por si te sirven.
|
|
30
|
-
* @returns El trabajo terminado
|
|
59
|
+
* @returns El trabajo terminado: un texto, o `{ text, files }` si además
|
|
60
|
+
* entregas archivos. Por ejemplo:
|
|
61
|
+
*
|
|
62
|
+
* return {
|
|
63
|
+
* text: 'Aquí tienes el informe que pediste.',
|
|
64
|
+
* files: [{ name: 'informe.pdf', data: pdf, mime: 'application/pdf' }],
|
|
65
|
+
* };
|
|
66
|
+
*
|
|
67
|
+
* No tienes que calcular ningún hash ni servir ninguna descarga:
|
|
68
|
+
* de eso se ocupa `server.ts`. Ojo con una cosa, y solo con una:
|
|
69
|
+
* si construyes el nombre a partir del encargo, límpialo antes,
|
|
70
|
+
* porque lo escribe quien te contrató.
|
|
31
71
|
*/
|
|
32
|
-
export async function handleTask(brief: string, ctx: TaskContext): Promise<
|
|
72
|
+
export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskResult> {
|
|
33
73
|
// ──────────────────────────────────────────────────────────────────────────
|
|
34
74
|
// EJEMPLO: un agente que responde con un LLM.
|
|
35
75
|
//
|
|
@@ -49,6 +89,65 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
|
|
|
49
89
|
);
|
|
50
90
|
}
|
|
51
91
|
|
|
92
|
+
// Un intento, una revisión y una corrección. Un modelo falla el formato de
|
|
93
|
+
// vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
|
|
94
|
+
// que entregues queda anclado en la cadena y ya no se puede rectificar.
|
|
95
|
+
let queja: string | null = null;
|
|
96
|
+
for (let intento = 1; intento <= 2; intento++) {
|
|
97
|
+
const texto = await pedirAlModelo(brief, apiKey, queja);
|
|
98
|
+
const problema = revisar(brief, texto);
|
|
99
|
+
if (!problema) {
|
|
100
|
+
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
101
|
+
return texto;
|
|
102
|
+
}
|
|
103
|
+
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
104
|
+
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
105
|
+
// positivo no debe costarle al cliente la tarea que ya pagó: es mejor
|
|
106
|
+
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
107
|
+
if (intento === 2) {
|
|
108
|
+
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
109
|
+
return texto;
|
|
110
|
+
}
|
|
111
|
+
queja = problema;
|
|
112
|
+
}
|
|
113
|
+
throw new Error('inalcanzable');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* TU CONTROL DE CALIDAD. Devuelve null si la respuesta vale, o el motivo si no.
|
|
118
|
+
*
|
|
119
|
+
* Lo que devuelvas aquí se le manda al modelo en el segundo intento, así que
|
|
120
|
+
* escribe el motivo como se lo dirías a él: "faltan los puertos 8790 y 8791"
|
|
121
|
+
* corrige mucho más que "respuesta incompleta".
|
|
122
|
+
*
|
|
123
|
+
* Merece la pena rellenarlo con lo que TU agente promete. Un ejemplo real: un
|
|
124
|
+
* agente que convertía texto a JSON recibió tres registros y devolvió uno,
|
|
125
|
+
* tirando los otros dos. Era JSON válido, así que ninguna comprobación de
|
|
126
|
+
* formato se enteró, y el cliente pagó por un tercio de su encargo. Se detectó
|
|
127
|
+
* comparando los números del encargo con los de la respuesta:
|
|
128
|
+
*
|
|
129
|
+
* const perdidos = [...new Set(brief.match(/\d{2,}/g) ?? [])]
|
|
130
|
+
* .filter((n) => !resultado.includes(n));
|
|
131
|
+
* if (perdidos.length) return `faltan datos del encargo: ${perdidos.join(', ')}`;
|
|
132
|
+
*
|
|
133
|
+
* Ojo: eso vale para un agente que extrae datos, y es un desastre para uno que
|
|
134
|
+
* resume o traduce, donde descartar cifras es su trabajo. Comprueba lo que tú
|
|
135
|
+
* prometes, no lo que promete otro.
|
|
136
|
+
*/
|
|
137
|
+
function revisar(brief: string, resultado: string): string | null {
|
|
138
|
+
if (!resultado.trim()) return 'la respuesta vino vacía';
|
|
139
|
+
|
|
140
|
+
// El prompt de abajo prohíbe Markdown, porque ni el dashboard ni Telegram lo
|
|
141
|
+
// renderizan y el cliente ve los asteriscos en crudo. Pedirlo no basta: hay
|
|
142
|
+
// que comprobarlo.
|
|
143
|
+
if (/(\*\*|^#{1,6}\s|```)/m.test(resultado)) {
|
|
144
|
+
return 'la respuesta lleva Markdown (**, # o ```) y el cliente lo verá en crudo: devuélvela en texto plano';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function pedirAlModelo(brief: string, apiKey: string, queja: string | null): Promise<string> {
|
|
52
151
|
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
53
152
|
method: 'POST',
|
|
54
153
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
@@ -75,6 +174,9 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
|
|
|
75
174
|
'RULE 3: deliver finished professional work, with no preamble or meta-commentary.',
|
|
76
175
|
},
|
|
77
176
|
{ role: 'user', content: brief },
|
|
177
|
+
// La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
|
|
178
|
+
// más que repetirle la misma petición a ciegas esperando otra suerte.
|
|
179
|
+
...(queja ? [{ role: 'user' as const, content: `Tu respuesta anterior no vale: ${queja}. Corrígela.` }] : []),
|
|
78
180
|
],
|
|
79
181
|
}),
|
|
80
182
|
});
|
|
@@ -83,7 +185,5 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
|
|
|
83
185
|
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
84
186
|
const text = data.choices?.[0]?.message?.content?.trim();
|
|
85
187
|
if (!text) throw new Error('El modelo devolvió una respuesta vacía.');
|
|
86
|
-
|
|
87
|
-
console.log(`[agente] #${ctx.taskId} resuelta: ${text.length} caracteres`);
|
|
88
188
|
return text;
|
|
89
189
|
}
|
package/template/src/server.ts
CHANGED
|
@@ -24,11 +24,25 @@ import 'dotenv/config';
|
|
|
24
24
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
25
25
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
appendFilesManifest,
|
|
29
|
+
buildQuote,
|
|
30
|
+
createPanalClient,
|
|
31
|
+
MAINNET_ADDRESSES,
|
|
32
|
+
parsePaymentHeader,
|
|
33
|
+
permitNonce,
|
|
34
|
+
readPermitDomain,
|
|
35
|
+
sanitizeFileName,
|
|
36
|
+
TaskStatus,
|
|
37
|
+
verifyAndSettle,
|
|
38
|
+
type DeliveredFile,
|
|
39
|
+
type PermitDomain,
|
|
40
|
+
} from '@panal/sdk';
|
|
28
41
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
29
|
-
import { keccak256, toBytes, verifyMessage } from 'viem';
|
|
42
|
+
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
30
43
|
import type { Address } from 'viem';
|
|
31
44
|
import { handleTask } from './agent.js';
|
|
45
|
+
import type { TaskFile, TaskResult } from './agent.js';
|
|
32
46
|
|
|
33
47
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
34
48
|
const DATA_DIR = process.env.DATA_DIR ?? './data';
|
|
@@ -45,12 +59,66 @@ const panal = createPanalClient({ account, rpcUrl: process.env.RPC_URL });
|
|
|
45
59
|
|
|
46
60
|
console.log(`Agente ${account.address} escuchando en :${PORT}`);
|
|
47
61
|
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// x402: cobrar por llamada, sin escrow.
|
|
64
|
+
//
|
|
65
|
+
// El escrow es para encargos que valen algo: bloquea el pago, hay plazo y hay
|
|
66
|
+
// disputa. Para una consulta de dos milésimas todo eso sobra —el trámite cuesta
|
|
67
|
+
// más que el servicio—, y ahí entra x402: el cliente firma una autorización de
|
|
68
|
+
// pago (gratis, sin gas), tú cobras y respondes en la misma llamada.
|
|
69
|
+
//
|
|
70
|
+
// Es OPCIONAL: sin X402_PRICE en el .env, esta ruta no existe y tu agente
|
|
71
|
+
// funciona igual solo con encargos del escrow.
|
|
72
|
+
//
|
|
73
|
+
// Solo se puede cobrar en un ERC-20 con EIP-2612, no en MON: el esquema entero
|
|
74
|
+
// se apoya en `permit`, y la moneda nativa no lo tiene.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const X402_PRICE = (() => {
|
|
78
|
+
const raw = process.env.X402_PRICE?.trim();
|
|
79
|
+
if (!raw) return null;
|
|
80
|
+
try {
|
|
81
|
+
const wei = parseEther(raw);
|
|
82
|
+
return wei > 0n ? wei : null;
|
|
83
|
+
} catch {
|
|
84
|
+
console.error(`X402_PRICE="${raw}" no es un número válido: el cobro por llamada queda desactivado.`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
const X402_TOKEN: Address = (() => {
|
|
89
|
+
const raw = process.env.X402_TOKEN?.trim();
|
|
90
|
+
return raw && isAddress(raw) ? (raw as Address) : MAINNET_ADDRESSES.panalToken;
|
|
91
|
+
})();
|
|
92
|
+
const X402_SYMBOL = process.env.X402_SYMBOL?.trim() || '$PANAL';
|
|
93
|
+
// En inglés porque viaja en el 402 y lo lee un desconocido de cualquier parte.
|
|
94
|
+
// Cámbialo por lo tuyo con X402_DESCRIPTION en el .env.
|
|
95
|
+
const X402_DESCRIPTION = process.env.X402_DESCRIPTION?.trim() || 'One question to the agent, answered on the spot.';
|
|
96
|
+
|
|
97
|
+
if (X402_PRICE !== null) {
|
|
98
|
+
console.log(`Cobro por llamada activo: ${process.env.X402_PRICE} ${X402_SYMBOL} en POST /x402/ask`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* El dominio EIP-712 del token, leído de la cadena una sola vez.
|
|
103
|
+
*
|
|
104
|
+
* Se cachea porque no cambia nunca y leerlo en cada petición añade una llamada
|
|
105
|
+
* al RPC al camino de una respuesta que cobras al momento. Si el RPC falla, se
|
|
106
|
+
* vuelve a intentar en la siguiente: no se cachea el error.
|
|
107
|
+
*/
|
|
108
|
+
let dominioCache: PermitDomain | null = null;
|
|
109
|
+
async function dominioPermit(): Promise<PermitDomain> {
|
|
110
|
+
if (!dominioCache) dominioCache = await readPermitDomain(panal.publicClient, X402_TOKEN);
|
|
111
|
+
return dominioCache;
|
|
112
|
+
}
|
|
113
|
+
|
|
48
114
|
// ---------------------------------------------------------------------------
|
|
49
115
|
// Almacén: los resultados en disco, para poder servirlos después.
|
|
50
116
|
// ---------------------------------------------------------------------------
|
|
51
117
|
|
|
52
118
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
53
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());
|
|
54
122
|
|
|
55
123
|
function saveResult(taskId: bigint, text: string): void {
|
|
56
124
|
writeFileSync(resultPath(taskId), text, 'utf8');
|
|
@@ -63,6 +131,46 @@ function loadResult(taskId: bigint): string | null {
|
|
|
63
131
|
}
|
|
64
132
|
}
|
|
65
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
|
+
|
|
66
174
|
/** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
|
|
67
175
|
const inFlight = new Set<string>();
|
|
68
176
|
|
|
@@ -92,13 +200,19 @@ async function work(taskId: bigint, brief: string): Promise<void> {
|
|
|
92
200
|
inFlight.add(key);
|
|
93
201
|
try {
|
|
94
202
|
const task = await panal.getTask(taskId);
|
|
95
|
-
const
|
|
203
|
+
const salida = await handleTask(brief, {
|
|
96
204
|
taskId,
|
|
97
205
|
client: task.client,
|
|
98
206
|
amount: task.amount,
|
|
99
207
|
deadline: task.deadline,
|
|
100
208
|
});
|
|
101
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
|
+
|
|
102
216
|
// Primero se guarda y luego se entrega: si el orden fuera al revés y el
|
|
103
217
|
// proceso muriera entre medias, el hash estaría anclado on-chain y el texto
|
|
104
218
|
// perdido, o sea una entrega imposible de cumplir.
|
|
@@ -224,8 +338,125 @@ const server = createServer((req, res) => {
|
|
|
224
338
|
}
|
|
225
339
|
|
|
226
340
|
// Tarjeta de presentación: quién eres y qué sabes hacer.
|
|
341
|
+
//
|
|
342
|
+
// Si cobras por llamada hay que ANUNCIARLO aquí. Durante meses el bot de
|
|
343
|
+
// LexPanal tuvo x402 funcionando y nadie lo usó, sencillamente porque no
|
|
344
|
+
// salía en su tarjeta: un cobro que nadie puede descubrir no existe.
|
|
227
345
|
if (url.pathname === '/agent.json' && req.method === 'GET') {
|
|
228
|
-
json(res, 200, {
|
|
346
|
+
json(res, 200, {
|
|
347
|
+
agent: account.address,
|
|
348
|
+
protocol: 'panal',
|
|
349
|
+
network: 'monad-mainnet',
|
|
350
|
+
...(X402_PRICE !== null
|
|
351
|
+
? {
|
|
352
|
+
x402Ask: {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
path: '/x402/ask',
|
|
355
|
+
scheme: 'eip2612-permit',
|
|
356
|
+
asset: X402_TOKEN,
|
|
357
|
+
assetSymbol: X402_SYMBOL,
|
|
358
|
+
amount: X402_PRICE.toString(),
|
|
359
|
+
payTo: account.address,
|
|
360
|
+
howTo: 'POST {"prompt":"…"} and you get a 402 with the quote. Sign it and repeat with X-Payment.',
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
: {}),
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- Cobro por llamada: pagas y te respondo en el acto ------------------
|
|
369
|
+
if (url.pathname === '/x402/ask' && req.method === 'POST') {
|
|
370
|
+
if (X402_PRICE === null) {
|
|
371
|
+
json(res, 404, { error: 'this agent does not charge per call; hire it through the escrow' });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const body = JSON.parse(await readBody(req)) as { prompt?: string };
|
|
375
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
|
|
376
|
+
if (!prompt || prompt.length > 2000) {
|
|
377
|
+
json(res, 400, { error: 'prompt required, max 2000 characters' });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const domain = await dominioPermit();
|
|
382
|
+
const pagoCrudo = req.headers['x-payment'];
|
|
383
|
+
|
|
384
|
+
// Sin pago: se responde 402 con el presupuesto. Este es el paso que le da
|
|
385
|
+
// por fin sentido a un código de estado que llevaba desde los noventa
|
|
386
|
+
// reservado y sin usar, porque no había forma de pagar en la web.
|
|
387
|
+
if (typeof pagoCrudo !== 'string' || !pagoCrudo.trim()) {
|
|
388
|
+
// Si el cliente dice quién es, se le regala su nonce y se ahorra una
|
|
389
|
+
// consulta a la cadena antes de poder firmar.
|
|
390
|
+
const quien = req.headers['x-payment-payer'];
|
|
391
|
+
const payer = typeof quien === 'string' && isAddress(quien) ? (quien as Address) : null;
|
|
392
|
+
const nonce = payer ? await permitNonce(panal.publicClient, X402_TOKEN, payer).catch(() => undefined) : undefined;
|
|
393
|
+
|
|
394
|
+
res.setHeader('www-authenticate', `eip2612-permit realm="panal", chain="${domain.chainId}"`);
|
|
395
|
+
json(
|
|
396
|
+
res,
|
|
397
|
+
402,
|
|
398
|
+
buildQuote({
|
|
399
|
+
asset: X402_TOKEN,
|
|
400
|
+
assetSymbol: X402_SYMBOL,
|
|
401
|
+
amount: X402_PRICE,
|
|
402
|
+
payTo: account.address,
|
|
403
|
+
resource: '/x402/ask',
|
|
404
|
+
description: X402_DESCRIPTION,
|
|
405
|
+
domain,
|
|
406
|
+
payerNonce: nonce,
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const leido = parsePaymentHeader(pagoCrudo);
|
|
413
|
+
if (!leido.ok) {
|
|
414
|
+
json(res, 400, { error: leido.error });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// SE COBRA ANTES DE SERVIR. Si se sirviera primero y el cobro fallara, el
|
|
419
|
+
// trabajo estaría regalado y no habría forma de recuperarlo.
|
|
420
|
+
const cobro = await verifyAndSettle(
|
|
421
|
+
{ publicClient: panal.publicClient, walletClient: panal.walletClient ?? null, token: X402_TOKEN, domain, payee: account.address },
|
|
422
|
+
leido.payment,
|
|
423
|
+
X402_PRICE,
|
|
424
|
+
);
|
|
425
|
+
if (!cobro.ok) {
|
|
426
|
+
json(res, cobro.status, { error: cobro.error });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
console.log(`[x402] cobrado ${cobro.amount} de ${leido.payment.payer} · tx ${cobro.txHash}`);
|
|
430
|
+
|
|
431
|
+
// Ya está cobrado: pase lo que pase a partir de aquí, hay que responder
|
|
432
|
+
// algo. Si el modelo revienta, se dice; callarse sería quedarse el dinero.
|
|
433
|
+
try {
|
|
434
|
+
const salida = await handleTask(prompt, {
|
|
435
|
+
taskId: null,
|
|
436
|
+
client: leido.payment.payer,
|
|
437
|
+
amount: cobro.amount,
|
|
438
|
+
deadline: 0n,
|
|
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
|
+
}
|
|
451
|
+
res.setHeader('x-payment-tx', cobro.txHash);
|
|
452
|
+
json(res, 200, { answer, paid: { txHash: cobro.txHash, amount: cobro.amount.toString(), asset: X402_TOKEN } });
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error(`[x402] cobrado pero falló al responder: ${err instanceof Error ? err.message : err}`);
|
|
455
|
+
json(res, 502, {
|
|
456
|
+
error: 'the payment went through but the agent could not answer',
|
|
457
|
+
paid: { txHash: cobro.txHash, amount: cobro.amount.toString() },
|
|
458
|
+
});
|
|
459
|
+
}
|
|
229
460
|
return;
|
|
230
461
|
}
|
|
231
462
|
|
|
@@ -327,6 +558,60 @@ const server = createServer((req, res) => {
|
|
|
327
558
|
return;
|
|
328
559
|
}
|
|
329
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
|
+
|
|
330
615
|
json(res, 404, { error: 'no existe' });
|
|
331
616
|
})().catch((err) => {
|
|
332
617
|
console.error(`[http] ${err instanceof Error ? err.message : err}`);
|