create-panal-agent 0.13.1 → 0.13.2
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/package.json +1 -1
- package/template/src/agent.ts +5 -5
- package/template/src/register.ts +21 -0
- package/template/src/salida.ts +168 -1
- package/template/src/server.ts +2 -1
package/package.json
CHANGED
package/template/src/agent.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
|
|
15
15
|
import { leerAdjuntos, type AdjuntoRecibido, type AdjuntosLeidos } from './adjuntos.js';
|
|
16
|
-
import { comoArchivo, formatoPedido } from './salida.js';
|
|
16
|
+
import { comoArchivo, formatoPedido, nombreDelTema } from './salida.js';
|
|
17
17
|
import { historialComoTexto, type Turno } from './memoria.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -199,7 +199,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
199
199
|
const problema = revisar(brief, texto);
|
|
200
200
|
if (!problema) {
|
|
201
201
|
console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
|
|
202
|
-
return conArchivoSiLoPidio(brief, texto, ctx);
|
|
202
|
+
return await conArchivoSiLoPidio(brief, texto, ctx);
|
|
203
203
|
}
|
|
204
204
|
console.error(`[agente] ${etiqueta(ctx)} intento ${intento}: ${problema}`);
|
|
205
205
|
// A la segunda se entrega igual. Tu revisión puede equivocarse, y un falso
|
|
@@ -207,7 +207,7 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
|
|
|
207
207
|
// entregar algo imperfecto y que él decida, que dejarlo sin nada.
|
|
208
208
|
if (intento === 2) {
|
|
209
209
|
console.error(`[agente] ${etiqueta(ctx)} se entrega pese a: ${problema}`);
|
|
210
|
-
return conArchivoSiLoPidio(brief, texto, ctx);
|
|
210
|
+
return await conArchivoSiLoPidio(brief, texto, ctx);
|
|
211
211
|
}
|
|
212
212
|
queja = problema;
|
|
213
213
|
}
|
|
@@ -372,10 +372,10 @@ function revisar(brief: string, resultado: string): string | null {
|
|
|
372
372
|
* lo ancla en la cadena, así que el cliente puede demostrar que el archivo que
|
|
373
373
|
* se baja es exactamente el que le entregaste.
|
|
374
374
|
*/
|
|
375
|
-
function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskResult {
|
|
375
|
+
async function conArchivoSiLoPidio(brief: string, texto: string, ctx: TaskContext): Promise<TaskResult> {
|
|
376
376
|
const formato = formatoPedido(brief);
|
|
377
377
|
if (!formato) return texto;
|
|
378
|
-
const archivo = comoArchivo(formato, 'entrega', `Panal - entrega ${etiqueta(ctx)}`, texto);
|
|
378
|
+
const archivo = comoArchivo(formato, await nombreDelTema(brief, 'entrega'), `Panal - entrega ${etiqueta(ctx)}`, texto);
|
|
379
379
|
const bytes = typeof archivo.data === 'string' ? archivo.data.length : archivo.data.byteLength;
|
|
380
380
|
console.log(`[agente] ${etiqueta(ctx)} ${archivo.name} de ${bytes} bytes adjunto`);
|
|
381
381
|
// El TEXTO se sigue entregando: es lo que se ancla en la cadena. El archivo
|
package/template/src/register.ts
CHANGED
|
@@ -38,6 +38,27 @@ const PERFIL = {
|
|
|
38
38
|
// vacía NO la sustituye `??`. Con `??` el botUrl acababa siendo '' y la
|
|
39
39
|
// comprobación de "no has puesto tu URL" no saltaba nunca.
|
|
40
40
|
botUrl: process.env.PUBLIC_URL?.trim() || 'https://cambia-esto.example.com',
|
|
41
|
+
|
|
42
|
+
// TU CARA, si quieres tenerla. Todo esto es opcional y va vacío por defecto:
|
|
43
|
+
// un agente sin logo no vale menos, es lo que hay hoy en todo el mercado.
|
|
44
|
+
//
|
|
45
|
+
// Lo que compra ponerlo es que el cliente pueda MIRARTE antes de pagarte. En
|
|
46
|
+
// el mercado sales entre desconocidos, y un repositorio que se puede abrir
|
|
47
|
+
// dice más de ti que cualquier descripción que escribas de ti mismo.
|
|
48
|
+
//
|
|
49
|
+
// Se guarda en tu ficha del registro, así que cambiarlo cuesta una
|
|
50
|
+
// transacción: `npm run register` otra vez y ya.
|
|
51
|
+
links: {
|
|
52
|
+
// El logo sale en tu tarjeta, en el mercado y en la app. Https, cuadrado y
|
|
53
|
+
// pequeño: se pinta a 56 px, no hace falta más.
|
|
54
|
+
logo: '',
|
|
55
|
+
web: '',
|
|
56
|
+
// Tu perfil o el repositorio del agente: valen `usuario` y `usuario/repo`.
|
|
57
|
+
github: '',
|
|
58
|
+
// Solo el usuario; también se traga el enlace entero si lo pegas.
|
|
59
|
+
x: '',
|
|
60
|
+
telegram: '',
|
|
61
|
+
},
|
|
41
62
|
};
|
|
42
63
|
|
|
43
64
|
/** Lo que cobras por tarea. */
|
package/template/src/salida.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* peso y confusión.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { llmChat, resolverLlm, stripFilesManifest } from '@panal/sdk';
|
|
18
19
|
import { textoAPdf } from './pdf.js';
|
|
19
20
|
import { escribirZip } from './zip.js';
|
|
20
21
|
|
|
@@ -36,7 +37,20 @@ export interface ArchivoDeSalida {
|
|
|
36
37
|
* Devuelve `null` cuando no pide nada, que es el caso normal.
|
|
37
38
|
*/
|
|
38
39
|
export function formatoPedido(brief: string): Formato | null {
|
|
39
|
-
|
|
40
|
+
// El manifiesto de adjuntos NO es lo que pidio el cliente: es contabilidad
|
|
41
|
+
// del protocolo, y va pegada al FINAL del brief. Sin quitarla, sus lineas
|
|
42
|
+
// `name:` y `mime:` son las ultimas menciones de un formato que hay en el
|
|
43
|
+
// texto, y esta funcion se queda justamente con la ultima.
|
|
44
|
+
//
|
|
45
|
+
// El efecto es que el adjunto elige el formato de SALIDA. Comprobado en un
|
|
46
|
+
// encargo real de mainnet (#67): el cliente pidio JSON, adjunto un `.txt`, y
|
|
47
|
+
// el manifiesto —`name: pedidos n.txt`, `mime: text/plain`— gano al «devuelve
|
|
48
|
+
// solo el JSON» que estaba escrito antes. Se entrego un .txt.
|
|
49
|
+
//
|
|
50
|
+
// No es raro ni un caso de laboratorio: casi todo adjunto lleva en el nombre
|
|
51
|
+
// una extension que aqui es un formato. Adjuntar un PDF hacia que la entrega
|
|
52
|
+
// fuera un PDF, se pidiera lo que se pidiera.
|
|
53
|
+
const t = stripFilesManifest(brief).toLowerCase();
|
|
40
54
|
const mencion: { formato: Formato; en: number }[] = [];
|
|
41
55
|
|
|
42
56
|
for (const [formato, patron] of PATRONES) {
|
|
@@ -334,6 +348,159 @@ export function comoTabla(texto: string): string | null {
|
|
|
334
348
|
].join('\n');
|
|
335
349
|
}
|
|
336
350
|
|
|
351
|
+
/* ── cómo se llama el archivo ────────────────────────────────────────────── */
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Los reservados de Windows y los separadores de ruta.
|
|
355
|
+
*
|
|
356
|
+
* NO se tocan las letras: un nombre en chino, en árabe o con tildes es un
|
|
357
|
+
* nombre perfectamente válido, y quitárselos sería justo lo contrario de lo
|
|
358
|
+
* que hace falta aquí.
|
|
359
|
+
*/
|
|
360
|
+
const PROHIBIDOS = /[/\\:*?"<>|]/g;
|
|
361
|
+
|
|
362
|
+
/** Cuántos caracteres como mucho. Un nombre no es un resumen. */
|
|
363
|
+
const MAX_NOMBRE = 60;
|
|
364
|
+
|
|
365
|
+
/** ¿Es un carácter imprimible? Los de control no valen en un nombre. */
|
|
366
|
+
function imprimible(c: string): boolean {
|
|
367
|
+
const p = c.codePointAt(0) ?? 0;
|
|
368
|
+
return p >= 0x20 && p !== 0x7f;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Un título cualquiera, convertido en nombre de archivo.
|
|
373
|
+
*
|
|
374
|
+
* Se exporta para poder probarlo sin gastar una llamada al modelo.
|
|
375
|
+
*/
|
|
376
|
+
export function comoNombre(crudo: string): string {
|
|
377
|
+
const linea = crudo.split(/\r?\n/).find((l) => l.trim()) ?? '';
|
|
378
|
+
const limpio = [...linea]
|
|
379
|
+
.filter(imprimible)
|
|
380
|
+
.join('')
|
|
381
|
+
.trim()
|
|
382
|
+
// El modelo devuelve el título entrecomillado más veces de las que parece.
|
|
383
|
+
.replace(/^["'`«“]+|["'`»”]+$/g, '')
|
|
384
|
+
// Y a veces le pone extensión, que aquí la pone `comoArchivo`.
|
|
385
|
+
.replace(/\.(pdf|docx?|xlsx?|md|txt|csv|json|zip)$/i, '')
|
|
386
|
+
.replace(PROHIBIDOS, ' ')
|
|
387
|
+
.replace(/\s+/g, '-')
|
|
388
|
+
.replace(/-{2,}/g, '-')
|
|
389
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
390
|
+
.toLowerCase();
|
|
391
|
+
// Por code points y no con `.slice`: cortar por unidades UTF-16 parte por la
|
|
392
|
+
// mitad un carácter fuera del plano básico.
|
|
393
|
+
return [...limpio].slice(0, MAX_NOMBRE).join('').replace(/[-.]+$/, '');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* La cabecera `content-disposition` de un archivo que se descarga.
|
|
398
|
+
*
|
|
399
|
+
* DOS FORMAS DEL NOMBRE, Y LAS DOS HACEN FALTA (RFC 6266). Una cabecera HTTP
|
|
400
|
+
* solo admite latin-1, y desde que el nombre del archivo lo escribe el modelo
|
|
401
|
+
* en el idioma del cliente, un entregable puede llamarse
|
|
402
|
+
* `两个整数相除.pdf`. Interpolarlo tal cual en `filename="…"` no da un nombre
|
|
403
|
+
* feo: Node LANZA `ERR_INVALID_CHAR` al escribir la cabecera y la descarga
|
|
404
|
+
* responde 500. El archivo estaba entregado, pagado y anclado en la cadena, y
|
|
405
|
+
* el cliente no podía bajárselo.
|
|
406
|
+
*
|
|
407
|
+
* filename= una versión en ASCII, para quien no entienda lo otro
|
|
408
|
+
* filename*= el nombre de verdad, en UTF-8 percent-encoded
|
|
409
|
+
*
|
|
410
|
+
* Los navegadores prefieren `filename*` cuando está, así que el nombre bueno
|
|
411
|
+
* es el que se ve. Y las comillas se van del ASCII a propósito: una comilla
|
|
412
|
+
* dentro de `filename="…"` parte la cabecera por la mitad.
|
|
413
|
+
*/
|
|
414
|
+
export function comoAdjunto(nombre: string): string {
|
|
415
|
+
const ascii =
|
|
416
|
+
[...nombre]
|
|
417
|
+
.map((c) => {
|
|
418
|
+
const p = c.codePointAt(0) ?? 0;
|
|
419
|
+
return p >= 0x20 && p < 0x7f && c !== '"' && c !== '\\' ? c : '_';
|
|
420
|
+
})
|
|
421
|
+
.join('')
|
|
422
|
+
.replace(/_{2,}/g, '_')
|
|
423
|
+
.replace(/^[_.]+|[_.]+$/g, '') || 'archivo';
|
|
424
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(nombre)}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Lo que se le pide al modelo. Corto a propósito: es un nombre, no un resumen.
|
|
429
|
+
*
|
|
430
|
+
* Las dos reglas que de verdad cambian el resultado son las dos últimas. Sin
|
|
431
|
+
* la del SUJETO, el modelo nombra la acción —«escribir-casos-de-prueba»— y
|
|
432
|
+
* todos los archivos de un mismo agente vuelven a llamarse igual, que es el
|
|
433
|
+
* problema que esto viene a arreglar. Y sin la del IDIOMA contesta en inglés
|
|
434
|
+
* aunque el encargo venga en otro, porque el inglés es su idioma por defecto.
|
|
435
|
+
*/
|
|
436
|
+
const PIDE_UN_NOMBRE =
|
|
437
|
+
'You name files. Given a client request, reply with ONLY a file name for the deliverable.\n' +
|
|
438
|
+
'Two to five words. No extension, no quotes, no path, no explanation, no punctuation at the ends.\n' +
|
|
439
|
+
'Name the SUBJECT the work is about, never the action asked for: for "write the test cases for a ' +
|
|
440
|
+
'function that divides two integers" answer "division de dos enteros", not "escribir casos de prueba".\n' +
|
|
441
|
+
'Write it in the SAME language the client wrote in, in their own script. Do not translate it to English.';
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* El nombre del archivo que se entrega, sacado del TEMA del encargo.
|
|
445
|
+
*
|
|
446
|
+
* ANTES TODOS SE LLAMABAN IGUAL. Cada agente tenía un nombre fijo —
|
|
447
|
+
* `casos-de-prueba.pdf`, `revision.pdf`, `traducciones.pdf`—, así que un
|
|
448
|
+
* cliente que encargara tres cosas al mismo agente acababa con tres archivos
|
|
449
|
+
* del mismo nombre en su carpeta de descargas, pisándose unos a otros o
|
|
450
|
+
* quedando como «casos-de-prueba (2).pdf». Y estaba en castellano para todo el
|
|
451
|
+
* mundo, cuando el contenido va en el idioma del cliente desde hace tiempo.
|
|
452
|
+
*
|
|
453
|
+
* EL TEMA SALE DEL ENCARGO, no de la entrega. La entrega es texto plano sin
|
|
454
|
+
* título —el prompt prohíbe los encabezados a propósito—, así que su primera
|
|
455
|
+
* línea es el primer caso de prueba, no de qué va la cosa. El encargo, en
|
|
456
|
+
* cambio, lo escribió el cliente: dice el tema y está en su idioma.
|
|
457
|
+
*
|
|
458
|
+
* NUNCA LANZA Y NUNCA DEVUELVE VACÍO. Si el modelo no contesta, tarda o
|
|
459
|
+
* devuelve algo que no sirve, se usa el nombre de siempre. Nombrar un archivo
|
|
460
|
+
* no puede impedir entregarlo: el pago ya está bloqueado.
|
|
461
|
+
*/
|
|
462
|
+
export async function nombreDelTema(brief: string, deReserva: string): Promise<string> {
|
|
463
|
+
try {
|
|
464
|
+
const cfg = resolverLlm(process.env);
|
|
465
|
+
const respuesta = await llmChat(
|
|
466
|
+
// NO SE TOCA NI LA TEMPERATURA NI EL TOPE DE TOKENS, y las dos cosas se
|
|
467
|
+
// aprendieron probando contra el modelo de verdad.
|
|
468
|
+
//
|
|
469
|
+
// Aquí ponía `temperature: 0` —lo natural para pedir algo determinista— y
|
|
470
|
+
// el modelo lo rechazaba con un 400: hay modelos que solo aceptan 1. Y
|
|
471
|
+
// ponía `maxTokens: 32` —es un nombre, no un texto— y la respuesta volvía
|
|
472
|
+
// con `choices` vacío, porque un modelo que razona antes de contestar se
|
|
473
|
+
// gasta ese presupuesto pensando y no le queda para escribir.
|
|
474
|
+
//
|
|
475
|
+
// Los dos fallos son INVISIBLES: se cae al nombre de siempre, que es
|
|
476
|
+
// exactamente lo que había antes, así que la función habría quedado
|
|
477
|
+
// muerta sin que nadie lo notara. Se hereda lo que el operador ya tiene
|
|
478
|
+
// configurado, que es lo que funciona en el resto de sus llamadas.
|
|
479
|
+
//
|
|
480
|
+
// Lo único propio es el reloj: un timeout más corto que el del trabajo y
|
|
481
|
+
// un solo reintento, porque esto va DESPUÉS de tener la entrega hecha y
|
|
482
|
+
// no puede retrasarla.
|
|
483
|
+
{ ...cfg, timeoutMs: 20_000, maxRetries: 1 },
|
|
484
|
+
// El encargo entero no hace falta: el tema está al principio, y mandarlo
|
|
485
|
+
// completo puede ser mandar un contrato de treinta páginas para sacar
|
|
486
|
+
// cuatro palabras.
|
|
487
|
+
// Sin el manifiesto, por lo mismo que en `formatoPedido`: son 1.500
|
|
488
|
+
// caracteres de presupuesto y un hash de 64 ocupa sitio sin decir nada
|
|
489
|
+
// del tema. Con adjuntos cortos llegaba a colarse entero.
|
|
490
|
+
{ system: PIDE_UN_NOMBRE, user: stripFilesManifest(brief).trim().slice(0, 1_500) },
|
|
491
|
+
);
|
|
492
|
+
const nombre = comoNombre(respuesta);
|
|
493
|
+
if (nombre) return nombre;
|
|
494
|
+
console.warn(`[salida] el modelo no dio un nombre usable; el archivo va como «${deReserva}»`);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
console.warn(
|
|
497
|
+
`[salida] no se pudo nombrar el archivo (${err instanceof Error ? err.message.split('\n')[0] : err}); ` +
|
|
498
|
+
`va como «${deReserva}»`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return deReserva;
|
|
502
|
+
}
|
|
503
|
+
|
|
337
504
|
/**
|
|
338
505
|
* El archivo listo para adjuntar a la entrega.
|
|
339
506
|
*
|
package/template/src/server.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type DeliveredFile,
|
|
48
48
|
type PermitDomain,
|
|
49
49
|
} from '@panal/sdk';
|
|
50
|
+
import { comoAdjunto } from './salida.js';
|
|
50
51
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
51
52
|
import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
|
|
52
53
|
import type { Address } from 'viem';
|
|
@@ -1419,7 +1420,7 @@ const server = createServer((req, res) => {
|
|
|
1419
1420
|
'content-length': bytes.byteLength,
|
|
1420
1421
|
// `attachment` a propósito: lo que hay dentro lo eligió el agente, y no
|
|
1421
1422
|
// se le deja que el navegador del cliente lo ejecute como una página.
|
|
1422
|
-
'content-disposition':
|
|
1423
|
+
'content-disposition': comoAdjunto(nombre),
|
|
1423
1424
|
'x-content-type-options': 'nosniff',
|
|
1424
1425
|
});
|
|
1425
1426
|
res.end(bytes);
|