@panal/sdk 0.26.0 → 0.27.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 +8 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.js +83 -2
- package/dist/consultas.d.ts +88 -0
- package/dist/consultas.js +142 -0
- package/dist/files.d.ts +14 -3
- package/dist/files.js +14 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/subcontratos.d.ts +104 -0
- package/dist/subcontratos.js +171 -0
- package/dist/x402-server.d.ts +9 -0
- package/dist/x402-server.js +2 -0
- package/dist/x402.d.ts +28 -0
- package/dist/x402.js +21 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -208,6 +208,14 @@ El nombre y los decimales salen de la lista, nunca de lo que diga la cotización
|
|
|
208
208
|
|
|
209
209
|
`panal.ask(skill, pregunta, { maxSpend, asset })` hace lo mismo sin elegir agente: cotiza a los que tienen esa skill y paga al más barato que quepa en el tope, comparando solo a los que cobran en `asset` ($PANAL si no se indica).
|
|
210
210
|
|
|
211
|
+
**Con archivos.** `panal.askAgent(agente, pregunta, { maxSpend, files })` y `panal.ask(…, { files })` mandan hasta 5 archivos de 25 MB (`{ name, bytes, mime }`) y devuelven en `files` los que conteste el agente, ya bajados, comprobados contra su hash y abiertos:
|
|
212
|
+
|
|
213
|
+
- Solo se mandan a un agente cuya cotización diga que los acepta (`accept.files`); `ask` elige solo entre esos. Si no, no se firma nada.
|
|
214
|
+
- Los bytes no viajan en la pregunta: se dejan en el buzón de Panal a nombre del agente —cifrados para él si publicó su clave— y en la pregunta va su lista con el hash de cada uno. Solo pueden bajarlos el destinatario y quien los dejó.
|
|
215
|
+
- Por piezas: `uploadFilesFor(para, archivos)` y `receiveFiles(lista)` en el cliente, o `uploadConsultaFile` / `downloadConsultaFile` / `parseConsultaFiles` sueltas. Del lado que cobra, `buildQuote` y `buildNativeQuote` aceptan `files: CONSULTA_FILES_CAPABILITY` para anunciarlo.
|
|
216
|
+
|
|
217
|
+
Si el agente que te contesta subcontrató a otros, `r.subcontracts` dice a quién, para qué y cuánto pagó (ver `parseSubcontractsManifest` para leerlo de una entrega).
|
|
218
|
+
|
|
211
219
|
**Cobrar es la otra mitad**, y la plantilla de [`create-panal-agent`](../create-agent) ya la trae montada en `POST /x402/ask`. Por piezas:
|
|
212
220
|
|
|
213
221
|
- **Con `permit`:** `buildQuote` hace la cotización. `readPermitDomain` lee el dominio de firma del token y lo comprueba contra su `DOMAIN_SEPARATOR` (el de USDC es la versión 2, no la 1). `parseX402Header` lee el pago y `verifyAndSettle` lo verifica y cobra en la cadena.
|
package/dist/client.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { Account, Address, Hex, PublicClient, WalletClient } from 'viem';
|
|
|
19
19
|
import { type AttachedFile, type HashedFile } from './files.js';
|
|
20
20
|
import { type Acceso, type ClaveDeCifrado } from './cifrado.js';
|
|
21
21
|
import { type AskResult, type X402Accept } from './x402.js';
|
|
22
|
+
import { type OutgoingFile, type ReceivedFile } from './consultas.js';
|
|
22
23
|
import { type CallEnvelope } from './envelope.js';
|
|
23
24
|
import { type PanalAddresses, type PanalNetwork } from './chains.js';
|
|
24
25
|
import { type OpcionesPrecios, type Precios } from './precios.js';
|
|
@@ -509,7 +510,27 @@ export declare class PanalClient {
|
|
|
509
510
|
* heredaba ni presupuesto ni camino, y el ciclo dejaba de detectarse.
|
|
510
511
|
*/
|
|
511
512
|
envelope?: CallEnvelope;
|
|
513
|
+
/**
|
|
514
|
+
* Archivos para el agente (una foto, un audio, un PDF…): como mucho 5 de
|
|
515
|
+
* 25 MB. Se dejan en el buzón a su nombre —cifrados si publicó clave— y
|
|
516
|
+
* en la pregunta solo va su lista. Solo si su cotización dice que los acepta.
|
|
517
|
+
*/
|
|
518
|
+
files?: OutgoingFile[];
|
|
512
519
|
}): Promise<AskResult>;
|
|
520
|
+
/** Lanza si la cotización no admite estos archivos: sin firmar ni subir nada. */
|
|
521
|
+
private comprobarQueAceptaArchivos;
|
|
522
|
+
/**
|
|
523
|
+
* Deja archivos en el buzón para `para`. Si publicó clave, cifrados para él
|
|
524
|
+
* y con un nombre opaco: el buzón no ve ni el contenido ni cómo se llaman, y
|
|
525
|
+
* el nombre real viaja solo en la lista que recibe el agente.
|
|
526
|
+
*/
|
|
527
|
+
uploadFilesFor(para: Address, archivos: OutgoingFile[]): Promise<HashedFile[]>;
|
|
528
|
+
/**
|
|
529
|
+
* Los archivos que alguien dejó en el buzón para esta cuenta (la respuesta
|
|
530
|
+
* de un agente, o la pregunta de otro): bajados, comprobados contra su hash
|
|
531
|
+
* y abiertos si iban cifrados.
|
|
532
|
+
*/
|
|
533
|
+
receiveFiles(manifest: HashedFile[]): Promise<ReceivedFile[]>;
|
|
513
534
|
/**
|
|
514
535
|
* Busca un agente con esa skill, negocia el precio y le paga por la consulta.
|
|
515
536
|
*
|
|
@@ -563,6 +584,8 @@ export declare class PanalClient {
|
|
|
563
584
|
* y un tope en una moneda no dice nada de otra.
|
|
564
585
|
*/
|
|
565
586
|
asset?: Address;
|
|
587
|
+
/** Archivos para el agente que responda. Ver `askAgent`: solo se elige entre los que los aceptan. */
|
|
588
|
+
files?: OutgoingFile[];
|
|
566
589
|
}): Promise<AskResult & {
|
|
567
590
|
agent: Address;
|
|
568
591
|
skill: string;
|
package/dist/client.js
CHANGED
|
@@ -22,6 +22,7 @@ import { assertPublicUrl, fetchBytesLimited, fetchLimited, rutaDeAgente, sinBarr
|
|
|
22
22
|
import { MAX_FILE_BYTES, appendAttachmentsManifest, attachmentFrom, parseAttachmentsManifest, parseFilesManifest, sanitizeFileName, verifyFileBytes, } from './files.js';
|
|
23
23
|
import { accesoSignMessage, cifrarArchivo, cifrarTexto, claveDesdeFirma, comprobarClavePublicada, concederAcceso, descifrarArchivo, descifrarTexto, esArchivoCifrado, esTextoCifrado, mensajeParaDerivarClave, mensajeParaPublicarClave, nombreEnBuzon, puedeAbrir, } from './cifrado.js';
|
|
24
24
|
import { X402Error, payAndAsk, quoteAsk } from './x402.js';
|
|
25
|
+
import { MAX_CONSULTA_FILES, downloadConsultaFile, uploadConsultaFile, } from './consultas.js';
|
|
25
26
|
import { descend, newEnvelope, remainingBudget } from './envelope.js';
|
|
26
27
|
import { NATIVE_CURRENCY, addressesFor, chainFor } from './chains.js';
|
|
27
28
|
import { leerPrecios, valorDeVenta } from './precios.js';
|
|
@@ -1193,13 +1194,82 @@ export class PanalClient {
|
|
|
1193
1194
|
async askAgent(agent, prompt, options) {
|
|
1194
1195
|
const wallet = this.wallet();
|
|
1195
1196
|
const endpoint = await this.x402Endpoint(agent, options);
|
|
1196
|
-
|
|
1197
|
+
const archivos = options.files ?? [];
|
|
1198
|
+
let quote = options.quote;
|
|
1199
|
+
let manifiesto = [];
|
|
1200
|
+
if (archivos.length) {
|
|
1201
|
+
// Se cotiza ANTES de subir nada: si no acepta archivos, no se deja ni uno.
|
|
1202
|
+
quote ??= await quoteAsk(endpoint, prompt, { payer: this.account.address, ...options });
|
|
1203
|
+
this.comprobarQueAceptaArchivos(quote, archivos);
|
|
1204
|
+
manifiesto = await this.uploadFilesFor(agent, archivos);
|
|
1205
|
+
}
|
|
1206
|
+
const result = await payAndAsk(wallet, this.account, endpoint, prompt, {
|
|
1197
1207
|
...options,
|
|
1208
|
+
...(quote ? { quote } : {}),
|
|
1209
|
+
files: manifiesto,
|
|
1198
1210
|
chainId: chainFor(this.network).id,
|
|
1199
1211
|
// Se ata a quién esperamos pagar: si el endpoint estuviera secuestrado y
|
|
1200
1212
|
// cotizara a nombre de otro, la firma no llega a producirse.
|
|
1201
1213
|
expectedPayee: agent,
|
|
1202
1214
|
});
|
|
1215
|
+
return { ...result, files: await this.receiveFiles(result.fileManifest) };
|
|
1216
|
+
}
|
|
1217
|
+
/** Lanza si la cotización no admite estos archivos: sin firmar ni subir nada. */
|
|
1218
|
+
comprobarQueAceptaArchivos(accept, archivos) {
|
|
1219
|
+
if (archivos.length > MAX_CONSULTA_FILES) {
|
|
1220
|
+
throw new X402Error(`Como mucho ${MAX_CONSULTA_FILES} archivos por pregunta, y mandas ${archivos.length}.`);
|
|
1221
|
+
}
|
|
1222
|
+
if (!accept.files)
|
|
1223
|
+
throw new X402Error('Este agente no acepta archivos con la pregunta.');
|
|
1224
|
+
if (archivos.length > accept.files.max) {
|
|
1225
|
+
throw new X402Error(`Este agente acepta ${accept.files.max} archivo(s) por pregunta y mandas ${archivos.length}.`);
|
|
1226
|
+
}
|
|
1227
|
+
const grande = archivos.find((f) => f.bytes.byteLength > accept.files.maxBytes || f.bytes.byteLength > MAX_FILE_BYTES);
|
|
1228
|
+
if (grande)
|
|
1229
|
+
throw new X402Error(`"${grande.name}" pasa del tamaño que acepta este agente.`);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Deja archivos en el buzón para `para`. Si publicó clave, cifrados para él
|
|
1233
|
+
* y con un nombre opaco: el buzón no ve ni el contenido ni cómo se llaman, y
|
|
1234
|
+
* el nombre real viaja solo en la lista que recibe el agente.
|
|
1235
|
+
*/
|
|
1236
|
+
async uploadFilesFor(para, archivos) {
|
|
1237
|
+
const wallet = this.wallet();
|
|
1238
|
+
const yo = this.account.address;
|
|
1239
|
+
const suClave = await this.getPublicKey(para);
|
|
1240
|
+
const sign = (message) => wallet.signMessage({ account: this.account, message });
|
|
1241
|
+
const out = [];
|
|
1242
|
+
for (const f of archivos) {
|
|
1243
|
+
const bytes = suClave ? new Uint8Array(cifrarArchivo(f.bytes, [{ cuenta: getAddress(para), clave: suClave }])) : f.bytes;
|
|
1244
|
+
const subido = await uploadConsultaFile({
|
|
1245
|
+
buzonUrl: this.buzonUrl,
|
|
1246
|
+
para,
|
|
1247
|
+
de: yo,
|
|
1248
|
+
file: { name: suClave ? nombreEnBuzon(keccak256(bytes)) : f.name, bytes, ...(suClave ? {} : f.mime ? { mime: f.mime } : {}) },
|
|
1249
|
+
sign,
|
|
1250
|
+
});
|
|
1251
|
+
out.push({ ...subido, name: sanitizeFileName(f.name), ...(f.mime ? { mime: f.mime } : {}) });
|
|
1252
|
+
}
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Los archivos que alguien dejó en el buzón para esta cuenta (la respuesta
|
|
1257
|
+
* de un agente, o la pregunta de otro): bajados, comprobados contra su hash
|
|
1258
|
+
* y abiertos si iban cifrados.
|
|
1259
|
+
*/
|
|
1260
|
+
async receiveFiles(manifest) {
|
|
1261
|
+
if (!manifest.length)
|
|
1262
|
+
return [];
|
|
1263
|
+
const wallet = this.wallet();
|
|
1264
|
+
const yo = this.account.address;
|
|
1265
|
+
const sign = (message) => wallet.signMessage({ account: this.account, message });
|
|
1266
|
+
const out = [];
|
|
1267
|
+
for (const f of manifest) {
|
|
1268
|
+
const crudo = await downloadConsultaFile({ buzonUrl: this.buzonUrl, para: yo, lector: yo, file: f, sign });
|
|
1269
|
+
const bytes = await this.decryptFile(crudo);
|
|
1270
|
+
out.push({ name: f.name, size: bytes.byteLength, bytes, ...(f.mime ? { mime: f.mime } : {}) });
|
|
1271
|
+
}
|
|
1272
|
+
return out;
|
|
1203
1273
|
}
|
|
1204
1274
|
/**
|
|
1205
1275
|
* Busca un agente con esa skill, negocia el precio y le paga por la consulta.
|
|
@@ -1218,6 +1288,7 @@ export class PanalClient {
|
|
|
1218
1288
|
*/
|
|
1219
1289
|
async ask(skill, prompt, options) {
|
|
1220
1290
|
const wallet = this.wallet();
|
|
1291
|
+
const archivos = options.files ?? [];
|
|
1221
1292
|
const excluded = new Set([...(options.exclude ?? []), this.account.address].map((a) => getAddress(a).toLowerCase()));
|
|
1222
1293
|
// El presupuesto real es el menor entre lo que dice el sobre y el tope de
|
|
1223
1294
|
// esta llamada: heredar una cadena no puede ampliar lo que autorizaste.
|
|
@@ -1273,6 +1344,8 @@ export class PanalClient {
|
|
|
1273
1344
|
...options,
|
|
1274
1345
|
envelope: heredado,
|
|
1275
1346
|
});
|
|
1347
|
+
if (archivos.length)
|
|
1348
|
+
this.comprobarQueAceptaArchivos(accept, archivos);
|
|
1276
1349
|
if (getAddress(accept.asset) !== moneda) {
|
|
1277
1350
|
rechazos.push(`${agent.metadata.name || agent.address}: cobra en ${accept.asset}, no en ${moneda}`);
|
|
1278
1351
|
}
|
|
@@ -1293,8 +1366,11 @@ export class PanalClient {
|
|
|
1293
1366
|
// Se desciende el sobre ANTES de firmar: aquí es donde se comprueba que
|
|
1294
1367
|
// quedan saltos, que hay presupuesto y que no estamos cerrando un ciclo.
|
|
1295
1368
|
const siguiente = descend(heredado, this.account.address, BigInt(elegido.accept.amount));
|
|
1369
|
+
// Los archivos se suben SOLO para el elegido, y ya sabiendo que los acepta.
|
|
1370
|
+
const manifiesto = archivos.length ? await this.uploadFilesFor(elegido.agent.address, archivos) : [];
|
|
1296
1371
|
const result = await payAndAsk(wallet, this.account, elegido.endpoint, prompt, {
|
|
1297
1372
|
...options,
|
|
1373
|
+
files: manifiesto,
|
|
1298
1374
|
maxSpend: tope,
|
|
1299
1375
|
chainId: chainFor(this.network).id,
|
|
1300
1376
|
expectedPayee: elegido.agent.address,
|
|
@@ -1305,7 +1381,12 @@ export class PanalClient {
|
|
|
1305
1381
|
// `skill` es la que de verdad encontró al vendedor, que puede no ser la que
|
|
1306
1382
|
// pediste: quien llama necesita poder decirlo en su log, o cada búsqueda
|
|
1307
1383
|
// ensanchada es un cambio de comportamiento invisible.
|
|
1308
|
-
return {
|
|
1384
|
+
return {
|
|
1385
|
+
...result,
|
|
1386
|
+
files: await this.receiveFiles(result.fileManifest),
|
|
1387
|
+
agent: elegido.agent.address,
|
|
1388
|
+
skill: usada,
|
|
1389
|
+
};
|
|
1309
1390
|
}
|
|
1310
1391
|
/**
|
|
1311
1392
|
* Dónde escucha el x402 de un agente.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panal SDK — archivos en una pregunta pagada (x402) entre agentes.
|
|
3
|
+
*
|
|
4
|
+
* Una pregunta x402 es texto: 2.000 caracteres a la ida y una respuesta a la
|
|
5
|
+
* vuelta. Para pedirle a otro agente que lea una foto, transcriba un audio o
|
|
6
|
+
* resuma un PDF hacía falta poder mandarle el archivo, y que él pudiera
|
|
7
|
+
* devolver los suyos.
|
|
8
|
+
*
|
|
9
|
+
* Los bytes NO viajan en la pregunta. Van al buzón de Panal, a nombre de quien
|
|
10
|
+
* tiene que leerlos, y en la pregunta solo va la lista con su hash:
|
|
11
|
+
*
|
|
12
|
+
* POST /x402/ask { "prompt": "…", "files": [{ name, size, hash, mime }] }
|
|
13
|
+
*
|
|
14
|
+
* Quien recibe la lista baja cada archivo y comprueba sus bytes contra el hash
|
|
15
|
+
* antes de hacer nada: lo que le llega es exactamente lo que le anunciaron, y
|
|
16
|
+
* el buzón no puede cambiarlo por el camino. A la vuelta, igual al revés.
|
|
17
|
+
*
|
|
18
|
+
* En el buzón, cada archivo es de dos cuentas y de nadie más: quien lo dejó y
|
|
19
|
+
* su destinatario. Subirlo pide la firma de quien lo deja (con el destinatario
|
|
20
|
+
* y el hash dentro); bajarlo, la de una de las dos. Y si el destinatario tiene
|
|
21
|
+
* clave de cifrado publicada, se cifra para él antes de subirlo: entonces ni el
|
|
22
|
+
* buzón puede leerlo. Caduca como todo lo del buzón: es un relevo, no un archivo.
|
|
23
|
+
*
|
|
24
|
+
* Ningún mensaje lleva gas: son firmas EIP-191 con caducidad dentro.
|
|
25
|
+
*/
|
|
26
|
+
import type { Address, Hex } from 'viem';
|
|
27
|
+
import { type HashedFile } from './files.js';
|
|
28
|
+
/** Cuántos archivos como mucho en una pregunta o en su respuesta. Como los adjuntos de un encargo. */
|
|
29
|
+
export declare const MAX_CONSULTA_FILES = 5;
|
|
30
|
+
/** Lo que anuncia un agente en su cotización si acepta archivos: cuántos y de qué tamaño. */
|
|
31
|
+
export interface ConsultaFilesCapability {
|
|
32
|
+
max: number;
|
|
33
|
+
maxBytes: number;
|
|
34
|
+
}
|
|
35
|
+
/** La capacidad con la que cotiza un agente con el motor de Panal. */
|
|
36
|
+
export declare const CONSULTA_FILES_CAPABILITY: ConsultaFilesCapability;
|
|
37
|
+
/** Un archivo para mandar: lo que se tiene antes de subirlo. */
|
|
38
|
+
export interface OutgoingFile {
|
|
39
|
+
name: string;
|
|
40
|
+
bytes: Uint8Array;
|
|
41
|
+
mime?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Un archivo recibido, ya comprobado contra su hash y abierto si iba cifrado. */
|
|
44
|
+
export interface ReceivedFile {
|
|
45
|
+
name: string;
|
|
46
|
+
mime?: string;
|
|
47
|
+
size: number;
|
|
48
|
+
bytes: Uint8Array;
|
|
49
|
+
}
|
|
50
|
+
/** Firma que autoriza a dejar ESTOS bytes para ESTE destinatario. */
|
|
51
|
+
export declare function consultaUploadMessage(para: Address, hash: Hex, expira: number): string;
|
|
52
|
+
/** Firma que autoriza a leer un archivo, a su destinatario o a quien lo dejó. */
|
|
53
|
+
export declare function consultaReadMessage(hash: Hex, expira: number): string;
|
|
54
|
+
/** La ruta de un archivo de consulta en el buzón. */
|
|
55
|
+
export declare function consultaFileUrl(buzonUrl: string, para: Address, hash?: Hex): string;
|
|
56
|
+
/**
|
|
57
|
+
* Valida la lista de archivos que llega en una pregunta o en una respuesta.
|
|
58
|
+
* Entrada no fiable: devuelve `null` si algo no cuadra, para rechazarla entera
|
|
59
|
+
* —una pregunta con un archivo mal anunciado no se cobra a medias—.
|
|
60
|
+
*/
|
|
61
|
+
export declare function parseConsultaFiles(value: unknown, max?: number): HashedFile[] | null;
|
|
62
|
+
/** Firma un mensaje con caducidad: lo que firma la cuenta que sube o que lee. */
|
|
63
|
+
export type SignMessage = (message: string) => Promise<Hex>;
|
|
64
|
+
/**
|
|
65
|
+
* Deja un archivo en el buzón para `para`. Los bytes se suben tal cual
|
|
66
|
+
* llegan: si hay que cifrarlos, se cifran ANTES (y el hash es el de lo
|
|
67
|
+
* cifrado, que es lo que se guarda y se comprueba).
|
|
68
|
+
*/
|
|
69
|
+
export declare function uploadConsultaFile(params: {
|
|
70
|
+
buzonUrl: string;
|
|
71
|
+
para: Address;
|
|
72
|
+
de: Address;
|
|
73
|
+
file: OutgoingFile;
|
|
74
|
+
sign: SignMessage;
|
|
75
|
+
}): Promise<HashedFile>;
|
|
76
|
+
/**
|
|
77
|
+
* Baja un archivo de consulta y comprueba sus bytes contra el hash anunciado.
|
|
78
|
+
* Lanza si no cuadran: un archivo cambiado no se le pasa a nadie «avisando».
|
|
79
|
+
*/
|
|
80
|
+
export declare function downloadConsultaFile(params: {
|
|
81
|
+
buzonUrl: string;
|
|
82
|
+
/** Su destinatario: en qué buzón está. */
|
|
83
|
+
para: Address;
|
|
84
|
+
/** Quien lo lee: el destinatario o quien lo dejó. */
|
|
85
|
+
lector: Address;
|
|
86
|
+
file: HashedFile;
|
|
87
|
+
sign: SignMessage;
|
|
88
|
+
}): Promise<Uint8Array>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panal SDK — archivos en una pregunta pagada (x402) entre agentes.
|
|
3
|
+
*
|
|
4
|
+
* Una pregunta x402 es texto: 2.000 caracteres a la ida y una respuesta a la
|
|
5
|
+
* vuelta. Para pedirle a otro agente que lea una foto, transcriba un audio o
|
|
6
|
+
* resuma un PDF hacía falta poder mandarle el archivo, y que él pudiera
|
|
7
|
+
* devolver los suyos.
|
|
8
|
+
*
|
|
9
|
+
* Los bytes NO viajan en la pregunta. Van al buzón de Panal, a nombre de quien
|
|
10
|
+
* tiene que leerlos, y en la pregunta solo va la lista con su hash:
|
|
11
|
+
*
|
|
12
|
+
* POST /x402/ask { "prompt": "…", "files": [{ name, size, hash, mime }] }
|
|
13
|
+
*
|
|
14
|
+
* Quien recibe la lista baja cada archivo y comprueba sus bytes contra el hash
|
|
15
|
+
* antes de hacer nada: lo que le llega es exactamente lo que le anunciaron, y
|
|
16
|
+
* el buzón no puede cambiarlo por el camino. A la vuelta, igual al revés.
|
|
17
|
+
*
|
|
18
|
+
* En el buzón, cada archivo es de dos cuentas y de nadie más: quien lo dejó y
|
|
19
|
+
* su destinatario. Subirlo pide la firma de quien lo deja (con el destinatario
|
|
20
|
+
* y el hash dentro); bajarlo, la de una de las dos. Y si el destinatario tiene
|
|
21
|
+
* clave de cifrado publicada, se cifra para él antes de subirlo: entonces ni el
|
|
22
|
+
* buzón puede leerlo. Caduca como todo lo del buzón: es un relevo, no un archivo.
|
|
23
|
+
*
|
|
24
|
+
* Ningún mensaje lleva gas: son firmas EIP-191 con caducidad dentro.
|
|
25
|
+
*/
|
|
26
|
+
import { getAddress, isAddress, keccak256 } from 'viem';
|
|
27
|
+
import { fetchBytesLimited, fetchLimited, sinBarraFinal } from './net.js';
|
|
28
|
+
import { MAX_FILE_BYTES, sanitizeFileName, verifyFileBytes } from './files.js';
|
|
29
|
+
/** Cuántos archivos como mucho en una pregunta o en su respuesta. Como los adjuntos de un encargo. */
|
|
30
|
+
export const MAX_CONSULTA_FILES = 5;
|
|
31
|
+
/** Lo que dura una firma de subida o de lectura. */
|
|
32
|
+
const VENTANA_FIRMA_S = 10 * 60;
|
|
33
|
+
/** La capacidad con la que cotiza un agente con el motor de Panal. */
|
|
34
|
+
export const CONSULTA_FILES_CAPABILITY = { max: MAX_CONSULTA_FILES, maxBytes: MAX_FILE_BYTES };
|
|
35
|
+
/** Firma que autoriza a dejar ESTOS bytes para ESTE destinatario. */
|
|
36
|
+
export function consultaUploadMessage(para, hash, expira) {
|
|
37
|
+
return `Panal: dejo un archivo para ${getAddress(para)}\nhash: ${hash.toLowerCase()}\nexpira: ${expira}`;
|
|
38
|
+
}
|
|
39
|
+
/** Firma que autoriza a leer un archivo, a su destinatario o a quien lo dejó. */
|
|
40
|
+
export function consultaReadMessage(hash, expira) {
|
|
41
|
+
return `Panal: leer el archivo ${hash.toLowerCase()}\nexpira: ${expira}`;
|
|
42
|
+
}
|
|
43
|
+
/** La ruta de un archivo de consulta en el buzón. */
|
|
44
|
+
export function consultaFileUrl(buzonUrl, para, hash) {
|
|
45
|
+
// `sinBarraFinal` y no una expresión regular: con una URL llena de `/` una
|
|
46
|
+
// regex como /\/+$/ tarda en cuadrático (CodeQL js/polynomial-redos).
|
|
47
|
+
const base = `${sinBarraFinal(buzonUrl)}/${getAddress(para)}/consulta-archivo`;
|
|
48
|
+
return hash ? `${base}/${hash.toLowerCase()}` : base;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Valida la lista de archivos que llega en una pregunta o en una respuesta.
|
|
52
|
+
* Entrada no fiable: devuelve `null` si algo no cuadra, para rechazarla entera
|
|
53
|
+
* —una pregunta con un archivo mal anunciado no se cobra a medias—.
|
|
54
|
+
*/
|
|
55
|
+
export function parseConsultaFiles(value, max = MAX_CONSULTA_FILES) {
|
|
56
|
+
if (value === undefined || value === null)
|
|
57
|
+
return [];
|
|
58
|
+
if (!Array.isArray(value) || value.length > max)
|
|
59
|
+
return null;
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const v of value) {
|
|
62
|
+
if (!v || typeof v !== 'object')
|
|
63
|
+
return null;
|
|
64
|
+
const { name, size, hash, mime } = v;
|
|
65
|
+
if (typeof name !== 'string' || typeof hash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(hash))
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof size !== 'number' || !Number.isInteger(size) || size <= 0 || size > MAX_FILE_BYTES)
|
|
68
|
+
return null;
|
|
69
|
+
if (mime !== undefined && (typeof mime !== 'string' || mime.length > 120))
|
|
70
|
+
return null;
|
|
71
|
+
let limpio = '';
|
|
72
|
+
try {
|
|
73
|
+
limpio = sanitizeFileName(name);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!limpio)
|
|
79
|
+
return null;
|
|
80
|
+
out.push({ name: limpio, size, hash: hash.toLowerCase(), ...(mime ? { mime } : {}) });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Deja un archivo en el buzón para `para`. Los bytes se suben tal cual
|
|
86
|
+
* llegan: si hay que cifrarlos, se cifran ANTES (y el hash es el de lo
|
|
87
|
+
* cifrado, que es lo que se guarda y se comprueba).
|
|
88
|
+
*/
|
|
89
|
+
export async function uploadConsultaFile(params) {
|
|
90
|
+
const { file } = params;
|
|
91
|
+
if (file.bytes.byteLength === 0)
|
|
92
|
+
throw new Error(`"${file.name}" está vacío.`);
|
|
93
|
+
if (file.bytes.byteLength > MAX_FILE_BYTES) {
|
|
94
|
+
throw new Error(`"${file.name}" pasa de ${Math.round(MAX_FILE_BYTES / 1024 / 1024)} MB.`);
|
|
95
|
+
}
|
|
96
|
+
const name = sanitizeFileName(file.name);
|
|
97
|
+
if (!name)
|
|
98
|
+
throw new Error('El archivo no tiene un nombre válido.');
|
|
99
|
+
const hash = keccak256(file.bytes);
|
|
100
|
+
const expira = Math.floor(Date.now() / 1000) + VENTANA_FIRMA_S;
|
|
101
|
+
const signature = await params.sign(consultaUploadMessage(params.para, hash, expira));
|
|
102
|
+
const { status, text } = await fetchLimited(consultaFileUrl(params.buzonUrl, params.para), {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
'content-type': file.mime || 'application/octet-stream',
|
|
106
|
+
'x-panal-address': getAddress(params.de),
|
|
107
|
+
'x-panal-signature': signature,
|
|
108
|
+
'x-panal-expira': String(expira),
|
|
109
|
+
'x-panal-filename': encodeURIComponent(name),
|
|
110
|
+
},
|
|
111
|
+
body: file.bytes,
|
|
112
|
+
timeoutMs: 120_000,
|
|
113
|
+
maxBytes: 10_000,
|
|
114
|
+
});
|
|
115
|
+
if (status !== 200)
|
|
116
|
+
throw new Error(`El buzón no aceptó "${name}" (${status}: ${text.slice(0, 160)}).`);
|
|
117
|
+
return { name, size: file.bytes.byteLength, hash, ...(file.mime ? { mime: file.mime } : {}) };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Baja un archivo de consulta y comprueba sus bytes contra el hash anunciado.
|
|
121
|
+
* Lanza si no cuadran: un archivo cambiado no se le pasa a nadie «avisando».
|
|
122
|
+
*/
|
|
123
|
+
export async function downloadConsultaFile(params) {
|
|
124
|
+
const { file } = params;
|
|
125
|
+
if (!isAddress(params.para) || !isAddress(params.lector))
|
|
126
|
+
throw new Error('Dirección no válida.');
|
|
127
|
+
const expira = Math.floor(Date.now() / 1000) + VENTANA_FIRMA_S;
|
|
128
|
+
const signature = await params.sign(consultaReadMessage(file.hash, expira));
|
|
129
|
+
const { status, bytes } = await fetchBytesLimited(consultaFileUrl(params.buzonUrl, params.para, file.hash), {
|
|
130
|
+
headers: {
|
|
131
|
+
'x-panal-address': getAddress(params.lector),
|
|
132
|
+
'x-panal-signature': signature,
|
|
133
|
+
'x-panal-expira': String(expira),
|
|
134
|
+
},
|
|
135
|
+
timeoutMs: 120_000,
|
|
136
|
+
maxBytes: Math.min(MAX_FILE_BYTES, file.size) + 1024,
|
|
137
|
+
});
|
|
138
|
+
if (status !== 200)
|
|
139
|
+
throw new Error(`El buzón no dio "${file.name}" (${status}).`);
|
|
140
|
+
verifyFileBytes(file, bytes);
|
|
141
|
+
return bytes;
|
|
142
|
+
}
|
package/dist/files.d.ts
CHANGED
|
@@ -92,6 +92,16 @@ export declare function sanitizeFileName(name: string): string;
|
|
|
92
92
|
export declare function buildFilesManifest(files: DeliveredFile[]): string;
|
|
93
93
|
/** El texto de la entrega con el manifiesto pegado al final. */
|
|
94
94
|
export declare function appendFilesManifest(text: string, files: DeliveredFile[]): string;
|
|
95
|
+
/**
|
|
96
|
+
* Lee los bloques `clave: valor` que van bajo una cabecera dada.
|
|
97
|
+
*
|
|
98
|
+
* Lo comparten el manifiesto de entrega y el de adjuntos: los dos se anclan en
|
|
99
|
+
* la cadena y los dos tienen que leerse igual en todas partes. Un bloque
|
|
100
|
+
* termina en la primera línea vacía o que ya no es `clave: valor`, y eso basta
|
|
101
|
+
* para que dos manifiestos pegados no se contaminen: ninguna cabecera lleva
|
|
102
|
+
* dos puntos.
|
|
103
|
+
*/
|
|
104
|
+
export declare function parseBlocks(text: string, tag: string): Record<string, string>[];
|
|
95
105
|
/**
|
|
96
106
|
* Lee los archivos anunciados en el texto de una entrega.
|
|
97
107
|
*
|
|
@@ -101,10 +111,11 @@ export declare function appendFilesManifest(text: string, files: DeliveredFile[]
|
|
|
101
111
|
*/
|
|
102
112
|
export declare function parseFilesManifest(text: string): DeliveredFile[];
|
|
103
113
|
/**
|
|
104
|
-
* El texto de la entrega sin los bloques
|
|
114
|
+
* El texto de la entrega sin los bloques de Panal: archivos, adjuntos y
|
|
115
|
+
* subcontrataciones.
|
|
105
116
|
*
|
|
106
|
-
* Para enseñárselo a una persona:
|
|
107
|
-
* chat de Telegram no
|
|
117
|
+
* Para enseñárselo a una persona: los bloques son para la máquina, y en un
|
|
118
|
+
* chat de Telegram no aportan más que ruido. Lo que dicen se enseña aparte.
|
|
108
119
|
*/
|
|
109
120
|
export declare function stripFilesManifest(text: string): string;
|
|
110
121
|
/** Comprueba unos bytes contra lo que el manifiesto prometía. Lanza si no cuadra. */
|
package/dist/files.js
CHANGED
|
@@ -111,7 +111,7 @@ export function appendFilesManifest(text, files) {
|
|
|
111
111
|
* para que dos manifiestos pegados no se contaminen: ninguna cabecera lleva
|
|
112
112
|
* dos puntos.
|
|
113
113
|
*/
|
|
114
|
-
function
|
|
114
|
+
export function parseBlocks(text, tag) {
|
|
115
115
|
const out = [];
|
|
116
116
|
const lineas = text.split(/\r?\n/);
|
|
117
117
|
for (let i = 0; i < lineas.length; i++) {
|
|
@@ -161,7 +161,7 @@ function parseComun(campos) {
|
|
|
161
161
|
*/
|
|
162
162
|
export function parseFilesManifest(text) {
|
|
163
163
|
const out = [];
|
|
164
|
-
for (const campos of
|
|
164
|
+
for (const campos of parseBlocks(text, FILES_BLOCK)) {
|
|
165
165
|
const comun = parseComun(campos);
|
|
166
166
|
if (!comun)
|
|
167
167
|
continue;
|
|
@@ -175,17 +175,24 @@ export function parseFilesManifest(text) {
|
|
|
175
175
|
return out;
|
|
176
176
|
}
|
|
177
177
|
/**
|
|
178
|
-
*
|
|
178
|
+
* Cualquier cabecera de bloque de Panal: `[panal-files/1]`, `[panal-attach/1]`,
|
|
179
|
+
* `[panal-subcontracts/1]` y las que vengan. Se reconocen por la forma para que
|
|
180
|
+
* una versión nueva de un bloque no le salga a nadie como texto de la entrega.
|
|
181
|
+
*/
|
|
182
|
+
const CABECERA_DE_BLOQUE = /^\[panal-[a-z]+\/\d+\]$/;
|
|
183
|
+
/**
|
|
184
|
+
* El texto de la entrega sin los bloques de Panal: archivos, adjuntos y
|
|
185
|
+
* subcontrataciones.
|
|
179
186
|
*
|
|
180
|
-
* Para enseñárselo a una persona:
|
|
181
|
-
* chat de Telegram no
|
|
187
|
+
* Para enseñárselo a una persona: los bloques son para la máquina, y en un
|
|
188
|
+
* chat de Telegram no aportan más que ruido. Lo que dicen se enseña aparte.
|
|
182
189
|
*/
|
|
183
190
|
export function stripFilesManifest(text) {
|
|
184
191
|
const lineas = text.split(/\r?\n/);
|
|
185
192
|
const fuera = [];
|
|
186
193
|
let dentro = false;
|
|
187
194
|
for (const linea of lineas) {
|
|
188
|
-
if (
|
|
195
|
+
if (CABECERA_DE_BLOQUE.test(linea.trim())) {
|
|
189
196
|
dentro = true;
|
|
190
197
|
continue;
|
|
191
198
|
}
|
|
@@ -332,7 +339,7 @@ export function appendAttachmentsManifest(brief, files) {
|
|
|
332
339
|
/** Lee los adjuntos anunciados en un encargo. No lanza por un bloque roto. */
|
|
333
340
|
export function parseAttachmentsManifest(brief) {
|
|
334
341
|
const out = [];
|
|
335
|
-
for (const campos of
|
|
342
|
+
for (const campos of parseBlocks(brief, ATTACH_BLOCK)) {
|
|
336
343
|
const comun = parseComun(campos);
|
|
337
344
|
if (comun)
|
|
338
345
|
out.push(comun);
|
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, normalizeAgentLink,
|
|
|
27
27
|
export type { Agent, AgentLinkKey, AgentLinks, AgentMetadata, Task } from './types.js';
|
|
28
28
|
export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
|
|
29
29
|
export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
|
|
30
|
+
export { MAX_SUBCONTRACTS, SUBCONTRACTS_BLOCK, appendSubcontractsManifest, buildSubcontractsManifest, parseSubcontractsManifest, subcontractsFromJson, subcontractsOfAnswer, subcontractsToJson, type SubcontractJson, type SubcontractRecord, } from './subcontratos.js';
|
|
31
|
+
export { CONSULTA_FILES_CAPABILITY, MAX_CONSULTA_FILES, consultaFileUrl, consultaReadMessage, consultaUploadMessage, downloadConsultaFile, parseConsultaFiles, uploadConsultaFile, type ConsultaFilesCapability, type OutgoingFile, type ReceivedFile, type SignMessage, } from './consultas.js';
|
|
30
32
|
export { X402_NATIVE, formatX402Amount, networkOfChain, parseX402Amount, x402Currencies, x402Currency, x402CurrencyByName, } from './currencies.js';
|
|
31
33
|
export type { X402Currency, X402Scheme } from './currencies.js';
|
|
32
34
|
export type { AskResult, PayAndAskOptions, PermitDomain, X402Accept, X402Quote } from './x402.js';
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,8 @@ export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, normalizeAgentLink,
|
|
|
25
25
|
export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
|
|
26
26
|
// x402: pagar a otro agente por una consulta, sin escrow y sin humano.
|
|
27
27
|
export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
|
|
28
|
+
export { MAX_SUBCONTRACTS, SUBCONTRACTS_BLOCK, appendSubcontractsManifest, buildSubcontractsManifest, parseSubcontractsManifest, subcontractsFromJson, subcontractsOfAnswer, subcontractsToJson, } from './subcontratos.js';
|
|
29
|
+
export { CONSULTA_FILES_CAPABILITY, MAX_CONSULTA_FILES, consultaFileUrl, consultaReadMessage, consultaUploadMessage, downloadConsultaFile, parseConsultaFiles, uploadConsultaFile, } from './consultas.js';
|
|
28
30
|
export { X402_NATIVE, formatX402Amount, networkOfChain, parseX402Amount, x402Currencies, x402Currency, x402CurrencyByName, } from './currencies.js';
|
|
29
31
|
export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
|
|
30
32
|
// Archivos: entregar un PDF o un vídeo anclando SU hash, no el del enlace.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panal SDK — a quién pagó un agente para hacer tu encargo, dicho en la entrega.
|
|
3
|
+
*
|
|
4
|
+
* Un agente puede subcontratar: preguntarle a otro, pagándole de lo que cobró,
|
|
5
|
+
* lo que no sabe hacer. Hasta ahora eso solo quedaba en los logs de quien lo
|
|
6
|
+
* hacía, y el cliente recibía un trabajo en el que había participado gente que
|
|
7
|
+
* no conocía. Si además le manda sus archivos a ese otro, deja de ser un
|
|
8
|
+
* detalle interno: es su información en manos de un tercero.
|
|
9
|
+
*
|
|
10
|
+
* Por eso va DENTRO del texto que se entrega, como el manifiesto de archivos:
|
|
11
|
+
*
|
|
12
|
+
* Aquí tienes la revisión.
|
|
13
|
+
*
|
|
14
|
+
* [panal-subcontracts/1]
|
|
15
|
+
* agent: 0x8e9c…
|
|
16
|
+
* skill: tests
|
|
17
|
+
* amount: 150000000000000000
|
|
18
|
+
* currency: $PANAL
|
|
19
|
+
* asset: 0x2E2e…7777
|
|
20
|
+
* decimals: 18
|
|
21
|
+
* tx: 0x51ab…
|
|
22
|
+
*
|
|
23
|
+
* Ese texto es el que se ancla en la cadena, así que el apartado no se puede
|
|
24
|
+
* quitar ni cambiar después de entregar, y cada pago se comprueba en el
|
|
25
|
+
* explorador con su `tx`. Lo escribe el motor del agente, no su autor: ningún
|
|
26
|
+
* `handleTask` puede olvidarse de decirlo.
|
|
27
|
+
*
|
|
28
|
+
* `via` es para la cadena entera. Si el agente que contrataste pagó a B y B, a
|
|
29
|
+
* su vez, pagó a C, la entrega lleva a B y a C, y C lleva `via: B`. El dinero
|
|
30
|
+
* de C salió de B, no de ti, pero tu información pasó por los dos.
|
|
31
|
+
*
|
|
32
|
+
* Por la red, en la respuesta de una pregunta x402, la misma lista va en JSON
|
|
33
|
+
* (`subcontracts`): así el que pregunta sabe a quién subcontrató el que le
|
|
34
|
+
* respondió, sin leer su texto.
|
|
35
|
+
*/
|
|
36
|
+
import type { Address, Hex } from 'viem';
|
|
37
|
+
/** Cabecera del bloque. Lleva versión porque el formato se ancla en la cadena. */
|
|
38
|
+
export declare const SUBCONTRACTS_BLOCK = "[panal-subcontracts/1]";
|
|
39
|
+
/**
|
|
40
|
+
* Cuántas subcontrataciones se cuentan como mucho en una entrega.
|
|
41
|
+
*
|
|
42
|
+
* Una cadena tiene un tope de saltos y cada agente un presupuesto, así que en
|
|
43
|
+
* la práctica son pocas; el tope es para que una respuesta hostil no llene una
|
|
44
|
+
* entrega con miles de líneas.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MAX_SUBCONTRACTS = 32;
|
|
47
|
+
/** Una subcontratación: a quién, para qué y cuánto se le pagó. */
|
|
48
|
+
export interface SubcontractRecord {
|
|
49
|
+
/** El agente que cobró y respondió. */
|
|
50
|
+
agent: Address;
|
|
51
|
+
/** Para qué: la skill con la que se le buscó. */
|
|
52
|
+
skill: string;
|
|
53
|
+
/** Lo pagado, en unidades mínimas de `currency`. */
|
|
54
|
+
amount: bigint;
|
|
55
|
+
/** En qué se pagó. */
|
|
56
|
+
currency: {
|
|
57
|
+
symbol: string;
|
|
58
|
+
address: Address;
|
|
59
|
+
decimals: number;
|
|
60
|
+
};
|
|
61
|
+
/** La transacción del cobro, si el agente la reportó. */
|
|
62
|
+
txHash?: Hex;
|
|
63
|
+
/** Si no lo contrató el agente del encargo sino uno de sus subcontratados: cuál. */
|
|
64
|
+
via?: Address;
|
|
65
|
+
/** Cuántos archivos se le mandaron con la pregunta: tu información pasó por él. */
|
|
66
|
+
files?: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Construye el bloque. Orden de claves fijo y `\n`: se ancla en la cadena, y
|
|
70
|
+
* las mismas subcontrataciones tienen que dar exactamente los mismos bytes.
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildSubcontractsManifest(list: SubcontractRecord[]): string;
|
|
73
|
+
/** El texto de la entrega con las subcontrataciones al final. Sin ninguna, igual que estaba. */
|
|
74
|
+
export declare function appendSubcontractsManifest(text: string, list: SubcontractRecord[]): string;
|
|
75
|
+
/**
|
|
76
|
+
* Lee las subcontrataciones anunciadas en una entrega.
|
|
77
|
+
*
|
|
78
|
+
* Nunca lanza: un bloque mal formado se salta y los demás se leen. Que una
|
|
79
|
+
* línea rota impida leer la entrega sería peor que no enseñar esa línea.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseSubcontractsManifest(text: string): SubcontractRecord[];
|
|
82
|
+
/** La forma JSON, para la respuesta de una pregunta x402. */
|
|
83
|
+
export interface SubcontractJson {
|
|
84
|
+
agent: string;
|
|
85
|
+
skill: string;
|
|
86
|
+
amount: string;
|
|
87
|
+
currency: string;
|
|
88
|
+
asset: string;
|
|
89
|
+
decimals: number;
|
|
90
|
+
tx?: string;
|
|
91
|
+
via?: string;
|
|
92
|
+
files?: number;
|
|
93
|
+
}
|
|
94
|
+
export declare function subcontractsToJson(list: SubcontractRecord[]): SubcontractJson[];
|
|
95
|
+
/**
|
|
96
|
+
* Lee la lista que manda otro agente en su respuesta. Es entrada no fiable:
|
|
97
|
+
* se valida entrada a entrada y lo que no cuadra se descarta, nunca se lanza.
|
|
98
|
+
*/
|
|
99
|
+
export declare function subcontractsFromJson(value: unknown): SubcontractRecord[];
|
|
100
|
+
/**
|
|
101
|
+
* Lo que apunta un agente al subcontratar: la subcontratación directa y, detrás,
|
|
102
|
+
* las que hizo a su vez el que respondió, con `via` para saber de dónde vienen.
|
|
103
|
+
*/
|
|
104
|
+
export declare function subcontractsOfAnswer(direct: Omit<SubcontractRecord, 'via'>, nested?: SubcontractRecord[]): SubcontractRecord[];
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panal SDK — a quién pagó un agente para hacer tu encargo, dicho en la entrega.
|
|
3
|
+
*
|
|
4
|
+
* Un agente puede subcontratar: preguntarle a otro, pagándole de lo que cobró,
|
|
5
|
+
* lo que no sabe hacer. Hasta ahora eso solo quedaba en los logs de quien lo
|
|
6
|
+
* hacía, y el cliente recibía un trabajo en el que había participado gente que
|
|
7
|
+
* no conocía. Si además le manda sus archivos a ese otro, deja de ser un
|
|
8
|
+
* detalle interno: es su información en manos de un tercero.
|
|
9
|
+
*
|
|
10
|
+
* Por eso va DENTRO del texto que se entrega, como el manifiesto de archivos:
|
|
11
|
+
*
|
|
12
|
+
* Aquí tienes la revisión.
|
|
13
|
+
*
|
|
14
|
+
* [panal-subcontracts/1]
|
|
15
|
+
* agent: 0x8e9c…
|
|
16
|
+
* skill: tests
|
|
17
|
+
* amount: 150000000000000000
|
|
18
|
+
* currency: $PANAL
|
|
19
|
+
* asset: 0x2E2e…7777
|
|
20
|
+
* decimals: 18
|
|
21
|
+
* tx: 0x51ab…
|
|
22
|
+
*
|
|
23
|
+
* Ese texto es el que se ancla en la cadena, así que el apartado no se puede
|
|
24
|
+
* quitar ni cambiar después de entregar, y cada pago se comprueba en el
|
|
25
|
+
* explorador con su `tx`. Lo escribe el motor del agente, no su autor: ningún
|
|
26
|
+
* `handleTask` puede olvidarse de decirlo.
|
|
27
|
+
*
|
|
28
|
+
* `via` es para la cadena entera. Si el agente que contrataste pagó a B y B, a
|
|
29
|
+
* su vez, pagó a C, la entrega lleva a B y a C, y C lleva `via: B`. El dinero
|
|
30
|
+
* de C salió de B, no de ti, pero tu información pasó por los dos.
|
|
31
|
+
*
|
|
32
|
+
* Por la red, en la respuesta de una pregunta x402, la misma lista va en JSON
|
|
33
|
+
* (`subcontracts`): así el que pregunta sabe a quién subcontrató el que le
|
|
34
|
+
* respondió, sin leer su texto.
|
|
35
|
+
*/
|
|
36
|
+
import { getAddress, isAddress } from 'viem';
|
|
37
|
+
import { parseBlocks } from './files.js';
|
|
38
|
+
/** Cabecera del bloque. Lleva versión porque el formato se ancla en la cadena. */
|
|
39
|
+
export const SUBCONTRACTS_BLOCK = '[panal-subcontracts/1]';
|
|
40
|
+
/**
|
|
41
|
+
* Cuántas subcontrataciones se cuentan como mucho en una entrega.
|
|
42
|
+
*
|
|
43
|
+
* Una cadena tiene un tope de saltos y cada agente un presupuesto, así que en
|
|
44
|
+
* la práctica son pocas; el tope es para que una respuesta hostil no llene una
|
|
45
|
+
* entrega con miles de líneas.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_SUBCONTRACTS = 32;
|
|
48
|
+
/** Una línea `clave: valor` no puede llevar saltos, y la skill la eligió un modelo. */
|
|
49
|
+
function enUnaLinea(texto, max) {
|
|
50
|
+
return texto.replace(/[\r\n\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Construye el bloque. Orden de claves fijo y `\n`: se ancla en la cadena, y
|
|
54
|
+
* las mismas subcontrataciones tienen que dar exactamente los mismos bytes.
|
|
55
|
+
*/
|
|
56
|
+
export function buildSubcontractsManifest(list) {
|
|
57
|
+
return list
|
|
58
|
+
.slice(0, MAX_SUBCONTRACTS)
|
|
59
|
+
.map((s) => {
|
|
60
|
+
const lineas = [
|
|
61
|
+
SUBCONTRACTS_BLOCK,
|
|
62
|
+
`agent: ${getAddress(s.agent)}`,
|
|
63
|
+
`skill: ${enUnaLinea(s.skill, 64)}`,
|
|
64
|
+
`amount: ${s.amount.toString()}`,
|
|
65
|
+
`currency: ${enUnaLinea(s.currency.symbol, 16)}`,
|
|
66
|
+
`asset: ${getAddress(s.currency.address)}`,
|
|
67
|
+
`decimals: ${s.currency.decimals}`,
|
|
68
|
+
];
|
|
69
|
+
if (s.txHash)
|
|
70
|
+
lineas.push(`tx: ${s.txHash.toLowerCase()}`);
|
|
71
|
+
if (s.via)
|
|
72
|
+
lineas.push(`via: ${getAddress(s.via)}`);
|
|
73
|
+
if (s.files)
|
|
74
|
+
lineas.push(`files: ${s.files}`);
|
|
75
|
+
return lineas.join('\n');
|
|
76
|
+
})
|
|
77
|
+
.join('\n\n');
|
|
78
|
+
}
|
|
79
|
+
/** El texto de la entrega con las subcontrataciones al final. Sin ninguna, igual que estaba. */
|
|
80
|
+
export function appendSubcontractsManifest(text, list) {
|
|
81
|
+
if (list.length === 0)
|
|
82
|
+
return text;
|
|
83
|
+
return `${text.trimEnd()}\n\n${buildSubcontractsManifest(list)}\n`;
|
|
84
|
+
}
|
|
85
|
+
/** Valida una subcontratación venga de donde venga. Lo que no se entiende, se descarta. */
|
|
86
|
+
function validar(c) {
|
|
87
|
+
const { agent, skill, amount, currency, asset, decimals, tx, via, files } = c;
|
|
88
|
+
if (typeof agent !== 'string' || !isAddress(agent))
|
|
89
|
+
return null;
|
|
90
|
+
if (typeof asset !== 'string' || !isAddress(asset))
|
|
91
|
+
return null;
|
|
92
|
+
if (typeof skill !== 'string' || !enUnaLinea(skill, 64))
|
|
93
|
+
return null;
|
|
94
|
+
if (typeof currency !== 'string' || !enUnaLinea(currency, 16))
|
|
95
|
+
return null;
|
|
96
|
+
const cantidad = typeof amount === 'string' && /^\d{1,78}$/.test(amount) ? BigInt(amount) : null;
|
|
97
|
+
if (cantidad === null)
|
|
98
|
+
return null;
|
|
99
|
+
const dec = typeof decimals === 'number' ? decimals : Number(decimals);
|
|
100
|
+
if (!Number.isInteger(dec) || dec < 0 || dec > 36)
|
|
101
|
+
return null;
|
|
102
|
+
if (tx !== undefined && (typeof tx !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(tx)))
|
|
103
|
+
return null;
|
|
104
|
+
if (via !== undefined && (typeof via !== 'string' || !isAddress(via)))
|
|
105
|
+
return null;
|
|
106
|
+
// Un recuento raro no tumba la línea: solo se deja de decir.
|
|
107
|
+
const archivos = files === undefined ? 0 : Number(files);
|
|
108
|
+
return {
|
|
109
|
+
agent: getAddress(agent),
|
|
110
|
+
skill: enUnaLinea(skill, 64),
|
|
111
|
+
amount: cantidad,
|
|
112
|
+
currency: { symbol: enUnaLinea(currency, 16), address: getAddress(asset), decimals: dec },
|
|
113
|
+
...(tx ? { txHash: tx.toLowerCase() } : {}),
|
|
114
|
+
...(via ? { via: getAddress(via) } : {}),
|
|
115
|
+
...(Number.isInteger(archivos) && archivos > 0 && archivos <= 1000 ? { files: archivos } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Lee las subcontrataciones anunciadas en una entrega.
|
|
120
|
+
*
|
|
121
|
+
* Nunca lanza: un bloque mal formado se salta y los demás se leen. Que una
|
|
122
|
+
* línea rota impida leer la entrega sería peor que no enseñar esa línea.
|
|
123
|
+
*/
|
|
124
|
+
export function parseSubcontractsManifest(text) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const campos of parseBlocks(text, SUBCONTRACTS_BLOCK)) {
|
|
127
|
+
const s = validar(campos);
|
|
128
|
+
if (s)
|
|
129
|
+
out.push(s);
|
|
130
|
+
if (out.length >= MAX_SUBCONTRACTS)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
export function subcontractsToJson(list) {
|
|
136
|
+
return list.slice(0, MAX_SUBCONTRACTS).map((s) => ({
|
|
137
|
+
agent: s.agent,
|
|
138
|
+
skill: s.skill,
|
|
139
|
+
amount: s.amount.toString(),
|
|
140
|
+
currency: s.currency.symbol,
|
|
141
|
+
asset: s.currency.address,
|
|
142
|
+
decimals: s.currency.decimals,
|
|
143
|
+
...(s.txHash ? { tx: s.txHash } : {}),
|
|
144
|
+
...(s.via ? { via: s.via } : {}),
|
|
145
|
+
...(s.files ? { files: s.files } : {}),
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Lee la lista que manda otro agente en su respuesta. Es entrada no fiable:
|
|
150
|
+
* se valida entrada a entrada y lo que no cuadra se descarta, nunca se lanza.
|
|
151
|
+
*/
|
|
152
|
+
export function subcontractsFromJson(value) {
|
|
153
|
+
if (!Array.isArray(value))
|
|
154
|
+
return [];
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const v of value.slice(0, MAX_SUBCONTRACTS)) {
|
|
157
|
+
if (!v || typeof v !== 'object')
|
|
158
|
+
continue;
|
|
159
|
+
const s = validar(v);
|
|
160
|
+
if (s)
|
|
161
|
+
out.push(s);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Lo que apunta un agente al subcontratar: la subcontratación directa y, detrás,
|
|
167
|
+
* las que hizo a su vez el que respondió, con `via` para saber de dónde vienen.
|
|
168
|
+
*/
|
|
169
|
+
export function subcontractsOfAnswer(direct, nested = []) {
|
|
170
|
+
return [direct, ...nested.map((s) => ({ ...s, via: s.via ?? direct.agent }))].slice(0, MAX_SUBCONTRACTS);
|
|
171
|
+
}
|
package/dist/x402-server.d.ts
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import { type Address, type Hex, type PublicClient, type WalletClient } from 'viem';
|
|
38
38
|
import { type PanalNetwork } from './chains.js';
|
|
39
39
|
import type { PermitDomain } from './x402.js';
|
|
40
|
+
import type { ConsultaFilesCapability } from './consultas.js';
|
|
40
41
|
export type { PermitDomain };
|
|
41
42
|
export declare const X402_VERSION = 1;
|
|
42
43
|
export declare const X402_SERVER_SCHEME = "eip2612-permit";
|
|
@@ -110,6 +111,8 @@ export interface X402ServerAccept {
|
|
|
110
111
|
payerNonce?: string;
|
|
111
112
|
/** Dominio EIP-712 con el que firmar, para no obligar al cliente a leerlo. */
|
|
112
113
|
domain: PermitDomain;
|
|
114
|
+
/** Si se aceptan archivos con la pregunta (ver `consultas.ts`). */
|
|
115
|
+
files?: ConsultaFilesCapability;
|
|
113
116
|
}
|
|
114
117
|
export interface X402ServerQuote {
|
|
115
118
|
x402Version: typeof X402_VERSION;
|
|
@@ -128,6 +131,8 @@ export declare function buildQuote(params: {
|
|
|
128
131
|
payerNonce?: bigint;
|
|
129
132
|
network?: PanalNetwork;
|
|
130
133
|
nowS?: number;
|
|
134
|
+
/** Anunciar que se aceptan archivos: con el motor de Panal, `CONSULTA_FILES_CAPABILITY`. */
|
|
135
|
+
files?: ConsultaFilesCapability;
|
|
131
136
|
}): X402ServerQuote;
|
|
132
137
|
export interface X402Payment {
|
|
133
138
|
scheme: typeof X402_SERVER_SCHEME;
|
|
@@ -194,6 +199,8 @@ export interface X402NativeAccept {
|
|
|
194
199
|
maxTimeoutSeconds: number;
|
|
195
200
|
/** Secreto de esta cotización: se presenta con el pago, nunca va a la cadena. */
|
|
196
201
|
paymentId: Hex;
|
|
202
|
+
/** Si se aceptan archivos con la pregunta (ver `consultas.ts`). */
|
|
203
|
+
files?: ConsultaFilesCapability;
|
|
197
204
|
}
|
|
198
205
|
/**
|
|
199
206
|
* La cotización de una pregunta en MON.
|
|
@@ -212,6 +219,8 @@ export declare function buildNativeQuote(params: {
|
|
|
212
219
|
payer?: Address | null;
|
|
213
220
|
network?: PanalNetwork;
|
|
214
221
|
nowS?: number;
|
|
222
|
+
/** Anunciar que se aceptan archivos: con el motor de Panal, `CONSULTA_FILES_CAPABILITY`. */
|
|
223
|
+
files?: ConsultaFilesCapability;
|
|
215
224
|
}): {
|
|
216
225
|
x402Version: typeof X402_VERSION;
|
|
217
226
|
accepts: X402NativeAccept[];
|
package/dist/x402-server.js
CHANGED
|
@@ -172,6 +172,7 @@ export function buildQuote(params) {
|
|
|
172
172
|
maxTimeoutSeconds: 120,
|
|
173
173
|
payerNonce: params.payerNonce?.toString(),
|
|
174
174
|
domain: params.domain,
|
|
175
|
+
...(params.files ? { files: params.files } : {}),
|
|
175
176
|
},
|
|
176
177
|
],
|
|
177
178
|
hint: 'Sign an EIP-2612 permit with the domain and fields of accepts[0] (spender = payTo, ' +
|
|
@@ -477,6 +478,7 @@ export function buildNativeQuote(params) {
|
|
|
477
478
|
deadline,
|
|
478
479
|
maxTimeoutSeconds: 120,
|
|
479
480
|
paymentId: nativePaymentId(params.secret, { payer, payTo: params.payTo, amount, deadline, resource: params.resource }),
|
|
481
|
+
...(params.files ? { files: params.files } : {}),
|
|
480
482
|
},
|
|
481
483
|
],
|
|
482
484
|
hint: 'Send EXACTLY `amount` MON to `payTo` before `deadline`, then repeat the request with the header ' +
|
package/dist/x402.d.ts
CHANGED
|
@@ -19,6 +19,9 @@ import type { Account, Address, Hex, WalletClient } from 'viem';
|
|
|
19
19
|
import { type UrlGuardOptions } from './net.js';
|
|
20
20
|
import { type CallEnvelope } from './envelope.js';
|
|
21
21
|
import { type X402Currency } from './currencies.js';
|
|
22
|
+
import { type SubcontractRecord } from './subcontratos.js';
|
|
23
|
+
import { type ConsultaFilesCapability, type ReceivedFile } from './consultas.js';
|
|
24
|
+
import type { HashedFile } from './files.js';
|
|
22
25
|
/** El esquema de las monedas con `permit`. Debe coincidir con el servidor. */
|
|
23
26
|
export declare const X402_SCHEME = "eip2612-permit";
|
|
24
27
|
export interface PermitDomain {
|
|
@@ -45,6 +48,12 @@ export interface X402Accept {
|
|
|
45
48
|
domain?: PermitDomain;
|
|
46
49
|
/** Solo en MON: el código de esta cotización, que se presenta con el pago. */
|
|
47
50
|
paymentId?: Hex;
|
|
51
|
+
/**
|
|
52
|
+
* Si el agente acepta archivos con la pregunta, cuántos y de qué tamaño.
|
|
53
|
+
* Sin esto, no se le mandan: un agente que no los espera los ignoraría y
|
|
54
|
+
* cobraría igual una respuesta que no los ha mirado. Ver `consultas.ts`.
|
|
55
|
+
*/
|
|
56
|
+
files?: ConsultaFilesCapability;
|
|
48
57
|
}
|
|
49
58
|
export interface X402Quote {
|
|
50
59
|
x402Version?: number;
|
|
@@ -87,6 +96,12 @@ export interface PayAndAskOptions extends UrlGuardOptions {
|
|
|
87
96
|
* ha gastado su salto. Ver `descend()` en envelope.ts.
|
|
88
97
|
*/
|
|
89
98
|
envelope?: CallEnvelope;
|
|
99
|
+
/**
|
|
100
|
+
* Archivos que acompañan a la pregunta, YA en el buzón a nombre del agente
|
|
101
|
+
* (ver `uploadConsultaFile`): aquí solo va su lista. Si la cotización no
|
|
102
|
+
* dice que el agente los acepta, no se firma nada.
|
|
103
|
+
*/
|
|
104
|
+
files?: HashedFile[];
|
|
90
105
|
}
|
|
91
106
|
export interface AskResult {
|
|
92
107
|
answer: string;
|
|
@@ -99,6 +114,19 @@ export interface AskResult {
|
|
|
99
114
|
/** Transacción del cobro, si el servidor la reporta. */
|
|
100
115
|
txHash?: Hex;
|
|
101
116
|
endpoint: string;
|
|
117
|
+
/**
|
|
118
|
+
* A quién subcontrató, a su vez, el agente que respondió para contestarte
|
|
119
|
+
* (vacío si no subcontrató o si su motor no lo dice). Ver `subcontratos.ts`.
|
|
120
|
+
*/
|
|
121
|
+
subcontracts: SubcontractRecord[];
|
|
122
|
+
/** Los archivos que devolvió, tal y como los anunció: dejados en el buzón a tu nombre. */
|
|
123
|
+
fileManifest: HashedFile[];
|
|
124
|
+
/**
|
|
125
|
+
* Esos archivos ya bajados, comprobados y abiertos. Los rellena el cliente
|
|
126
|
+
* (`ask`, `askAgent`); con `payAndAsk` a pelo va vacío y la lista está en
|
|
127
|
+
* `fileManifest`, para bajarlos con `downloadConsultaFile`.
|
|
128
|
+
*/
|
|
129
|
+
files: ReceivedFile[];
|
|
102
130
|
}
|
|
103
131
|
/**
|
|
104
132
|
* Paga una consulta a un endpoint x402 y devuelve la respuesta.
|
package/dist/x402.js
CHANGED
|
@@ -20,6 +20,8 @@ import { estimateGas, waitForTransactionReceipt } from 'viem/actions';
|
|
|
20
20
|
import { assertPublicUrl, fetchLimited } from './net.js';
|
|
21
21
|
import { envelopeHeaders } from './envelope.js';
|
|
22
22
|
import { networkOfChain, x402Currency } from './currencies.js';
|
|
23
|
+
import { subcontractsFromJson } from './subcontratos.js';
|
|
24
|
+
import { parseConsultaFiles } from './consultas.js';
|
|
23
25
|
/** El esquema de las monedas con `permit`. Debe coincidir con el servidor. */
|
|
24
26
|
export const X402_SCHEME = 'eip2612-permit';
|
|
25
27
|
/** El de MON, que no tiene `permit`: se manda una transferencia y se presenta. */
|
|
@@ -112,6 +114,19 @@ export async function payAndAsk(wallet, account, endpoint, prompt, options) {
|
|
|
112
114
|
// atacante creyendo que pagas al agente que elegiste.
|
|
113
115
|
throw new X402Error(`La cotización cobra a ${accept.payTo} y esperabas a ${options.expectedPayee}: no se firma.`);
|
|
114
116
|
}
|
|
117
|
+
const enviados = options.files ?? [];
|
|
118
|
+
if (enviados.length) {
|
|
119
|
+
const puede = accept.files;
|
|
120
|
+
if (!puede) {
|
|
121
|
+
throw new X402Error('Este agente no acepta archivos con la pregunta: no se firma.');
|
|
122
|
+
}
|
|
123
|
+
if (enviados.length > puede.max) {
|
|
124
|
+
throw new X402Error(`Este agente acepta ${puede.max} archivo(s) por pregunta y mandas ${enviados.length}.`);
|
|
125
|
+
}
|
|
126
|
+
const grande = enviados.find((f) => f.size > puede.maxBytes);
|
|
127
|
+
if (grande)
|
|
128
|
+
throw new X402Error(`"${grande.name}" pasa del tamaño que acepta este agente.`);
|
|
129
|
+
}
|
|
115
130
|
if (options.asset && getAddress(accept.asset) !== getAddress(options.asset)) {
|
|
116
131
|
throw new X402Error(`La cotización pide pagar en ${accept.asset} y esperabas ${options.asset}.`);
|
|
117
132
|
}
|
|
@@ -146,7 +161,7 @@ export async function payAndAsk(wallet, account, endpoint, prompt, options) {
|
|
|
146
161
|
'x-payment': header.value,
|
|
147
162
|
...(options.envelope ? envelopeHeaders(options.envelope) : {}),
|
|
148
163
|
},
|
|
149
|
-
body: JSON.stringify({ prompt }),
|
|
164
|
+
body: JSON.stringify(enviados.length ? { prompt, files: enviados } : { prompt }),
|
|
150
165
|
timeoutMs: options.timeoutMs ?? (accept.maxTimeoutSeconds ?? 120) * 1000,
|
|
151
166
|
});
|
|
152
167
|
// `paid` es como lo devuelve la plantilla de agentes; `payment`, como lo
|
|
@@ -181,6 +196,11 @@ export async function payAndAsk(wallet, account, endpoint, prompt, options) {
|
|
|
181
196
|
payee: getAddress(accept.payTo),
|
|
182
197
|
txHash: body.payment?.txHash ?? body.paid?.txHash ?? header.txHash,
|
|
183
198
|
endpoint: url.toString(),
|
|
199
|
+
// Entrada de otro agente: se valida entrada a entrada y lo que no cuadra se descarta.
|
|
200
|
+
subcontracts: subcontractsFromJson(body.subcontracts),
|
|
201
|
+
// Una lista de archivos mal formada se ignora entera: no hay a medias.
|
|
202
|
+
fileManifest: parseConsultaFiles(body.files) ?? [],
|
|
203
|
+
files: [],
|
|
184
204
|
};
|
|
185
205
|
}
|
|
186
206
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panal/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "SDK de Panal: contrata agentes de IA autonomos on-chain en Monad",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "tsc -p tsconfig.json",
|
|
52
52
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
53
|
-
"test": "tsx test/sdk.test.ts && tsx test/currencies.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts && tsx test/files.test.ts && tsx test/attachments.test.ts && tsx test/llm.test.ts && tsx test/skill.test.ts && tsx test/agent-card.test.ts && tsx test/niveles.test.ts && tsx test/ejemplos.test.ts && tsx test/tablon.test.ts && tsx test/reserva.test.ts && tsx test/precios.test.ts && tsx test/tablon-reglas.test.ts && tsx test/cifrado.test.ts",
|
|
53
|
+
"test": "tsx test/sdk.test.ts && tsx test/currencies.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts && tsx test/files.test.ts && tsx test/attachments.test.ts && tsx test/llm.test.ts && tsx test/skill.test.ts && tsx test/agent-card.test.ts && tsx test/niveles.test.ts && tsx test/ejemplos.test.ts && tsx test/tablon.test.ts && tsx test/reserva.test.ts && tsx test/precios.test.ts && tsx test/tablon-reglas.test.ts && tsx test/cifrado.test.ts && tsx test/subcontratos.test.ts && tsx test/consultas.test.ts",
|
|
54
54
|
"test:nombres": "tsx test/nombres.test.ts"
|
|
55
55
|
}
|
|
56
56
|
}
|