create-panal-agent 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-panal-agent",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@panal/sdk": "^0.15.0",
13
+ "@panal/sdk": "^0.16.0",
14
14
  "unpdf": "^1.8.1",
15
15
  "dotenv": "^17.0.0",
16
16
  "tsx": "^4.19.0",
@@ -23,7 +23,8 @@
23
23
  import 'dotenv/config';
24
24
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
- import { join } from 'node:path';
26
+ import { dirname, join } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
27
28
  import {
28
29
  MAX_FILE_BYTES,
29
30
  appendFilesManifest,
@@ -31,6 +32,9 @@ import {
31
32
  buildQuote,
32
33
  createPanalClient,
33
34
  leerNiveles,
35
+ leerNivelesDeMetadata,
36
+ normalizarIdioma,
37
+ resolverLlm,
34
38
  LoopDetected,
35
39
  MAINNET_ADDRESSES,
36
40
  monad,
@@ -48,6 +52,7 @@ import {
48
52
  type CallEnvelope,
49
53
  type DeliveredFile,
50
54
  type FichaNivel,
55
+ type LlmConfig,
51
56
  type Nivel,
52
57
  type PermitDomain,
53
58
  } from '@panal/sdk';
@@ -56,6 +61,7 @@ import { privateKeyToAccount } from 'viem/accounts';
56
61
  import { isAddress, keccak256, parseEther, toBytes, verifyMessage } from 'viem';
57
62
  import type { Address } from 'viem';
58
63
  import { handleTask, NIVELES, SUBCONTRATA_SKILLS } from './agent.js';
64
+ import { traducirFrases } from './traduccion.js';
59
65
  import type { AdjuntoRecibido, NivelPropio, TaskContext, TaskFile, TaskResult } from './agent.js';
60
66
  import { arrancarVigilante } from './vigilante.js';
61
67
  import { historialParaElModelo, recordarTurno, type Turno } from './memoria.js';
@@ -89,7 +95,7 @@ const MAX_BRIEF_CHARS = 32_000;
89
95
  // propios clientes van a descartar: si aquí no sobrevive, no se anuncia.
90
96
  // ---------------------------------------------------------------------------
91
97
 
92
- const NIVELES_OK = leerNiveles({
98
+ let NIVELES_OK = leerNiveles({
93
99
  tiers: NIVELES.map((n) => ({ ...n, amountWei: n.wei.toString() })),
94
100
  });
95
101
 
@@ -101,7 +107,7 @@ if (NIVELES.length > 0 && NIVELES_OK.length !== NIVELES.length) {
101
107
  }
102
108
 
103
109
  /** El nivel más barato: por debajo de eso, un agente con niveles no trabaja. */
104
- const NIVEL_MINIMO = NIVELES_OK[0] ?? null;
110
+ let NIVEL_MINIMO = NIVELES_OK[0] ?? null;
105
111
 
106
112
  // Se dicen al arrancar. Antes sólo salían dentro del bloque de subcontratación
107
113
  // —que no se imprime si no hay presupuesto—, así que un agente con niveles y
@@ -132,7 +138,7 @@ for (const n of NIVELES) {
132
138
 
133
139
 
134
140
  /** El tope de encargo del nivel mayor, o el de siempre si no hay niveles. */
135
- const TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.maxBriefChars ?? 0));
141
+ let TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.maxBriefChars ?? 0));
136
142
 
137
143
  /**
138
144
  * Tope del cuerpo de una petición: sin esto, cualquiera te tumba el proceso.
@@ -143,7 +149,22 @@ const TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.ma
143
149
  * los 32 KB de propina cubren el resto del JSON: la firma, la dirección y el
144
150
  * manifiesto de adjuntos que viaja dentro del encargo.
145
151
  */
146
- const MAX_BODY = Math.max(256 * 1024, TOPE_BRIEF_MAYOR * 4 + 32 * 1024);
152
+ let MAX_BODY = Math.max(256 * 1024, TOPE_BRIEF_MAYOR * 4 + 32 * 1024);
153
+
154
+ /**
155
+ * Los tres números de arriba se recalculan cuando cambian los niveles.
156
+ *
157
+ * Cambian porque ahora se pueden editar desde la web sin tocar este código:
158
+ * viven en el `metadataURI` on-chain y el dueño del agente los mueve firmando
159
+ * una transacción. Si estos números se quedaran con lo que había al arrancar,
160
+ * un nivel nuevo de 320 000 caracteres se anunciaría y luego se rechazaría por
161
+ * pasarse del tope, que es la peor de las dos opciones: se cobra y no se hace.
162
+ */
163
+ function recalcularTopes(): void {
164
+ NIVEL_MINIMO = NIVELES_OK[0] ?? null;
165
+ TOPE_BRIEF_MAYOR = Math.max(MAX_BRIEF_CHARS, ...NIVELES_OK.map((n) => n.maxBriefChars ?? 0));
166
+ MAX_BODY = Math.max(256 * 1024, TOPE_BRIEF_MAYOR * 4 + 32 * 1024);
167
+ }
147
168
 
148
169
  /** Un nivel ya validado, en la forma con la que se anuncia y se devuelve. */
149
170
  function comoFicha(n: Nivel): FichaNivel {
@@ -162,9 +183,22 @@ function nivelDe(pagado: bigint): NivelPropio | null {
162
183
  if (NIVELES_OK.length === 0) return null;
163
184
  const leido = nivelPara(NIVELES_OK, pagado);
164
185
  if (!leido) return null;
165
- // Se devuelve el declarado en `agent.ts`, no el normalizado: es el que tiene
166
- // los tipos que el autor del agente espera encontrar en `ctx.nivel`.
167
- return NIVELES.find((n) => n.wei === leido.wei) ?? null;
186
+ // Se prefiere el declarado en `agent.ts`: es el que tiene los tipos que el
187
+ // autor del agente espera en `ctx.nivel`, y el único que puede traer
188
+ // `subcontrata`, que no cabe en la ficha on-chain.
189
+ const propio = NIVELES.find((n) => n.wei === leido.wei);
190
+ if (propio) return propio;
191
+ // Y si viene de la CADENA, se arma uno. Devolver null aquí sería lo peor que
192
+ // podría pasar: el cliente pagó el nivel grande, lo vio anunciado y el
193
+ // agente trabajaría creyendo que no compró ninguno.
194
+ return {
195
+ name: leido.name ?? '',
196
+ ...(leido.description === null ? {} : { description: leido.description }),
197
+ wei: leido.wei,
198
+ ...(leido.maxBriefChars === null ? {} : { maxBriefChars: leido.maxBriefChars }),
199
+ ...(leido.maxAttachChars === null ? {} : { maxAttachChars: leido.maxAttachChars }),
200
+ ...(leido.maxAttachCharsTotal === null ? {} : { maxAttachCharsTotal: leido.maxAttachCharsTotal }),
201
+ };
168
202
  }
169
203
 
170
204
  const key = process.env.AGENT_PRIVATE_KEY?.trim();
@@ -177,6 +211,85 @@ const panal = createPanalClient({ account, rpcUrl: process.env.RPC_URL });
177
211
 
178
212
  console.log(`Agente ${account.address} escuchando en :${PORT}`);
179
213
 
214
+ // ---------------------------------------------------------------------------
215
+ // Los niveles de la CADENA mandan sobre los de `agent.ts`.
216
+ //
217
+ // Se pueden editar desde el panel de la web sin tocar una línea de código: van
218
+ // dentro del `metadataURI`, y cambiarlos es una transacción. Este bloque los
219
+ // lee y los deja al mando, porque son LOS QUE VIO EL CLIENTE: es contra la
220
+ // ficha on-chain contra lo que eligió tamaño y contra lo que bloqueó el dinero,
221
+ // así que trabajar con otros sería cobrar por una cosa y hacer otra.
222
+ //
223
+ // Se relee cada rato, y no solo al arrancar, porque el sentido de haberlos
224
+ // sacado del código es justamente no tener que reiniciar nada para cambiarlos.
225
+ // Si la lectura falla, se queda lo que hubiera: un RPC lento no puede dejar sin
226
+ // niveles a un agente que los tiene.
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /** Cada cuánto se vuelve a mirar la ficha. */
230
+ const REFRESCO_NIVELES = 5 * 60 * 1000;
231
+
232
+ /**
233
+ * El modelo con el que este agente traduce SU PROPIA ficha para `?lang=`.
234
+ *
235
+ * Se resuelve una vez y falla a `null` en vez de tumbar el arranque: un agente
236
+ * sin `LLM_API_KEY` es un agente perfectamente válido —hay agentes que no usan
237
+ * modelo ninguno— y quedarse sin poder servir la ficha por no poder traducirla
238
+ * sería dejarlo fuera del mercado por un lujo. Sin modelo, ficha original.
239
+ */
240
+ const LLM_FICHA: LlmConfig | null = (() => {
241
+ try {
242
+ return resolverLlm(process.env);
243
+ } catch {
244
+ return null;
245
+ }
246
+ })();
247
+
248
+ /** Los de `agent.ts`, para poder volver a ellos si la ficha se queda sin niveles. */
249
+ const NIVELES_DEL_CODIGO = NIVELES_OK;
250
+
251
+ /**
252
+ * El nombre y la descripción que este agente tiene EN LA CADENA.
253
+ *
254
+ * La plantilla no los tenía: su `/agent.json` publicaba endpoints y precios, y
255
+ * el texto salía solo del registro. Hacen falta aquí para poder servirlos
256
+ * TRADUCIDOS, que es lo que pide `?lang=`.
257
+ */
258
+ let FICHA_TEXTO: { name: string; description: string } = { name: '', description: '' };
259
+
260
+ async function refrescarNiveles(): Promise<void> {
261
+ try {
262
+ const ficha = await panal.getAgent(account.address);
263
+ FICHA_TEXTO = {
264
+ name: ficha.metadata.name,
265
+ description: ficha.metadata.description,
266
+ };
267
+ const enCadena = leerNivelesDeMetadata(ficha.metadataURI);
268
+ const antes = NIVELES_OK.map((n) => `${n.wei}:${n.name ?? ''}`).join('|');
269
+ NIVELES_OK = enCadena.length > 0 ? enCadena : NIVELES_DEL_CODIGO;
270
+ const ahora = NIVELES_OK.map((n) => `${n.wei}:${n.name ?? ''}`).join('|');
271
+ if (antes !== ahora) {
272
+ recalcularTopes();
273
+ console.log(
274
+ NIVELES_OK.length > 0
275
+ ? `[panal] niveles actualizados desde la cadena (${NIVELES_OK.length}): ` +
276
+ NIVELES_OK.map((n) => `${n.name ?? '?'} ${n.wei}`).join(', ')
277
+ : '[panal] este agente ya no publica niveles',
278
+ );
279
+ }
280
+ } catch {
281
+ // Sin RPC, con la ficha ilegible o con el agente aún sin registrar: se
282
+ // queda lo que hubiera. Callado, porque esto corre cada cinco minutos y un
283
+ // aviso por cada fallo llenaría el log de un agente que funciona.
284
+ }
285
+ }
286
+
287
+ // La primera va ANTES de escuchar: `MAX_BODY` sale del tope del nivel mayor, y
288
+ // arrancar con el número viejo sería anunciar un encargo de 320 000 caracteres
289
+ // y cortarlo al recibirlo.
290
+ await refrescarNiveles();
291
+ setInterval(() => void refrescarNiveles(), REFRESCO_NIVELES).unref();
292
+
180
293
  // ---------------------------------------------------------------------------
181
294
  // x402: cobrar por llamada, sin escrow.
182
295
  //
@@ -847,7 +960,23 @@ const LOGOS: [string, string][] = [
847
960
  ];
848
961
 
849
962
  /**
850
- * El primer logo que exista en la carpeta, o null si no publicas ninguno.
963
+ * La carpeta de tu proyecto: este archivo vive en `src/`, así que se sube uno.
964
+ *
965
+ * Se calcula desde el módulo y no desde el directorio de trabajo porque no son
966
+ * lo mismo cuando alguien arranca el agente sin pasar por `npm start`: un
967
+ * `systemd` sin `WorkingDirectory=`, o un Docker con otro `WORKDIR`. Y ese caso
968
+ * NO se cae con estruendo —la clave puede venir de una variable de entorno de
969
+ * verdad en vez del `.env`— así que el agente trabaja igual y lo único que pasa
970
+ * es que su logo devuelve 404 y no llega a publicarse. En silencio.
971
+ */
972
+ const RAIZ = join(dirname(fileURLToPath(import.meta.url)), '..');
973
+
974
+ /**
975
+ * El primer logo que exista, o null si no publicas ninguno.
976
+ *
977
+ * Se mira primero junto al proyecto y después en el directorio de trabajo. El
978
+ * respaldo se queda a propósito: si alguien coloca ahí su logo —que es lo que
979
+ * hacía falta hasta ahora— sigue funcionando igual.
851
980
  *
852
981
  * Se lee en cada petición y no se cachea en memoria a propósito: cambiar de
853
982
  * logo es dejar caer un archivo, y tener que reiniciar el agente —cortando los
@@ -855,11 +984,13 @@ const LOGOS: [string, string][] = [
855
984
  * clientes ya lo cachean una hora por la cabecera.
856
985
  */
857
986
  function buscaLogo(): { bytes: Buffer; tipo: string } | null {
858
- for (const [archivo, tipo] of LOGOS) {
859
- try {
860
- return { bytes: readFileSync(archivo), tipo };
861
- } catch {
862
- // No está: se prueba el siguiente formato.
987
+ for (const carpeta of [RAIZ, '.']) {
988
+ for (const [archivo, tipo] of LOGOS) {
989
+ try {
990
+ return { bytes: readFileSync(join(carpeta, archivo)), tipo };
991
+ } catch {
992
+ // No está: se prueba el siguiente formato.
993
+ }
863
994
  }
864
995
  }
865
996
  return null;
@@ -1130,6 +1261,39 @@ const server = createServer((req, res) => {
1130
1261
  // salía en su tarjeta: un cobro que nadie puede descubrir no existe.
1131
1262
  if (url.pathname === '/agent.json' && req.method === 'GET') {
1132
1263
  const base = process.env.PUBLIC_URL?.trim().replace(/\/+$/, '') || null;
1264
+ /**
1265
+ * `?lang=fr`: la misma ficha con las frases en francés.
1266
+ *
1267
+ * Traduce este agente con su propio modelo y guarda el resultado, así
1268
+ * que la primera petición en cada idioma cuesta una llamada y las demás
1269
+ * ninguna. Si falla —sin clave, sin cuota, sin red— se sirve el original:
1270
+ * no traducir no puede ser un error para quien pide la ficha.
1271
+ */
1272
+ const idioma = normalizarIdioma(url.searchParams.get('lang'));
1273
+ const nivelesFicha = NIVELES_OK.map(comoFicha);
1274
+ let descripcion = FICHA_TEXTO.description;
1275
+ if (idioma) {
1276
+ const traducido = await traducirFrases(
1277
+ {
1278
+ description: descripcion,
1279
+ tiers: NIVELES_OK.map((n) => ({ name: n.name ?? '', description: n.description ?? '' })),
1280
+ },
1281
+ idioma,
1282
+ LLM_FICHA,
1283
+ DATA_DIR,
1284
+ );
1285
+ if (traducido) {
1286
+ descripcion = traducido.description;
1287
+ traducido.tiers.forEach((t, i) => {
1288
+ const destino = nivelesFicha[i];
1289
+ // Solo se pisa lo que ya había: un nivel sin nombre no gana uno
1290
+ // por pasar por el traductor, y uno con nombre no lo pierde.
1291
+ if (!destino) return;
1292
+ if (destino.name && t.name) destino.name = t.name;
1293
+ if (destino.description && t.description) destino.description = t.description;
1294
+ });
1295
+ }
1296
+ }
1133
1297
  const x402 =
1134
1298
  X402_PRICE !== null
1135
1299
  ? {
@@ -1150,6 +1314,10 @@ const server = createServer((req, res) => {
1150
1314
  protocol: 'panal',
1151
1315
  network: 'monad-mainnet',
1152
1316
  chainId: monad.id,
1317
+ // El nombre NO se traduce nunca: «LexPanal» no significa nada en
1318
+ // francés y traducirlo sería inventarle otro nombre a este agente.
1319
+ ...(FICHA_TEXTO.name ? { name: FICHA_TEXTO.name } : {}),
1320
+ ...(descripcion ? { description: descripcion } : {}),
1153
1321
  endpoints: {
1154
1322
  base,
1155
1323
  postBrief: {
@@ -1184,7 +1352,7 @@ const server = createServer((req, res) => {
1184
1352
  },
1185
1353
  // Los niveles, sólo si este agente vende alguno. Ausente significa que
1186
1354
  // no los ofrece, y quien lee NO debe inventárselos a partir del precio.
1187
- ...(NIVELES_OK.length > 0 ? { tiers: NIVELES_OK.map(comoFicha) } : {}),
1355
+ ...(nivelesFicha.length > 0 ? { tiers: nivelesFicha } : {}),
1188
1356
  // ALIAS ANTIGUO, en la raíz. Aquí es donde esta plantilla lo publicaba
1189
1357
  // antes, y hay clientes ahí fuera que solo miran este sitio. Se sirve
1190
1358
  // por compatibilidad y desaparecerá; lo que se lee es `endpoints`.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Panal — tu ficha en el idioma de quien la lee.
3
+ *
4
+ * ───────────────────────────────────────────────────────────────────────────
5
+ * QUÉ ARREGLA
6
+ *
7
+ * El marketplace habla diez idiomas; tu ficha, uno. Tu descripción y los
8
+ * nombres de tus niveles son texto que escribiste tú, y salen igual en las
9
+ * diez versiones del escaparate: quien entra en árabe ve toda la interfaz en
10
+ * árabe y tu agente descrito en español, o peor, la descripción en inglés y
11
+ * los niveles en español, que es lo que pasa hoy en mainnet.
12
+ *
13
+ * Aquí `GET /agent.json?lang=fr` devuelve tu MISMA ficha con las frases en
14
+ * francés. Nadie tiene que aprender un formato nuevo: los lectores siguen
15
+ * mirando `description` y `tiers[].name`, solo que traducidos.
16
+ *
17
+ * QUÉ CUESTA, QUE ES LA PREGUNTA DE VERDAD
18
+ *
19
+ * Una llamada a tu modelo por idioma, UNA VEZ. El resultado se guarda en disco
20
+ * con la huella del texto original dentro del nombre, así que:
21
+ *
22
+ * - la segunda petición en francés no llama a nadie;
23
+ * - y si cambias tu descripción, la huella cambia y se vuelve a traducir
24
+ * sola, sin que tengas que acordarte de borrar nada.
25
+ *
26
+ * Diez idiomas son diez llamadas en toda la vida de una descripción. Traducir
27
+ * cuatro frases es la llamada más barata que va a hacer tu agente.
28
+ *
29
+ * CUANDO FALLA NO SE NOTA
30
+ *
31
+ * Si el modelo no contesta, se acabó la cuota o no hay `LLM_API_KEY`, se sirve
32
+ * la ficha ORIGINAL. Una traducción es una mejora, no un requisito: quedarse
33
+ * sin ficha por no poder traducirla sería dejar al agente fuera del mercado
34
+ * por un lujo.
35
+ *
36
+ * Y NO SE TRADUCE TU NOMBRE. «LexPanal» no significa nada en francés, y
37
+ * traducirlo sería inventarle otro nombre a tu agente y romper toda referencia
38
+ * escrita a él.
39
+ * ───────────────────────────────────────────────────────────────────────────
40
+ */
41
+
42
+ import { createHash } from 'node:crypto';
43
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
44
+ import { join } from 'node:path';
45
+ import { llmChat, NOMBRE_IDIOMA, type Idioma, type LlmConfig } from '@panal/sdk';
46
+
47
+ /** Lo que se traduce de una ficha. Nada más: el nombre del agente no se toca. */
48
+ export interface Frases {
49
+ description: string;
50
+ /** Nombre y descripción de cada nivel, en el orden en que van en la ficha. */
51
+ tiers: { name: string; description: string }[];
52
+ }
53
+
54
+ /** Tope de la respuesta que se acepta del modelo, por frase. */
55
+ const MAX_FRASE = 400;
56
+
57
+ /**
58
+ * Cuánto se espera. Corto a propósito: esto corre DENTRO de una petición de la
59
+ * ficha, y una tarjeta que tarda medio minuto en pintar es una tarjeta rota.
60
+ * Lo que no llegue a tiempo sale sin traducir y se traducirá en la siguiente.
61
+ */
62
+ const ESPERA_MS = 20_000;
63
+
64
+ /** La huella del texto original: si cambia, la traducción guardada ya no vale. */
65
+ function huella(frases: Frases): string {
66
+ return createHash('sha256').update(JSON.stringify(frases)).digest('hex').slice(0, 16);
67
+ }
68
+
69
+ function rutaCache(dir: string, idioma: Idioma, h: string): string {
70
+ return join(dir, 'idiomas', `${idioma}-${h}.json`);
71
+ }
72
+
73
+ /** Lo guardado, si vale. Nunca lanza: un archivo roto es como si no estuviera. */
74
+ function leerGuardado(dir: string, idioma: Idioma, h: string): Frases | null {
75
+ try {
76
+ const ruta = rutaCache(dir, idioma, h);
77
+ if (!existsSync(ruta)) return null;
78
+ return validar(JSON.parse(readFileSync(ruta, 'utf8')), null);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function guardar(dir: string, idioma: Idioma, h: string, frases: Frases): void {
85
+ try {
86
+ mkdirSync(join(dir, 'idiomas'), { recursive: true });
87
+ writeFileSync(rutaCache(dir, idioma, h), JSON.stringify(frases), 'utf8');
88
+ } catch {
89
+ // Sin disco se traduce más veces, que es lo peor que puede pasar aquí.
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Lo que devolvió el modelo, comprobado contra la forma que se le pidió.
95
+ *
96
+ * Un modelo puede contestar cualquier cosa: una disculpa, el JSON envuelto en
97
+ * markdown, o la lista con un nivel de más. Lo que no cuadre se descarta
98
+ * ENTERO y se sirve el original, porque media traducción en una tarjeta es
99
+ * peor que ninguna: parece que al agente le falta la mitad de la ficha.
100
+ *
101
+ * `original` sirve para exigir el mismo número de niveles. Con `null` solo se
102
+ * comprueba la forma, que es lo que hace falta al leer del disco.
103
+ */
104
+ function validar(v: unknown, original: Frases | null): Frases | null {
105
+ if (!v || typeof v !== 'object') return null;
106
+ const { description, tiers } = v as Record<string, unknown>;
107
+ if (typeof description !== 'string' || !description.trim()) return null;
108
+ if (!Array.isArray(tiers)) return null;
109
+ if (original && tiers.length !== original.tiers.length) return null;
110
+
111
+ const salida: Frases['tiers'] = [];
112
+ for (const t of tiers) {
113
+ if (!t || typeof t !== 'object') return null;
114
+ const { name, description: d } = t as Record<string, unknown>;
115
+ if (typeof name !== 'string' || typeof d !== 'string') return null;
116
+ salida.push({ name: name.trim().slice(0, MAX_FRASE), description: d.trim().slice(0, MAX_FRASE) });
117
+ }
118
+ return { description: description.trim().slice(0, MAX_FRASE), tiers: salida };
119
+ }
120
+
121
+ /** El JSON que venga, aunque llegue envuelto en un bloque de markdown. */
122
+ function comoJson(crudo: string): unknown {
123
+ const limpio = crudo.trim().replace(/^```(?:json)?\s*/i, '').replace(/```$/, '');
124
+ try {
125
+ return JSON.parse(limpio);
126
+ } catch {
127
+ // A veces el modelo escribe una frase antes del JSON. Se busca el objeto.
128
+ const i = limpio.indexOf('{');
129
+ const j = limpio.lastIndexOf('}');
130
+ if (i < 0 || j <= i) return null;
131
+ try {
132
+ return JSON.parse(limpio.slice(i, j + 1));
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ }
138
+
139
+ const SISTEMA =
140
+ 'You translate short marketplace copy. Reply with JSON only, no explanation, ' +
141
+ 'no markdown fence. Keep the exact same JSON shape and the same number of ' +
142
+ 'array items you are given. Translate the meaning, not word by word: these ' +
143
+ 'are product labels that people choose from, so they must read naturally and ' +
144
+ 'stay short. Do not translate brand names, product names or code identifiers.';
145
+
146
+ /**
147
+ * Las frases de la ficha en otro idioma.
148
+ *
149
+ * Devuelve `null` cuando no se ha podido traducir, y quien llama sirve el
150
+ * original: no traducir NUNCA puede ser un error para el que pide la ficha.
151
+ */
152
+ export async function traducirFrases(
153
+ frases: Frases,
154
+ idioma: Idioma,
155
+ llm: LlmConfig | null,
156
+ dir: string,
157
+ ): Promise<Frases | null> {
158
+ // Sin nada que traducir no se molesta a nadie.
159
+ if (!frases.description.trim() && frases.tiers.length === 0) return null;
160
+
161
+ const h = huella(frases);
162
+ const guardado = leerGuardado(dir, idioma, h);
163
+ if (guardado) return guardado;
164
+ if (!llm) return null;
165
+
166
+ try {
167
+ const crudo = await llmChat(
168
+ { ...llm, timeoutMs: ESPERA_MS, maxRetries: 0 },
169
+ {
170
+ system: SISTEMA,
171
+ user:
172
+ `Translate the values of this JSON into ${NOMBRE_IDIOMA[idioma]}.\n` +
173
+ 'Keep the keys in English and the array in the same order.\n\n' +
174
+ JSON.stringify(frases),
175
+ },
176
+ );
177
+ const traducido = validar(comoJson(crudo), frases);
178
+ if (!traducido) return null;
179
+ guardar(dir, idioma, h, traducido);
180
+ return traducido;
181
+ } catch {
182
+ // Sin clave, sin cuota, sin red o con el modelo caído: la ficha original.
183
+ return null;
184
+ }
185
+ }