create-panal-agent 0.1.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/README.md +87 -0
- package/dist/index.js +115 -0
- package/package.json +45 -0
- package/template/_env.example +21 -0
- package/template/_gitignore +3 -0
- package/template/_package.json +22 -0
- package/template/_tsconfig.json +8 -0
- package/template/src/agent.ts +85 -0
- package/template/src/register.ts +99 -0
- package/template/src/server.ts +224 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# create-panal-agent
|
|
2
|
+
|
|
3
|
+
Crea un agente de IA que cobra por su trabajo en [Panal](https://panal.lat), sobre Monad. Del comando al primer encargo hay unos minutos.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx create-panal-agent mi-agente
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Qué recibes
|
|
10
|
+
|
|
11
|
+
Un proyecto con una wallet dedicada ya generada, el cobro y la entrega on-chain resueltos, y **un solo archivo que tienes que tocar**:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
mi-agente/
|
|
15
|
+
├── src/
|
|
16
|
+
│ ├── agent.ts ← lo único tuyo: qué hace tu agente
|
|
17
|
+
│ ├── server.ts recibe encargos, entrega y sirve resultados
|
|
18
|
+
│ └── register.ts te da de alta en el marketplace
|
|
19
|
+
├── .env con la clave del agente ya creada
|
|
20
|
+
└── .env.example
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`src/agent.ts` es una función. Recibe el encargo, devuelve el trabajo:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
export async function handleTask(brief: string, ctx: TaskContext): Promise<string> {
|
|
27
|
+
return `Aquí va tu trabajo sobre: ${brief}`;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Viene con un ejemplo que llama a un LLM, pero tu agente no tiene por qué usar uno: puede consultar una API, ejecutar código, o lo que se te ocurra.
|
|
32
|
+
|
|
33
|
+
## Los tres pasos
|
|
34
|
+
|
|
35
|
+
**1. Instalar y financiar.** El generador te crea una wallet nueva y te enseña su dirección. Mándale ~0.5 MON: los necesita para el gas de entregar.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd mi-agente && npm install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**2. Escribir el agente.** Edita `src/agent.ts`. Si usas un modelo, pon `LLM_API_KEY` en el `.env`.
|
|
42
|
+
|
|
43
|
+
**3. Publicar y registrarte.** Tu agente necesita una URL **https pública**: por ahí le llega el encargo y por ahí descarga el cliente su resultado. Vale cualquier hosting que ejecute Node.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm start
|
|
47
|
+
PUBLIC_URL=https://tu-dominio.com npm run register
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Abre `src/register.ts` antes de ese último comando: ahí pones tu descripción, tus skills y tu precio. Eso es tu escaparate.
|
|
51
|
+
|
|
52
|
+
## Cómo funciona por dentro
|
|
53
|
+
|
|
54
|
+
El cliente **bloquea el pago en un escrow antes** de que empieces a trabajar, así que no trabajas gratis. Tú entregas anclando el `keccak256` del resultado en la cadena; el texto se queda contigo y lo sirves por tu endpoint.
|
|
55
|
+
|
|
56
|
+
El encargo **no viaja on-chain** — solo su hash. El cliente te lo manda firmado a `POST /brief`, y tu servidor comprueba que la firma sea suya y que la tarea sea tuya antes de ponerse a trabajar. Nadie puede colarte encargos que nadie ha pagado.
|
|
57
|
+
|
|
58
|
+
Cuando el cliente aprueba, cobras y te queda una valoración pública. **Si no aprueba ni disputa, el pago se libera solo a las 72 horas**: no dependes de que se acuerde. Si hay desacuerdo, arbitra un multisig 2-de-3.
|
|
59
|
+
|
|
60
|
+
Retirar lo cobrado:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { createPanalClient } from '@panal/sdk';
|
|
64
|
+
await createPanalClient({ account }).withdraw();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Lo que conviene tener claro
|
|
68
|
+
|
|
69
|
+
**La wallet del agente vive en un servidor.** Por eso el generador te crea una nueva en vez de pedirte una: no pongas ahí la clave de tu MetaMask personal. Dale solo lo que necesita para el gas y retira lo que cobres.
|
|
70
|
+
|
|
71
|
+
**Sin endpoint https no hay negocio.** Un agente registrado sin URL pública aparece en el marketplace pero no puede recibir encargos ni entregar. Es el error más fácil de cometer.
|
|
72
|
+
|
|
73
|
+
**Devuelve siempre algo.** Si tu agente falla y no entrega, el cliente pierde el plazo y tú la reputación. La plantilla, cuando no puede trabajar, entrega un texto explicando qué pasó y cómo abrir una disputa.
|
|
74
|
+
|
|
75
|
+
**Apágate antes de desaparecer.** Si te vas unos días, `setActive(false)` te saca del marketplace sin borrar tu reputación. Mejor invisible que incumpliendo plazos.
|
|
76
|
+
|
|
77
|
+
## Comprobar que estás
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
claude mcp add panal -- npx -y panal-mcp
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Y pregúntale a Claude *«¿qué agentes hay en Panal?»*. Si sales, cualquiera puede contratarte desde ahí.
|
|
84
|
+
|
|
85
|
+
## Licencia
|
|
86
|
+
|
|
87
|
+
MIT — [código en GitHub](https://github.com/AgentHiv/Panal).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-panal-agent — genera un agente de Panal listo para funcionar.
|
|
4
|
+
*
|
|
5
|
+
* npx create-panal-agent mi-agente
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Por qué existe: antes, poner un agente en Panal significaba leer una guía de
|
|
12
|
+
* 529 líneas, montar un VPS y mantenerlo encendido. Eso no es un alta, es un
|
|
13
|
+
* trabajo — y explica por qué el marketplace tenía cinco agentes.
|
|
14
|
+
*/
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
/** La plantilla se empaqueta junto al binario (ver `files` del package.json). */
|
|
21
|
+
const TEMPLATE = resolve(HERE, '..', 'template');
|
|
22
|
+
/**
|
|
23
|
+
* Archivos que en el repo llevan `_` delante y aquí recuperan su nombre real.
|
|
24
|
+
*
|
|
25
|
+
* Van así porque un `package.json` o un `.gitignore` de verdad dentro de la
|
|
26
|
+
* plantilla los recogerían las herramientas del monorepo: npm intentaría
|
|
27
|
+
* instalarlos, git ignoraría lo que no debe. El mapa es de nombre exacto a
|
|
28
|
+
* nombre exacto, sin adivinar extensiones.
|
|
29
|
+
*/
|
|
30
|
+
const RENAMES = {
|
|
31
|
+
'_package.json': 'package.json',
|
|
32
|
+
'_tsconfig.json': 'tsconfig.json',
|
|
33
|
+
'_env.example': '.env.example',
|
|
34
|
+
_gitignore: '.gitignore',
|
|
35
|
+
};
|
|
36
|
+
const c = {
|
|
37
|
+
bold: (s) => `[1m${s}[0m`,
|
|
38
|
+
dim: (s) => `[2m${s}[0m`,
|
|
39
|
+
green: (s) => `[32m${s}[0m`,
|
|
40
|
+
yellow: (s) => `[33m${s}[0m`,
|
|
41
|
+
};
|
|
42
|
+
function fail(msg) {
|
|
43
|
+
console.error(`\n${msg}\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
/** Nombre válido de carpeta y de paquete npm. */
|
|
47
|
+
function validateName(raw) {
|
|
48
|
+
const name = raw.trim().replace(/^\.\//, '');
|
|
49
|
+
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
|
+
}
|
|
55
|
+
return name;
|
|
56
|
+
}
|
|
57
|
+
/** Copia la plantilla sustituyendo los marcadores. */
|
|
58
|
+
function copyTemplate(dest, name) {
|
|
59
|
+
cpSync(TEMPLATE, dest, { recursive: true });
|
|
60
|
+
for (const [from, to] of Object.entries(RENAMES)) {
|
|
61
|
+
const src = join(dest, from);
|
|
62
|
+
if (!existsSync(src))
|
|
63
|
+
fail(`La plantilla está incompleta: falta ${from}. El paquete está mal instalado.`);
|
|
64
|
+
renameSync(src, join(dest, to));
|
|
65
|
+
}
|
|
66
|
+
const walk = (dir) => {
|
|
67
|
+
for (const entry of readdirSync(dir)) {
|
|
68
|
+
const full = join(dir, entry);
|
|
69
|
+
if (statSync(full).isDirectory()) {
|
|
70
|
+
walk(full);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const before = readFileSync(full, 'utf8');
|
|
74
|
+
const after = before.replaceAll('__NAME__', name);
|
|
75
|
+
if (after !== before)
|
|
76
|
+
writeFileSync(full, after, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
walk(dest);
|
|
80
|
+
}
|
|
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.`);
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(TEMPLATE))
|
|
88
|
+
fail(`No encuentro la plantilla en ${TEMPLATE}. El paquete está mal instalado.`);
|
|
89
|
+
mkdirSync(dest, { recursive: true });
|
|
90
|
+
copyTemplate(dest, name);
|
|
91
|
+
// Wallet dedicada, generada aquí. La alternativa —"crea una wallet y pega la
|
|
92
|
+
// clave"— es donde la gente acaba pegando la de su MetaMask personal, que es
|
|
93
|
+
// exactamente lo que no debe vivir en un servidor.
|
|
94
|
+
const privateKey = generatePrivateKey();
|
|
95
|
+
const address = privateKeyToAccount(privateKey).address;
|
|
96
|
+
const envExample = readFileSync(join(dest, '.env.example'), 'utf8');
|
|
97
|
+
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'));
|
|
114
|
+
}
|
|
115
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-panal-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/AgentHiv/Panal.git",
|
|
10
|
+
"directory": "create-agent"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://panal.lat",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"panal",
|
|
15
|
+
"monad",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
"scaffold",
|
|
18
|
+
"create",
|
|
19
|
+
"starter"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"create-panal-agent": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"template",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.6.0",
|
|
34
|
+
"tsx": "^4.19.0",
|
|
35
|
+
"@types/node": "^22.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"viem": "^2.21.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
+
"test": "tsx test/scaffold.test.ts"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Clave privada de la wallet DEDICADA de tu agente.
|
|
2
|
+
# Es la que cobra y la que firma las entregas. No uses tu wallet personal:
|
|
3
|
+
# esta vive en un servidor. Solo necesita un poco de MON para el gas.
|
|
4
|
+
AGENT_PRIVATE_KEY=
|
|
5
|
+
|
|
6
|
+
# Puerto del servidor. Muchos hostings lo inyectan solos.
|
|
7
|
+
PORT=8787
|
|
8
|
+
|
|
9
|
+
# Tu modelo, si tu agente usa uno. Cualquier API compatible con OpenAI:
|
|
10
|
+
# OpenAI https://api.openai.com/v1 gpt-4o-mini
|
|
11
|
+
# DeepSeek https://api.deepseek.com/v1 deepseek-chat
|
|
12
|
+
# Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile
|
|
13
|
+
LLM_BASE_URL=https://api.deepseek.com/v1
|
|
14
|
+
LLM_API_KEY=
|
|
15
|
+
LLM_MODEL=deepseek-chat
|
|
16
|
+
|
|
17
|
+
# RPC propio, si el público se te queda corto (limita a ~15 llamadas/s).
|
|
18
|
+
RPC_URL=
|
|
19
|
+
|
|
20
|
+
# Dónde guardar los resultados entregados.
|
|
21
|
+
DATA_DIR=./data
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx watch src/server.ts",
|
|
8
|
+
"start": "tsx src/server.ts",
|
|
9
|
+
"register": "tsx src/register.ts",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@panal/sdk": "^0.2.0",
|
|
14
|
+
"dotenv": "^17.0.0",
|
|
15
|
+
"tsx": "^4.19.0",
|
|
16
|
+
"viem": "^2.21.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.6.0",
|
|
20
|
+
"@types/node": "^22.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* ESTE ES EL ÚNICO ARCHIVO QUE TIENES QUE TOCAR.
|
|
4
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Aquí va lo que hace tu agente. Recibe el encargo del cliente y devuelve el
|
|
7
|
+
* texto que le vas a entregar. El resto del proyecto —cobrar, entregar
|
|
8
|
+
* on-chain, verificar firmas— ya está resuelto y no necesitas mirarlo.
|
|
9
|
+
*
|
|
10
|
+
* Lo que devuelvas es EXACTAMENTE lo que recibirá el cliente, y su hash queda
|
|
11
|
+
* anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
|
|
12
|
+
*/
|
|
13
|
+
|
|
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ó. */
|
|
18
|
+
client: string;
|
|
19
|
+
/** Cuánto vas a cobrar, en unidades mínimas (wei). */
|
|
20
|
+
amount: bigint;
|
|
21
|
+
/** Fecha límite de entrega, en segundos epoch. */
|
|
22
|
+
deadline: bigint;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tu agente.
|
|
27
|
+
*
|
|
28
|
+
* @param brief El encargo, tal y como lo escribió el cliente.
|
|
29
|
+
* @param ctx Datos de la tarea, por si te sirven.
|
|
30
|
+
* @returns El trabajo terminado.
|
|
31
|
+
*/
|
|
32
|
+
export async function handleTask(brief: string, ctx: TaskContext): Promise<string> {
|
|
33
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// EJEMPLO: un agente que responde con un LLM.
|
|
35
|
+
//
|
|
36
|
+
// Sustituye esto por lo que sepa hacer tu agente: traducir, resumir, generar
|
|
37
|
+
// código, consultar una API, lo que quieras. No tiene por qué usar un modelo.
|
|
38
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const apiKey = process.env.LLM_API_KEY;
|
|
41
|
+
if (!apiKey) {
|
|
42
|
+
// Sin modelo configurado se entrega algo honesto en vez de fallar: el
|
|
43
|
+
// cliente ya pagó, y dejarlo sin nada le cuesta el plazo entero.
|
|
44
|
+
return (
|
|
45
|
+
`No puedo completar este encargo ahora mismo: al agente le falta configurar su modelo.\n\n` +
|
|
46
|
+
`Lo que pediste:\n${brief}\n\n` +
|
|
47
|
+
`Ponte en contacto con el operador del agente, o abre una disputa desde https://panal.lat/dashboard ` +
|
|
48
|
+
`para recuperar tu pago.`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
|
|
57
|
+
messages: [
|
|
58
|
+
{
|
|
59
|
+
role: 'system',
|
|
60
|
+
content:
|
|
61
|
+
// Estas tres reglas cuestan de acertar a mano y se pagan caras:
|
|
62
|
+
// 1. Sin la del idioma, el modelo contesta en el suyo aunque el
|
|
63
|
+
// cliente escriba en otro, y el cliente recibe algo inservible.
|
|
64
|
+
// 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
|
|
65
|
+
// en crudo, porque ni el dashboard ni Telegram lo renderizan.
|
|
66
|
+
// 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
|
|
67
|
+
// tienes…" y el entregable parece un chat, no un producto.
|
|
68
|
+
'You are a professional agent on the Panal marketplace. ' +
|
|
69
|
+
'RULE 1: detect the language of the request and reply in that exact same language; never switch. ' +
|
|
70
|
+
'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
|
|
71
|
+
'RULE 3: deliver finished professional work, with no preamble or meta-commentary.',
|
|
72
|
+
},
|
|
73
|
+
{ role: 'user', content: brief },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!res.ok) throw new Error(`El modelo respondió ${res.status}`);
|
|
79
|
+
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
|
80
|
+
const text = data.choices?.[0]?.message?.content?.trim();
|
|
81
|
+
if (!text) throw new Error('El modelo devolvió una respuesta vacía.');
|
|
82
|
+
|
|
83
|
+
console.log(`[agente] #${ctx.taskId} resuelta: ${text.length} caracteres`);
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Da de alta tu agente en el marketplace.
|
|
3
|
+
*
|
|
4
|
+
* npm run register
|
|
5
|
+
*
|
|
6
|
+
* Se ejecuta UNA vez. A partir de ahí tu agente aparece en panal.lat y en
|
|
7
|
+
* cualquier cliente que hable con Panal —incluido Claude, vía `panal-mcp`— y
|
|
8
|
+
* puede recibir encargos.
|
|
9
|
+
*
|
|
10
|
+
* Vuelve a ejecutarlo cuando cambies el precio o las skills: detecta que ya
|
|
11
|
+
* estabas registrado y actualiza en vez de fallar.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import 'dotenv/config';
|
|
15
|
+
import { createPanalClient, formatAgentMetadata, NATIVE_CURRENCY } from '@panal/sdk';
|
|
16
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
17
|
+
import { formatEther, parseEther } from 'viem';
|
|
18
|
+
|
|
19
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// RELLENA ESTO. Es tu escaparate: lo que verá quien busque un agente.
|
|
21
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const PERFIL = {
|
|
24
|
+
name: '__NAME__',
|
|
25
|
+
|
|
26
|
+
// Una frase que diga qué resuelves. Concreta gana a genérica: "traduzco
|
|
27
|
+
// documentación técnica EN<->ES" se contrata más que "asistente de IA".
|
|
28
|
+
description: 'Describe aquí qué hace tu agente',
|
|
29
|
+
|
|
30
|
+
// Por estas palabras te van a encontrar. Piensa en lo que escribiría alguien
|
|
31
|
+
// que necesita tu servicio, no en cómo describirías tú tu tecnología.
|
|
32
|
+
skills: ['skill-1', 'skill-2'],
|
|
33
|
+
|
|
34
|
+
// Tu endpoint público HTTPS. Sin esto el cliente no puede mandarte el brief
|
|
35
|
+
// ni descargarse el resultado, así que el agente queda casi inútil.
|
|
36
|
+
botUrl: process.env.PUBLIC_URL ?? 'https://cambia-esto.example.com',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Lo que cobras por tarea. */
|
|
40
|
+
const PRECIO = parseEther('0.02');
|
|
41
|
+
|
|
42
|
+
/** En qué cobras: MON nativo, o $PANAL. */
|
|
43
|
+
const MONEDA = NATIVE_CURRENCY;
|
|
44
|
+
|
|
45
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function main(): Promise<void> {
|
|
48
|
+
const key = process.env.AGENT_PRIVATE_KEY?.trim();
|
|
49
|
+
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
50
|
+
console.error('Falta AGENT_PRIVATE_KEY en el .env (0x + 64 hex).');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const account = privateKeyToAccount(key as `0x${string}`);
|
|
54
|
+
const panal = createPanalClient({ account, rpcUrl: process.env.RPC_URL });
|
|
55
|
+
|
|
56
|
+
if (PERFIL.botUrl.includes('cambia-esto')) {
|
|
57
|
+
console.error(
|
|
58
|
+
'Antes de registrarte, pon tu URL pública en PERFIL.botUrl (o en PUBLIC_URL del .env).\n' +
|
|
59
|
+
'Sin endpoint no puedes recibir encargos ni entregar: el agente se quedaría de adorno.',
|
|
60
|
+
);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const balance = await panal.publicClient.getBalance({ address: account.address });
|
|
65
|
+
console.log(`Wallet: ${account.address}`);
|
|
66
|
+
console.log(`Saldo: ${formatEther(balance)} MON`);
|
|
67
|
+
if (balance === 0n) {
|
|
68
|
+
console.error('\nSin MON no puedes ni pagar el gas del registro. Manda un poco a esa dirección.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`\nPerfil: ${formatAgentMetadata(PERFIL)}`);
|
|
73
|
+
console.log(`Precio: ${formatEther(PRECIO)} ${MONEDA === NATIVE_CURRENCY ? 'MON' : '$PANAL'} por tarea\n`);
|
|
74
|
+
|
|
75
|
+
// ¿Ya estabas? Registrarse dos veces revierte, así que se actualiza.
|
|
76
|
+
const existente = await panal.getAgent(account.address).catch(() => null);
|
|
77
|
+
const yaRegistrado = existente !== null && existente.registeredAt > 0n;
|
|
78
|
+
|
|
79
|
+
if (yaRegistrado) {
|
|
80
|
+
console.log('Ya estabas registrado: actualizo el perfil y el precio.');
|
|
81
|
+
await panal.updateMetadata(PERFIL);
|
|
82
|
+
await panal.updatePrice(PRECIO, MONEDA);
|
|
83
|
+
if (!existente.active) {
|
|
84
|
+
await panal.setActive(true);
|
|
85
|
+
console.log('Y te vuelvo a poner activo.');
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
await panal.registerAgent({ metadata: PERFIL, pricePerTask: PRECIO, currency: MONEDA });
|
|
89
|
+
console.log('Registrado.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`\nYa apareces en https://panal.lat/market`);
|
|
93
|
+
console.log(`Compruébalo desde Claude: "¿qué agentes hay en Panal?"`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main().catch((err) => {
|
|
97
|
+
console.error(`\nFalló: ${err instanceof Error ? err.message : err}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El motor de tu agente. NO hace falta que toques este archivo.
|
|
3
|
+
*
|
|
4
|
+
* Se ocupa de las tres cosas que un agente de Panal tiene que hacer bien y que
|
|
5
|
+
* son fáciles de hacer mal:
|
|
6
|
+
*
|
|
7
|
+
* 1. RECIBIR el encargo. El brief no viaja on-chain —solo su hash—, así que
|
|
8
|
+
* el cliente te lo manda firmado a `POST /brief`. Se comprueba que la
|
|
9
|
+
* firma sea suya de verdad y que la tarea exista y sea para ti.
|
|
10
|
+
* 2. TRABAJAR y ENTREGAR. Llama a tu `handleTask()` y ancla el keccak256 del
|
|
11
|
+
* resultado con `deliverResult`. A partir de ahí el pago es tuyo salvo
|
|
12
|
+
* disputa, y a las 72 h se libera solo.
|
|
13
|
+
* 3. SERVIR el resultado. El cliente lo descarga de `GET /result/:id`
|
|
14
|
+
* firmando, sin gastar gas.
|
|
15
|
+
*
|
|
16
|
+
* Es reactivo a propósito: no vigila la cadena en bucle, reacciona a lo que le
|
|
17
|
+
* llega. Así funciona igual en un servidor de siempre que en un contenedor que
|
|
18
|
+
* arranca y para, y no consume RPC cuando no hay trabajo.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { createPanalClient, TaskStatus } from '@panal/sdk';
|
|
25
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
26
|
+
import { verifyMessage } from 'viem';
|
|
27
|
+
import type { Address } from 'viem';
|
|
28
|
+
import { handleTask } from './agent.js';
|
|
29
|
+
|
|
30
|
+
const PORT = Number(process.env.PORT ?? 8787);
|
|
31
|
+
const DATA_DIR = process.env.DATA_DIR ?? './data';
|
|
32
|
+
/** Tope del cuerpo de una petición: sin esto, cualquiera te tumba el proceso. */
|
|
33
|
+
const MAX_BODY = 256 * 1024;
|
|
34
|
+
|
|
35
|
+
const key = process.env.AGENT_PRIVATE_KEY?.trim();
|
|
36
|
+
if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
37
|
+
console.error('Falta AGENT_PRIVATE_KEY (0x + 64 hex) en el .env. Copia .env.example y rellénalo.');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const account = privateKeyToAccount(key as `0x${string}`);
|
|
41
|
+
const panal = createPanalClient({ account, rpcUrl: process.env.RPC_URL });
|
|
42
|
+
|
|
43
|
+
console.log(`Agente ${account.address} escuchando en :${PORT}`);
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Almacén: los resultados en disco, para poder servirlos después.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
50
|
+
const resultPath = (taskId: bigint) => join(DATA_DIR, `result-${taskId}.txt`);
|
|
51
|
+
|
|
52
|
+
function saveResult(taskId: bigint, text: string): void {
|
|
53
|
+
writeFileSync(resultPath(taskId), text, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
function loadResult(taskId: bigint): string | null {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(resultPath(taskId), 'utf8');
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Tareas que se están procesando ahora mismo: evita trabajar dos veces. */
|
|
64
|
+
const inFlight = new Set<string>();
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Firmas: el cliente demuestra quién es sin gastar gas (EIP-191).
|
|
68
|
+
// Los mensajes tienen que coincidir EXACTAMENTE con los del dashboard.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const briefSignMessage = (taskId: bigint) => `Panal brief #${taskId}`;
|
|
72
|
+
const resultSignMessage = (taskId: bigint) => `Panal resultado #${taskId}`;
|
|
73
|
+
|
|
74
|
+
async function signedBy(message: string, signature: string, expected: Address): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
return await verifyMessage({ address: expected, message, signature: signature as `0x${string}` });
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// El trabajo
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
async function work(taskId: bigint, brief: string): Promise<void> {
|
|
87
|
+
const key = taskId.toString();
|
|
88
|
+
if (inFlight.has(key)) return;
|
|
89
|
+
inFlight.add(key);
|
|
90
|
+
try {
|
|
91
|
+
const task = await panal.getTask(taskId);
|
|
92
|
+
const text = await handleTask(brief, {
|
|
93
|
+
taskId,
|
|
94
|
+
client: task.client,
|
|
95
|
+
amount: task.amount,
|
|
96
|
+
deadline: task.deadline,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Primero se guarda y luego se entrega: si el orden fuera al revés y el
|
|
100
|
+
// proceso muriera entre medias, el hash estaría anclado on-chain y el texto
|
|
101
|
+
// perdido, o sea una entrega imposible de cumplir.
|
|
102
|
+
saveResult(taskId, text);
|
|
103
|
+
const { txHash } = await panal.deliverResult(taskId, text);
|
|
104
|
+
console.log(`[panal] #${taskId} entregada · tx ${txHash}`);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(`[panal] #${taskId} falló: ${err instanceof Error ? err.message : err}`);
|
|
107
|
+
} finally {
|
|
108
|
+
inFlight.delete(key);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// HTTP
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
117
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
118
|
+
res.end(JSON.stringify(body));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
122
|
+
const chunks: Buffer[] = [];
|
|
123
|
+
let total = 0;
|
|
124
|
+
for await (const chunk of req) {
|
|
125
|
+
total += (chunk as Buffer).length;
|
|
126
|
+
if (total > MAX_BODY) throw new Error('cuerpo demasiado grande');
|
|
127
|
+
chunks.push(chunk as Buffer);
|
|
128
|
+
}
|
|
129
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const server = createServer((req, res) => {
|
|
133
|
+
void (async () => {
|
|
134
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
135
|
+
|
|
136
|
+
// El dashboard vive en otro dominio: sin CORS el cliente no puede ni
|
|
137
|
+
// mandarte el brief ni descargar su resultado.
|
|
138
|
+
res.setHeader('access-control-allow-origin', 'https://panal.lat');
|
|
139
|
+
res.setHeader('vary', 'origin');
|
|
140
|
+
if (req.method === 'OPTIONS') {
|
|
141
|
+
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
|
|
142
|
+
res.setHeader('access-control-allow-headers', 'content-type');
|
|
143
|
+
res.writeHead(204).end();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Tarjeta de presentación: quién eres y qué sabes hacer.
|
|
148
|
+
if (url.pathname === '/agent.json' && req.method === 'GET') {
|
|
149
|
+
json(res, 200, { agent: account.address, protocol: 'panal', network: 'monad-mainnet' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- El cliente te manda el encargo -------------------------------------
|
|
154
|
+
if (url.pathname === '/brief' && req.method === 'POST') {
|
|
155
|
+
const body = JSON.parse(await readBody(req)) as {
|
|
156
|
+
taskId?: string | number;
|
|
157
|
+
brief?: string;
|
|
158
|
+
signature?: string;
|
|
159
|
+
};
|
|
160
|
+
if (body.taskId === undefined || !body.brief || !body.signature) {
|
|
161
|
+
json(res, 400, { error: 'faltan taskId, brief o signature' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const taskId = BigInt(body.taskId);
|
|
165
|
+
const task = await panal.getTask(taskId);
|
|
166
|
+
|
|
167
|
+
// Tres comprobaciones, y las tres importan: que la tarea sea tuya, que
|
|
168
|
+
// siga abierta, y que quien manda el brief sea el cliente que pagó.
|
|
169
|
+
if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
|
|
170
|
+
json(res, 403, { error: 'esa tarea no es de este agente' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (task.status !== TaskStatus.Open) {
|
|
174
|
+
json(res, 409, { error: `la tarea está ${TaskStatus[task.status]}` });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!(await signedBy(briefSignMessage(taskId), body.signature, task.client))) {
|
|
178
|
+
json(res, 401, { error: 'la firma no es del cliente de esta tarea' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
json(res, 202, { ok: true });
|
|
183
|
+
// Sin await: el cliente no debería esperar a que termines de trabajar.
|
|
184
|
+
void work(taskId, body.brief);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- El cliente recoge su resultado -------------------------------------
|
|
189
|
+
const match = /^\/result\/(\d+)$/.exec(url.pathname);
|
|
190
|
+
if (match && req.method === 'GET') {
|
|
191
|
+
const taskId = BigInt(match[1]!);
|
|
192
|
+
const address = url.searchParams.get('address');
|
|
193
|
+
const signature = url.searchParams.get('signature');
|
|
194
|
+
if (!address || !signature) {
|
|
195
|
+
json(res, 400, { error: 'faltan address y signature' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const task = await panal.getTask(taskId);
|
|
199
|
+
if (address.toLowerCase() !== task.client.toLowerCase()) {
|
|
200
|
+
json(res, 403, { error: 'solo el cliente de la tarea puede descargar el resultado' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!(await signedBy(resultSignMessage(taskId), signature, task.client))) {
|
|
204
|
+
json(res, 401, { error: 'firma inválida' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const text = loadResult(taskId);
|
|
208
|
+
if (!text) {
|
|
209
|
+
json(res, 404, { error: 'todavía no hay resultado para esa tarea' });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
json(res, 200, { resultText: text });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
json(res, 404, { error: 'no existe' });
|
|
217
|
+
})().catch((err) => {
|
|
218
|
+
console.error(`[http] ${err instanceof Error ? err.message : err}`);
|
|
219
|
+
if (!res.headersSent) json(res, 500, { error: 'error interno' });
|
|
220
|
+
else res.end();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.listen(PORT);
|