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/i18n.js +959 -0
- package/dist/index.js +157 -42
- package/package.json +2 -2
- package/template/_package.json +1 -1
- package/template/src/agent.ts +78 -6
- package/template/src/server.ts +171 -3
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,
|
|
8
|
-
*
|
|
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) =>
|
|
38
|
-
dim: (s) =>
|
|
39
|
-
green: (s) =>
|
|
40
|
-
yellow: (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(
|
|
51
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
52
|
-
fail(
|
|
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(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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(
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
console.log(c.
|
|
102
|
-
console.log(` ${c.bold(
|
|
103
|
-
console.log(c.dim(`
|
|
104
|
-
console.log(c.
|
|
105
|
-
console.log(` ${c.bold('
|
|
106
|
-
console.log(c.dim(`
|
|
107
|
-
console.log(c.dim(`
|
|
108
|
-
console.log(` ${c.bold('
|
|
109
|
-
console.log(c.dim(`
|
|
110
|
-
console.log(c.dim(`
|
|
111
|
-
console.log(
|
|
112
|
-
console.log(
|
|
113
|
-
console.log(c.dim(
|
|
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.
|
|
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
|
}
|
package/template/_package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -12,16 +12,24 @@
|
|
|
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
|
+
/** 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
|
}
|
package/template/src/server.ts
CHANGED
|
@@ -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 {
|
|
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, {
|
|
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
|
|