create-panal-agent 0.1.3 → 0.3.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/index.js CHANGED
@@ -2,20 +2,26 @@
2
2
  /**
3
3
  * create-panal-agent — genera un agente de Panal listo para funcionar.
4
4
  *
5
- * npx create-panal-agent mi-agente
5
+ * npx create-panal-agent mi-agente [--lang es] [--no-input]
6
6
  *
7
- * Copia la plantilla, le pone el nombre, y genera una wallet dedicada nueva
8
- * para el agente. Después son tres pasos: instalar, publicar el endpoint y
9
- * registrarse.
7
+ * Copia la plantilla, le pone el nombre, genera una wallet dedicada nueva y
8
+ * escribe la documentación del proyecto en el idioma de quien lo ejecuta.
9
+ * Después son tres pasos: instalar, publicar el endpoint y registrarse.
10
10
  *
11
11
  * Por qué existe: antes, poner un agente en Panal significaba leer una guía de
12
12
  * 529 líneas, montar un VPS y mantenerlo encendido. Eso no es un alta, es un
13
13
  * trabajo — y explica por qué el marketplace tenía cinco agentes.
14
+ *
15
+ * Por qué en diez idiomas: el alta es lo primero que ve un desarrollador, y en
16
+ * ella van las tres cosas que salen caras si se malinterpretan —la clave
17
+ * privada, el endpoint público y el gas—. Un aviso que no se entiende es un
18
+ * aviso que no existe.
14
19
  */
15
20
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
16
21
  import { dirname, join, resolve } from 'node:path';
17
22
  import { fileURLToPath } from 'node:url';
18
23
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
24
+ import { CATALOG, LANGS, fill, isLang, resolveLang } from './i18n.js';
19
25
  const HERE = dirname(fileURLToPath(import.meta.url));
20
26
  /** La plantilla se empaqueta junto al binario (ver `files` del package.json). */
21
27
  const TEMPLATE = resolve(HERE, '..', 'template');
@@ -34,33 +40,81 @@ const RENAMES = {
34
40
  _gitignore: '.gitignore',
35
41
  };
36
42
  const c = {
37
- bold: (s) => `${s}`,
38
- dim: (s) => `${s}`,
39
- green: (s) => `${s}`,
40
- yellow: (s) => `${s}`,
43
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
44
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
45
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
46
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
41
47
  };
42
48
  function fail(msg) {
43
49
  console.error(`\n${msg}\n`);
44
50
  process.exit(1);
45
51
  }
52
+ /**
53
+ * Se admite `--lang es` y `--lang=es`. No es capricho: la primera forma es la
54
+ * que teclea una persona y la segunda la que escriben los scripts.
55
+ */
56
+ function parseArgs(argv) {
57
+ const args = { name: null, lang: null, noInput: false, help: false, version: false };
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const a = argv[i];
60
+ if (a === '--help' || a === '-h')
61
+ args.help = true;
62
+ else if (a === '--version' || a === '-v')
63
+ args.version = true;
64
+ else if (a === '--no-input' || a === '--yes' || a === '-y')
65
+ args.noInput = true;
66
+ else if (a === '--lang')
67
+ args.lang = argv[++i] ?? '';
68
+ else if (a.startsWith('--lang='))
69
+ args.lang = a.slice('--lang='.length);
70
+ else if (!a.startsWith('-') && args.name === null)
71
+ args.name = a;
72
+ }
73
+ return args;
74
+ }
46
75
  /** Nombre válido de carpeta y de paquete npm. */
47
- function validateName(raw) {
48
- const name = raw.trim().replace(/^\.\//, '');
76
+ function validateName(raw, t) {
77
+ const name = (raw ?? '').trim().replace(/^\.\//, '');
49
78
  if (!name)
50
- fail('Dile cómo se llama tu agente: npx create-panal-agent mi-agente');
51
- if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
52
- fail(`"${name}" no vale como nombre.\n` +
53
- 'Usa minúsculas, números y guiones: mi-agente, traductor-tecnico, resumidor.');
54
- }
79
+ fail(t.errNoName);
80
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
81
+ fail(fill(t.errBadName, { name }));
55
82
  return name;
56
83
  }
84
+ /**
85
+ * Selector interactivo. Solo aparece cuando hay una persona delante: sin TTY
86
+ * —en CI, en un Dockerfile, tras una tubería— preguntar cuelga el proceso, así
87
+ * que en ese caso se cae al inglés sin ruido.
88
+ */
89
+ async function askLang() {
90
+ const { createInterface } = await import('node:readline/promises');
91
+ console.log('');
92
+ LANGS.forEach((l, i) => {
93
+ console.log(` ${c.bold(String(i + 1).padStart(2))} ${l.label}${i === 0 ? c.dim(' (default)') : ''}`);
94
+ });
95
+ console.log('');
96
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
97
+ try {
98
+ // La pregunta va en inglés porque todavía no sabemos en qué idioma leer.
99
+ const answer = (await rl.question(`${CATALOG.en.pickLang} [1-${LANGS.length}] `)).trim();
100
+ const n = Number(answer);
101
+ if (Number.isInteger(n) && n >= 1 && n <= LANGS.length)
102
+ return LANGS[n - 1].code;
103
+ if (isLang(answer.toLowerCase()))
104
+ return answer.toLowerCase();
105
+ return 'en';
106
+ }
107
+ finally {
108
+ rl.close();
109
+ }
110
+ }
57
111
  /** Copia la plantilla sustituyendo los marcadores. */
58
- function copyTemplate(dest, name) {
112
+ function copyTemplate(dest, name, t) {
59
113
  cpSync(TEMPLATE, dest, { recursive: true });
60
114
  for (const [from, to] of Object.entries(RENAMES)) {
61
115
  const src = join(dest, from);
62
116
  if (!existsSync(src))
63
- fail(`La plantilla está incompleta: falta ${from}. El paquete está mal instalado.`);
117
+ fail(fill(t.errTemplateMissing, { name: from }));
64
118
  renameSync(src, join(dest, to));
65
119
  }
66
120
  const walk = (dir) => {
@@ -78,38 +132,99 @@ function copyTemplate(dest, name) {
78
132
  };
79
133
  walk(dest);
80
134
  }
81
- function main() {
82
- const name = validateName(process.argv[2] ?? '');
83
- const dest = resolve(process.cwd(), name);
84
- if (existsSync(dest) && readdirSync(dest).length > 0) {
85
- fail(`La carpeta ${name}/ ya existe y no está vacía. Elige otro nombre o bórrala.`);
135
+ /**
136
+ * El `.env.example` se escribe aquí, no se copia: sus comentarios explican qué
137
+ * es cada clave y son justo lo que hay que entender antes de pegar una clave
138
+ * privada en un servidor.
139
+ */
140
+ function writeEnvExample(dest, t) {
141
+ const bloque = (texto) => texto
142
+ .split('\n')
143
+ .map((l) => `# ${l}`)
144
+ .join('\n');
145
+ const contenido = [
146
+ bloque(t.env.key),
147
+ 'AGENT_PRIVATE_KEY=',
148
+ '',
149
+ bloque(t.env.port),
150
+ 'PORT=8787',
151
+ '',
152
+ bloque(t.env.model),
153
+ '# OpenAI https://api.openai.com/v1 gpt-4o-mini',
154
+ '# DeepSeek https://api.deepseek.com/v1 deepseek-chat',
155
+ '# Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile',
156
+ 'LLM_BASE_URL=https://api.deepseek.com/v1',
157
+ 'LLM_API_KEY=',
158
+ 'LLM_MODEL=deepseek-chat',
159
+ '',
160
+ bloque(t.env.rpc),
161
+ 'RPC_URL=',
162
+ '',
163
+ bloque(t.env.data),
164
+ 'DATA_DIR=./data',
165
+ '',
166
+ bloque(t.env.x402),
167
+ 'X402_PRICE=',
168
+ '# X402_TOKEN=0x2e2e44e7fa6178822d4397299f719e89d1a67777',
169
+ '# X402_SYMBOL=$PANAL',
170
+ '# X402_DESCRIPTION=',
171
+ '',
172
+ ].join('\n');
173
+ writeFileSync(join(dest, '.env.example'), contenido, 'utf8');
174
+ return contenido;
175
+ }
176
+ async function main() {
177
+ const args = parseArgs(process.argv.slice(2));
178
+ // --help y --version salen antes que nada: son las dos cosas que se piden
179
+ // cuando aún no se sabe qué hace el comando.
180
+ const pedido = resolveLang(args.lang, process.env);
181
+ if (args.lang && !pedido)
182
+ fail(fill(CATALOG.en.errBadLang, { name: args.lang }));
183
+ if (args.version) {
184
+ const pkg = JSON.parse(readFileSync(resolve(HERE, '..', 'package.json'), 'utf8'));
185
+ console.log(pkg.version);
186
+ return;
86
187
  }
188
+ if (args.help) {
189
+ console.log(`\n${(pedido ? CATALOG[pedido] : CATALOG.en).usage}\n`);
190
+ return;
191
+ }
192
+ // Sin idioma decidido: se pregunta si hay alguien delante, y si no, inglés.
193
+ const interactivo = !args.noInput && process.stdin.isTTY === true && process.stdout.isTTY === true;
194
+ const lang = pedido ?? (interactivo ? await askLang() : 'en');
195
+ const t = CATALOG[lang];
196
+ const name = validateName(args.name, t);
197
+ const dest = resolve(process.cwd(), name);
198
+ if (existsSync(dest) && readdirSync(dest).length > 0)
199
+ fail(fill(t.errDirExists, { name }));
87
200
  if (!existsSync(TEMPLATE))
88
- fail(`No encuentro la plantilla en ${TEMPLATE}. El paquete está mal instalado.`);
201
+ fail(fill(t.errTemplateMissing, { name: TEMPLATE }));
89
202
  mkdirSync(dest, { recursive: true });
90
- copyTemplate(dest, name);
203
+ copyTemplate(dest, name, t);
204
+ const envExample = writeEnvExample(dest, t);
91
205
  // Wallet dedicada, generada aquí. La alternativa —"crea una wallet y pega la
92
206
  // clave"— es donde la gente acaba pegando la de su MetaMask personal, que es
93
207
  // exactamente lo que no debe vivir en un servidor.
94
208
  const privateKey = generatePrivateKey();
95
209
  const address = privateKeyToAccount(privateKey).address;
96
- const envExample = readFileSync(join(dest, '.env.example'), 'utf8');
97
210
  writeFileSync(join(dest, '.env'), envExample.replace('AGENT_PRIVATE_KEY=', `AGENT_PRIVATE_KEY=${privateKey}`), 'utf8');
98
- console.log(`\n${c.green('✓')} ${c.bold(name)} creado.\n`);
99
- console.log(` Wallet del agente: ${c.bold(address)}`);
100
- console.log(c.dim(` Su clave está en ${name}/.env, que ya está en el .gitignore.\n`));
101
- console.log(c.bold('Lo que falta:\n'));
102
- console.log(` ${c.bold('1.')} Instalar y darle algo de MON para el gas`);
103
- console.log(c.dim(` cd ${name} && npm install`));
104
- console.log(c.dim(` manda ~0.5 MON a ${address}\n`));
105
- console.log(` ${c.bold('2.')} Escribir lo que hace tu agente`);
106
- console.log(c.dim(` edita src/agent.ts ${c.dim('(es el único archivo que tienes que tocar)')}`));
107
- console.log(c.dim(` si usa un modelo, pon LLM_API_KEY en el .env\n`));
108
- console.log(` ${c.bold('3.')} Publicarlo en una URL https y registrarte`);
109
- console.log(c.dim(` npm start ${c.dim('(y expón el puerto con https)')}`));
110
- console.log(c.dim(` PUBLIC_URL=https://tu-dominio npm run register\n`));
111
- console.log(`${c.yellow('Ojo:')} el endpoint tiene que ser https y público. Sin él el cliente no puede`);
112
- console.log('mandarte el encargo ni descargar su resultado, y el agente queda de adorno.\n');
113
- console.log(c.dim('Guía completa: https://github.com/AgentHiv/Panal/tree/main/create-agent\n'));
211
+ // El README del proyecto, en su idioma. Es donde vive lo que no cabe en la
212
+ // pantalla de alta: cómo se cobra, qué hace cada archivo y qué rompe agentes.
213
+ writeFileSync(join(dest, 'README.md'), fill(t.readme, { name, address }), 'utf8');
214
+ console.log(`\n${c.green('✓')} ${c.bold(fill(t.created, { name }))}\n`);
215
+ console.log(` ${t.walletLabel} ${c.bold(address)}`);
216
+ console.log(c.dim(` ${fill(t.walletNote, { name })}\n`));
217
+ console.log(c.bold(`${t.stepsTitle}\n`));
218
+ console.log(` ${c.bold('1.')} ${t.s1Title}`);
219
+ console.log(c.dim(` ${fill(t.s1Install, { name })}`));
220
+ console.log(c.dim(` ${fill(t.s1Fund, { address })}\n`));
221
+ console.log(` ${c.bold('2.')} ${t.s2Title}`);
222
+ console.log(c.dim(` ${t.s2Edit}`));
223
+ console.log(c.dim(` ${t.s2Key}\n`));
224
+ console.log(` ${c.bold('3.')} ${t.s3Title}`);
225
+ console.log(c.dim(` ${t.s3Start}`));
226
+ console.log(c.dim(` ${t.s3Register}\n`));
227
+ console.log(`${c.yellow(t.warnLabel)} ${t.warnBody}\n`);
228
+ console.log(c.dim(`${t.docs}\n`));
114
229
  }
115
- main();
230
+ void main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.1.3",
3
+ "version": "0.3.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",
@@ -40,6 +40,6 @@
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "tsx test/scaffold.test.ts"
43
+ "test": "tsx test/i18n.test.ts && tsx test/scaffold.test.ts"
44
44
  }
45
45
  }
@@ -10,7 +10,7 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@panal/sdk": "^0.4.0",
13
+ "@panal/sdk": "^0.5.0",
14
14
  "dotenv": "^17.0.0",
15
15
  "tsx": "^4.19.0",
16
16
  "viem": "^2.21.0"
@@ -12,16 +12,24 @@
12
12
  */
13
13
 
14
14
  export interface TaskContext {
15
- /** El id de la tarea en el escrow, por si quieres registrarlo. */
16
- taskId: bigint;
17
- /** La dirección del cliente que te contrató. */
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
+ /** Cómo se llama esto en los logs: `#31` si viene del escrow, `x402` si no. */
29
+ function etiqueta(ctx: TaskContext): string {
30
+ return ctx.taskId === null ? 'x402' : `#${ctx.taskId}`;
31
+ }
32
+
25
33
  /**
26
34
  * Tu agente.
27
35
  *
@@ -49,9 +57,72 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
49
57
  );
50
58
  }
51
59
 
60
+ // Un intento, una revisión y una corrección. Un modelo falla el formato de
61
+ // vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
62
+ // que entregues queda anclado en la cadena y ya no se puede rectificar.
63
+ let queja: string | null = null;
64
+ for (let intento = 1; intento <= 2; intento++) {
65
+ const texto = await pedirAlModelo(brief, apiKey, queja);
66
+ const problema = revisar(brief, texto);
67
+ if (!problema) {
68
+ console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
69
+ return texto;
70
+ }
71
+ console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
72
+ // A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
73
+ // positivo no debe costarle al cliente la tarea que ya pagó: es mejor
74
+ // entregar algo imperfecto y que él decida, que dejarlo sin nada.
75
+ if (intento === 2) {
76
+ console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
77
+ return texto;
78
+ }
79
+ queja = problema;
80
+ }
81
+ throw new Error('inalcanzable');
82
+ }
83
+
84
+ /**
85
+ * TU CONTROL DE CALIDAD. Devuelve null si la respuesta vale, o el motivo si no.
86
+ *
87
+ * Lo que devuelvas aquí se le manda al modelo en el segundo intento, así que
88
+ * escribe el motivo como se lo dirías a él: "faltan los puertos 8790 y 8791"
89
+ * corrige mucho más que "respuesta incompleta".
90
+ *
91
+ * Merece la pena rellenarlo con lo que TU agente promete. Un ejemplo real: un
92
+ * agente que convertía texto a JSON recibió tres registros y devolvió uno,
93
+ * tirando los otros dos. Era JSON válido, así que ninguna comprobación de
94
+ * formato se enteró, y el cliente pagó por un tercio de su encargo. Se detectó
95
+ * comparando los números del encargo con los de la respuesta:
96
+ *
97
+ * const perdidos = [...new Set(brief.match(/\d{2,}/g) ?? [])]
98
+ * .filter((n) => !resultado.includes(n));
99
+ * if (perdidos.length) return `faltan datos del encargo: ${perdidos.join(', ')}`;
100
+ *
101
+ * Ojo: eso vale para un agente que extrae datos, y es un desastre para uno que
102
+ * resume o traduce, donde descartar cifras es su trabajo. Comprueba lo que tú
103
+ * prometes, no lo que promete otro.
104
+ */
105
+ function revisar(brief: string, resultado: string): string | null {
106
+ if (!resultado.trim()) return 'la respuesta vino vacía';
107
+
108
+ // El prompt de abajo prohíbe Markdown, porque ni el dashboard ni Telegram lo
109
+ // renderizan y el cliente ve los asteriscos en crudo. Pedirlo no basta: hay
110
+ // que comprobarlo.
111
+ if (/(\*\*|^#{1,6}\s|```)/m.test(resultado)) {
112
+ return 'la respuesta lleva Markdown (**, # o ```) y el cliente lo verá en crudo: devuélvela en texto plano';
113
+ }
114
+
115
+ return null;
116
+ }
117
+
118
+ async function pedirAlModelo(brief: string, apiKey: string, queja: string | null): Promise<string> {
52
119
  const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
53
120
  method: 'POST',
54
121
  headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
122
+ // Sin este tope, un modelo que se cuelga deja la tarea colgada para
123
+ // siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
124
+ // que vence el plazo. Pasó de verdad, en mainnet.
125
+ signal: AbortSignal.timeout(120_000),
55
126
  body: JSON.stringify({
56
127
  model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
57
128
  messages: [
@@ -71,6 +142,9 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
71
142
  'RULE 3: deliver finished professional work, with no preamble or meta-commentary.',
72
143
  },
73
144
  { role: 'user', content: brief },
145
+ // La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
146
+ // más que repetirle la misma petición a ciegas esperando otra suerte.
147
+ ...(queja ? [{ role: 'user' as const, content: `Tu respuesta anterior no vale: ${queja}. Corrígela.` }] : []),
74
148
  ],
75
149
  }),
76
150
  });
@@ -79,7 +153,5 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
79
153
  const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
80
154
  const text = data.choices?.[0]?.message?.content?.trim();
81
155
  if (!text) throw new Error('El modelo devolvió una respuesta vacía.');
82
-
83
- console.log(`[agente] #${ctx.taskId} resuelta: ${text.length} caracteres`);
84
156
  return text;
85
157
  }
@@ -24,9 +24,19 @@ 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 { createPanalClient, TaskStatus } from '@panal/sdk';
27
+ import {
28
+ buildQuote,
29
+ createPanalClient,
30
+ MAINNET_ADDRESSES,
31
+ parsePaymentHeader,
32
+ permitNonce,
33
+ readPermitDomain,
34
+ TaskStatus,
35
+ verifyAndSettle,
36
+ type PermitDomain,
37
+ } from '@panal/sdk';
28
38
  import { privateKeyToAccount } from 'viem/accounts';
29
- import { keccak256, toBytes, verifyMessage } from 'viem';
39
+ import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
30
40
  import type { Address } from 'viem';
31
41
  import { handleTask } from './agent.js';
32
42
 
@@ -45,6 +55,58 @@ const panal = createPanalClient({ account, rpcUrl: process.env.RPC_URL });
45
55
 
46
56
  console.log(`Agente ${account.address} escuchando en :${PORT}`);
47
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // x402: cobrar por llamada, sin escrow.
60
+ //
61
+ // El escrow es para encargos que valen algo: bloquea el pago, hay plazo y hay
62
+ // disputa. Para una consulta de dos milésimas todo eso sobra —el trámite cuesta
63
+ // más que el servicio—, y ahí entra x402: el cliente firma una autorización de
64
+ // pago (gratis, sin gas), tú cobras y respondes en la misma llamada.
65
+ //
66
+ // Es OPCIONAL: sin X402_PRICE en el .env, esta ruta no existe y tu agente
67
+ // funciona igual solo con encargos del escrow.
68
+ //
69
+ // Solo se puede cobrar en un ERC-20 con EIP-2612, no en MON: el esquema entero
70
+ // se apoya en `permit`, y la moneda nativa no lo tiene.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const X402_PRICE = (() => {
74
+ const raw = process.env.X402_PRICE?.trim();
75
+ if (!raw) return null;
76
+ try {
77
+ const wei = parseEther(raw);
78
+ return wei > 0n ? wei : null;
79
+ } catch {
80
+ console.error(`X402_PRICE="${raw}" no es un número válido: el cobro por llamada queda desactivado.`);
81
+ return null;
82
+ }
83
+ })();
84
+ const X402_TOKEN: Address = (() => {
85
+ const raw = process.env.X402_TOKEN?.trim();
86
+ return raw && isAddress(raw) ? (raw as Address) : MAINNET_ADDRESSES.panalToken;
87
+ })();
88
+ const X402_SYMBOL = process.env.X402_SYMBOL?.trim() || '$PANAL';
89
+ // En inglés porque viaja en el 402 y lo lee un desconocido de cualquier parte.
90
+ // Cámbialo por lo tuyo con X402_DESCRIPTION en el .env.
91
+ const X402_DESCRIPTION = process.env.X402_DESCRIPTION?.trim() || 'One question to the agent, answered on the spot.';
92
+
93
+ if (X402_PRICE !== null) {
94
+ console.log(`Cobro por llamada activo: ${process.env.X402_PRICE} ${X402_SYMBOL} en POST /x402/ask`);
95
+ }
96
+
97
+ /**
98
+ * El dominio EIP-712 del token, leído de la cadena una sola vez.
99
+ *
100
+ * Se cachea porque no cambia nunca y leerlo en cada petición añade una llamada
101
+ * al RPC al camino de una respuesta que cobras al momento. Si el RPC falla, se
102
+ * vuelve a intentar en la siguiente: no se cachea el error.
103
+ */
104
+ let dominioCache: PermitDomain | null = null;
105
+ async function dominioPermit(): Promise<PermitDomain> {
106
+ if (!dominioCache) dominioCache = await readPermitDomain(panal.publicClient, X402_TOKEN);
107
+ return dominioCache;
108
+ }
109
+
48
110
  // ---------------------------------------------------------------------------
49
111
  // Almacén: los resultados en disco, para poder servirlos después.
50
112
  // ---------------------------------------------------------------------------
@@ -224,8 +286,114 @@ const server = createServer((req, res) => {
224
286
  }
225
287
 
226
288
  // Tarjeta de presentación: quién eres y qué sabes hacer.
289
+ //
290
+ // Si cobras por llamada hay que ANUNCIARLO aquí. Durante meses el bot de
291
+ // LexPanal tuvo x402 funcionando y nadie lo usó, sencillamente porque no
292
+ // salía en su tarjeta: un cobro que nadie puede descubrir no existe.
227
293
  if (url.pathname === '/agent.json' && req.method === 'GET') {
228
- json(res, 200, { agent: account.address, protocol: 'panal', network: 'monad-mainnet' });
294
+ json(res, 200, {
295
+ agent: account.address,
296
+ protocol: 'panal',
297
+ network: 'monad-mainnet',
298
+ ...(X402_PRICE !== null
299
+ ? {
300
+ x402Ask: {
301
+ method: 'POST',
302
+ path: '/x402/ask',
303
+ scheme: 'eip2612-permit',
304
+ asset: X402_TOKEN,
305
+ assetSymbol: X402_SYMBOL,
306
+ amount: X402_PRICE.toString(),
307
+ payTo: account.address,
308
+ howTo: 'POST {"prompt":"…"} and you get a 402 with the quote. Sign it and repeat with X-Payment.',
309
+ },
310
+ }
311
+ : {}),
312
+ });
313
+ return;
314
+ }
315
+
316
+ // ---- Cobro por llamada: pagas y te respondo en el acto ------------------
317
+ if (url.pathname === '/x402/ask' && req.method === 'POST') {
318
+ if (X402_PRICE === null) {
319
+ json(res, 404, { error: 'this agent does not charge per call; hire it through the escrow' });
320
+ return;
321
+ }
322
+ const body = JSON.parse(await readBody(req)) as { prompt?: string };
323
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
324
+ if (!prompt || prompt.length > 2000) {
325
+ json(res, 400, { error: 'prompt required, max 2000 characters' });
326
+ return;
327
+ }
328
+
329
+ const domain = await dominioPermit();
330
+ const pagoCrudo = req.headers['x-payment'];
331
+
332
+ // Sin pago: se responde 402 con el presupuesto. Este es el paso que le da
333
+ // por fin sentido a un código de estado que llevaba desde los noventa
334
+ // reservado y sin usar, porque no había forma de pagar en la web.
335
+ if (typeof pagoCrudo !== 'string' || !pagoCrudo.trim()) {
336
+ // Si el cliente dice quién es, se le regala su nonce y se ahorra una
337
+ // consulta a la cadena antes de poder firmar.
338
+ const quien = req.headers['x-payment-payer'];
339
+ const payer = typeof quien === 'string' && isAddress(quien) ? (quien as Address) : null;
340
+ const nonce = payer ? await permitNonce(panal.publicClient, X402_TOKEN, payer).catch(() => undefined) : undefined;
341
+
342
+ res.setHeader('www-authenticate', `eip2612-permit realm="panal", chain="${domain.chainId}"`);
343
+ json(
344
+ res,
345
+ 402,
346
+ buildQuote({
347
+ asset: X402_TOKEN,
348
+ assetSymbol: X402_SYMBOL,
349
+ amount: X402_PRICE,
350
+ payTo: account.address,
351
+ resource: '/x402/ask',
352
+ description: X402_DESCRIPTION,
353
+ domain,
354
+ payerNonce: nonce,
355
+ }),
356
+ );
357
+ return;
358
+ }
359
+
360
+ const leido = parsePaymentHeader(pagoCrudo);
361
+ if (!leido.ok) {
362
+ json(res, 400, { error: leido.error });
363
+ return;
364
+ }
365
+
366
+ // SE COBRA ANTES DE SERVIR. Si se sirviera primero y el cobro fallara, el
367
+ // trabajo estaría regalado y no habría forma de recuperarlo.
368
+ const cobro = await verifyAndSettle(
369
+ { publicClient: panal.publicClient, walletClient: panal.walletClient ?? null, token: X402_TOKEN, domain, payee: account.address },
370
+ leido.payment,
371
+ X402_PRICE,
372
+ );
373
+ if (!cobro.ok) {
374
+ json(res, cobro.status, { error: cobro.error });
375
+ return;
376
+ }
377
+ console.log(`[x402] cobrado ${cobro.amount} de ${leido.payment.payer} · tx ${cobro.txHash}`);
378
+
379
+ // Ya está cobrado: pase lo que pase a partir de aquí, hay que responder
380
+ // algo. Si el modelo revienta, se dice; callarse sería quedarse el dinero.
381
+ try {
382
+ const answer = await handleTask(prompt, {
383
+ taskId: null,
384
+ client: leido.payment.payer,
385
+ amount: cobro.amount,
386
+ deadline: 0n,
387
+ });
388
+ res.setHeader('x-payment-tx', cobro.txHash);
389
+ json(res, 200, { answer, paid: { txHash: cobro.txHash, amount: cobro.amount.toString(), asset: X402_TOKEN } });
390
+ } catch (err) {
391
+ console.error(`[x402] cobrado pero falló al responder: ${err instanceof Error ? err.message : err}`);
392
+ json(res, 502, {
393
+ error: 'the payment went through but the agent could not answer',
394
+ paid: { txHash: cobro.txHash, amount: cobro.amount.toString() },
395
+ });
396
+ }
229
397
  return;
230
398
  }
231
399