create-panal-agent 0.10.0 → 0.12.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.
@@ -11,8 +11,19 @@
11
11
  * anclado en la cadena al entregar. Si luego sirves otra cosa, se nota.
12
12
  */
13
13
 
14
- import type { CallEnvelope } from '@panal/sdk';
14
+ import { esImagenSoportada, llmChat, resolverLlm, type CallEnvelope, type LlmConfig } from '@panal/sdk';
15
15
  import { textoAPdf } from './pdf.js';
16
+ import { historialComoTexto, type Turno } from './memoria.js';
17
+
18
+ /** Un archivo que el CLIENTE te mandó con el encargo. */
19
+ export interface AdjuntoRecibido {
20
+ /** Nombre ya limpio, tal y como lo anunció el encargo. */
21
+ name: string;
22
+ /** Tipo MIME, si lo declaró: `image/png`, `application/pdf`… */
23
+ mime?: string;
24
+ /** El contenido. Su hash ya se comprobó contra lo que la cadena cubre. */
25
+ bytes: Uint8Array;
26
+ }
16
27
 
17
28
  export interface TaskContext {
18
29
  /**
@@ -27,6 +38,20 @@ export interface TaskContext {
27
38
  /** Fecha límite de entrega, en segundos epoch. Cero en una llamada x402. */
28
39
  deadline: bigint;
29
40
 
41
+ /**
42
+ * LO QUE EL CLIENTE TE ADJUNTÓ: una foto, un PDF que revisar, un CSV.
43
+ *
44
+ * Llegan verificados. El encargo anunció el hash de cada uno ANTES de que se
45
+ * pagara, así que estos bytes son exactamente los que la cadena cubre — si
46
+ * alguien hubiera cambiado uno por el camino, no habría llegado hasta aquí.
47
+ *
48
+ * Las imágenes se le pasan solas al modelo, si el tuyo sabe mirarlas. El
49
+ * resto lo tienes aquí en crudo para hacer lo que sepas hacer con ello.
50
+ *
51
+ * Vacío en una llamada x402: ahí no hay tarea donde anclar un adjunto.
52
+ */
53
+ adjuntos: AdjuntoRecibido[];
54
+
30
55
  /**
31
56
  * PREGUNTAR A OTRO AGENTE, Y PAGARLE.
32
57
  *
@@ -61,6 +86,23 @@ export interface TaskContext {
61
86
  * `consultar` ya lo respeta por su cuenta.
62
87
  */
63
88
  envelope: CallEnvelope | null;
89
+
90
+ /**
91
+ * LO QUE YA SE HABLÓ CON ESTA PERSONA.
92
+ *
93
+ * Sólo en llamadas x402, que son las que forman una conversación. En un
94
+ * encargo del escrow va vacío: un encargo tiene principio y fin, y
95
+ * arrastrarle memoria sería confundir dos cosas distintas.
96
+ *
97
+ * Quién es "esta persona" lo dice el PAGO, no una cabecera: firmó un permiso
98
+ * y el cobro se ejecutó en la cadena, así que nadie puede continuar la
99
+ * conversación de otro sin haber pagado como él.
100
+ *
101
+ * Va acotado por turnos y por caracteres (`MEMORIA_TURNOS`, `MEMORIA_CHARS`)
102
+ * porque entra en el prompt, y el prompt lo pagas TÚ mientras el cliente
103
+ * paga un precio fijo por mensaje.
104
+ */
105
+ historial: Turno[];
64
106
  }
65
107
 
66
108
  /** Un archivo que entregas junto al texto. */
@@ -115,10 +157,15 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
115
157
  // código, consultar una API, lo que quieras. No tiene por qué usar un modelo.
116
158
  // ──────────────────────────────────────────────────────────────────────────
117
159
 
118
- const apiKey = process.env.LLM_API_KEY;
119
- if (!apiKey) {
160
+ // El modelo, sea cual sea. `LLM_PROVIDER=claude|kimi|grok|glm|gemini|…` o
161
+ // `LLM_BASE_URL` a pelo para cualquiera que no esté en la lista.
162
+ let cfg: LlmConfig;
163
+ try {
164
+ cfg = resolverLlm(process.env);
165
+ } catch (err) {
120
166
  // Sin modelo configurado se entrega algo honesto en vez de fallar: el
121
167
  // cliente ya pagó, y dejarlo sin nada le cuesta el plazo entero.
168
+ console.error(`[agente] ${etiqueta(ctx)} sin modelo: ${err instanceof Error ? err.message : err}`);
122
169
  return (
123
170
  `No puedo completar este encargo ahora mismo: al agente le falta configurar su modelo.\n\n` +
124
171
  `Lo que pediste:\n${brief}\n\n` +
@@ -127,16 +174,28 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
127
174
  );
128
175
  }
129
176
 
177
+ // Lo que el cliente adjuntó y un modelo puede MIRAR. El resto de adjuntos
178
+ // sigue en `ctx.adjuntos` para que hagas con ellos lo que sepas hacer.
179
+ const imagenes = ctx.adjuntos
180
+ .filter((a) => esImagenSoportada(a.mime))
181
+ .map((a) => ({ mime: a.mime!, bytes: a.bytes }));
182
+ if (ctx.adjuntos.length > 0) {
183
+ console.log(
184
+ `[agente] ${etiqueta(ctx)} ${ctx.adjuntos.length} adjunto(s), ${imagenes.length} para el modelo: ` +
185
+ ctx.adjuntos.map((a) => a.name).join(', '),
186
+ );
187
+ }
188
+
130
189
  // ¿Esto lo sé hacer yo, o me conviene preguntar? La decisión es del agente,
131
190
  // no del cliente: él pidió un trabajo, no una arquitectura.
132
- const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, apiKey);
191
+ const ayuda = await pedirAyudaSiHaceFalta(brief, ctx, cfg);
133
192
 
134
193
  // Un intento, una revisión y una corrección. Un modelo falla el formato de
135
194
  // vez en cuando, y aquí eso no es un mensaje feo en un chat: el hash de lo
136
195
  // que entregues queda anclado en la cadena y ya no se puede rectificar.
137
196
  let queja: string | null = null;
138
197
  for (let intento = 1; intento <= 2; intento++) {
139
- const texto = await pedirAlModelo(brief, apiKey, queja, ayuda);
198
+ const texto = await pedirAlModelo(brief, cfg, queja, ayuda, imagenes, ctx.adjuntos, ctx.historial);
140
199
  const problema = revisar(brief, texto);
141
200
  if (!problema) {
142
201
  console.log(`[agente] ${etiqueta(ctx)} resuelta: ${texto.length} caracteres`);
@@ -172,12 +231,12 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<TaskR
172
231
  async function pedirAyudaSiHaceFalta(
173
232
  brief: string,
174
233
  ctx: TaskContext,
175
- apiKey: string,
234
+ cfg: LlmConfig,
176
235
  ): Promise<string | null> {
177
236
  // Sin presupuesto no hay nada que decidir, y así se ahorra la llamada.
178
237
  if (ctx.presupuesto <= 0n) return null;
179
238
 
180
- const decision = await decidirDelegacion(brief, apiKey);
239
+ const decision = await decidirDelegacion(brief, cfg);
181
240
  if (!decision) return null;
182
241
 
183
242
  try {
@@ -197,44 +256,52 @@ async function pedirAyudaSiHaceFalta(
197
256
  }
198
257
  }
199
258
 
259
+ /**
260
+ * Saca el JSON de una respuesta, aunque venga envuelto.
261
+ *
262
+ * `response_format: json_object` sólo lo entiende parte del mercado, y este
263
+ * agente puede correr contra cualquiera. Se pide JSON en el prompt y se busca
264
+ * el objeto a la vuelta: unos lo envuelven en ```json y otros le ponen una
265
+ * frase delante, y las dos cosas son fáciles de perdonar.
266
+ */
267
+ function extraerJson(crudo: string): unknown {
268
+ const sinValla = crudo.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
269
+ const ini = sinValla.indexOf('{');
270
+ const fin = sinValla.lastIndexOf('}');
271
+ if (ini === -1 || fin <= ini) return null;
272
+ try {
273
+ return JSON.parse(sinValla.slice(ini, fin + 1));
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
200
279
  /** Una llamada corta al modelo: ¿delego, y a quién? Formato JSON o nada. */
201
280
  async function decidirDelegacion(
202
281
  brief: string,
203
- apiKey: string,
282
+ cfg: LlmConfig,
204
283
  ): Promise<{ skill: string; pregunta: string } | null> {
205
284
  try {
206
- const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
207
- method: 'POST',
208
- headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
209
- // Corto a propósito: si decidir tarda más que trabajar, no compensa.
210
- signal: AbortSignal.timeout(30_000),
211
- body: JSON.stringify({
212
- model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
213
- response_format: { type: 'json_object' },
214
- messages: [
215
- {
216
- role: 'system',
217
- content:
218
- 'You are an agent deciding whether to PAY another specialist agent out of your own earnings ' +
219
- 'to do part of a job. Answer with JSON only.\n' +
220
- '{"delegate": false} if you can do the job yourself. This is the right answer almost always.\n' +
221
- '{"delegate": true, "skill": "…", "question": "…"} ONLY if the job clearly needs expertise ' +
222
- 'outside your own, and a specialist answer would measurably improve the result.\n' +
223
- '"skill" is one or two words to search a marketplace by (e.g. "translation", "legal", "json").\n' +
224
- '"question" is the self-contained question for that specialist: it will be sent on its own, ' +
225
- 'so it must make sense without the rest of the job.\n' +
226
- 'Paying costs real money. If in doubt, do not delegate.',
227
- },
228
- { role: 'user', content: brief },
229
- ],
230
- }),
231
- });
232
- if (!res.ok) return null;
233
- const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
234
- const crudo = data.choices?.[0]?.message?.content;
235
- if (!crudo) return null;
236
- const parsed = JSON.parse(crudo) as { delegate?: boolean; skill?: string; question?: string };
237
- if (parsed.delegate !== true) return null;
285
+ const crudo = await llmChat(
286
+ // Corto a propósito, y sin reintentos: si decidir tarda más que trabajar
287
+ // —o cuesta más—, no compensa. Ante la duda se trabaja solo.
288
+ { ...cfg, timeoutMs: 30_000, maxRetries: 0, maxTokens: 400 },
289
+ {
290
+ system:
291
+ 'You are an agent deciding whether to PAY another specialist agent out of your own earnings ' +
292
+ 'to do part of a job. Answer with JSON only, no prose and no code fences.\n' +
293
+ '{"delegate": false} if you can do the job yourself. This is the right answer almost always.\n' +
294
+ '{"delegate": true, "skill": "…", "question": "…"} ONLY if the job clearly needs expertise ' +
295
+ 'outside your own, and a specialist answer would measurably improve the result.\n' +
296
+ '"skill" is one or two words to search a marketplace by (e.g. "translation", "legal", "json").\n' +
297
+ '"question" is the self-contained question for that specialist: it will be sent on its own, ' +
298
+ 'so it must make sense without the rest of the job.\n' +
299
+ 'Paying costs real money. If in doubt, do not delegate.',
300
+ user: brief,
301
+ },
302
+ );
303
+ const parsed = extraerJson(crudo) as { delegate?: boolean; skill?: string; question?: string } | null;
304
+ if (!parsed || parsed.delegate !== true) return null;
238
305
  const skill = parsed.skill?.trim();
239
306
  const pregunta = parsed.question?.trim();
240
307
  if (!skill || !pregunta) return null;
@@ -314,71 +381,91 @@ function conPdfSiLoPidio(brief: string, texto: string, ctx: TaskContext): TaskRe
314
381
 
315
382
  async function pedirAlModelo(
316
383
  brief: string,
317
- apiKey: string,
384
+ cfg: LlmConfig,
318
385
  queja: string | null,
319
386
  ayuda: string | null,
387
+ imagenes: { mime: string; bytes: Uint8Array }[],
388
+ adjuntos: AdjuntoRecibido[],
389
+ historial: Turno[],
320
390
  ): Promise<string> {
321
- const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
322
- method: 'POST',
323
- headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
391
+ // Todo va en un solo turno de usuario, con cada parte etiquetada. Los tres
392
+ // dialectos aceptan varios turnos, pero cada uno los cuenta a su manera, y
393
+ // lo que aquí importa no es de quién es cada mensaje: es que el modelo no
394
+ // confunda el material de apoyo con el encargo.
395
+ const partes: string[] = [];
396
+
397
+ // Lo hablado antes va PRIMERO y marcado como tal. Sin la etiqueta, el modelo
398
+ // confunde una pregunta vieja con la de ahora y contesta a la equivocada;
399
+ // con ella entiende que es contexto y que lo último es lo que se le pide.
400
+ const antes = historialComoTexto(historial);
401
+ if (antes) {
402
+ partes.push(`Conversación anterior con este cliente, para que sepas de qué habláis:\n\n${antes}`);
403
+ }
404
+
405
+ partes.push(historial.length > 0 ? `Ahora te pide:\n${brief}` : brief);
406
+
407
+ // Los adjuntos que el modelo NO puede mirar. Se nombran para que sepa que
408
+ // existen: sin esto contesta como si el cliente no hubiera mandado nada, y
409
+ // el cliente ve una respuesta que ignora la mitad de lo que pidió.
410
+ const noMirables = adjuntos.filter((a) => !imagenes.some((i) => i.bytes === a.bytes));
411
+ if (noMirables.length > 0) {
412
+ partes.push(
413
+ `[El cliente adjuntó estos archivos, que no puedes abrir: ${noMirables
414
+ .map((a) => `${a.name}${a.mime ? ` (${a.mime})` : ''}`)
415
+ .join(', ')}. El agente los tiene y los procesa aparte.]`,
416
+ );
417
+ }
418
+
419
+ // Lo que contestó el especialista, si se le preguntó. Va marcado como
420
+ // material de apoyo y no como parte del encargo: sin esa aclaración el
421
+ // modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
422
+ if (ayuda) {
423
+ partes.push(
424
+ 'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
425
+ `no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
426
+ );
427
+ }
428
+
429
+ // La corrección va como una parte más: decirle QUÉ falló acierta mucho más
430
+ // que repetirle la misma petición a ciegas esperando otra suerte.
431
+ if (queja) partes.push(`Tu respuesta anterior no vale: ${queja}. Corrígela.`);
432
+
433
+ return llmChat(
324
434
  // Sin este tope, un modelo que se cuelga deja la tarea colgada para
325
435
  // siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
326
436
  // que vence el plazo. Pasó de verdad, en mainnet.
327
- signal: AbortSignal.timeout(120_000),
328
- body: JSON.stringify({
329
- model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
330
- messages: [
331
- {
332
- role: 'system',
333
- content:
334
- // Estas tres reglas cuestan de acertar a mano y se pagan caras:
335
- // 1. Sin la del idioma, el modelo contesta en el suyo aunque el
336
- // cliente escriba en otro, y el cliente recibe algo inservible.
337
- // 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
338
- // en crudo, porque ni el dashboard ni Telegram lo renderizan.
339
- // 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
340
- // tienes…" y el entregable parece un chat, no un producto.
341
- 'You are a professional agent on the Panal marketplace. ' +
342
- // El "never fall back to English" y lo de los títulos no son
343
- // adorno: en producción, una petición en portugués volvió entera en
344
- // inglés porque el prompt listaba los títulos de sección en inglés y
345
- // el modelo los copiaba; y otra en chino devolvió las claves en
346
- // inglés. Los dos fallos con la regla del idioma ya puesta.
347
- 'RULE 1, before anything else: detect the language of the request and reply in that exact same ' +
348
- 'language; never switch part-way, and never fall back to English because the request is not in ' +
349
- 'English. If the instructions below name sections, headings or field names, translate those too: ' +
350
- 'they are written in one language only because these instructions are. ' +
351
- 'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
352
- 'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
353
- // El agente adjunta el archivo por su cuenta; el modelo no se entera
354
- // y, sin esta regla, se disculpa por no poder generarlo.
355
- 'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
356
- 'you answer. Never mention files, PDFs or attachments — not even to say you cannot make them.',
357
- },
358
- { role: 'user', content: brief },
359
- // Lo que contestó el especialista, si se le preguntó. Va marcado como
360
- // material de apoyo y no como parte del encargo: sin esa aclaración el
361
- // modelo tiende a copiarlo tal cual y a entregar la respuesta de otro.
362
- ...(ayuda
363
- ? [
364
- {
365
- role: 'user' as const,
366
- content:
367
- 'Material de apoyo, pagado a un agente especialista. Úsalo si ayuda y descártalo si no; ' +
368
- `no lo copies tal cual ni lo menciones en la entrega:\n\n${ayuda}`,
369
- },
370
- ]
371
- : []),
372
- // La corrección va como un mensaje más: decirle QUÉ falló acierta mucho
373
- // más que repetirle la misma petición a ciegas esperando otra suerte.
374
- ...(queja ? [{ role: 'user' as const, content: `Tu respuesta anterior no vale: ${queja}. Corrígela.` }] : []),
375
- ],
376
- }),
377
- });
378
-
379
- if (!res.ok) throw new Error(`El modelo respondió ${res.status}`);
380
- const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
381
- const text = data.choices?.[0]?.message?.content?.trim();
382
- if (!text) throw new Error('El modelo devolvió una respuesta vacía.');
383
- return text;
437
+ { ...cfg, timeoutMs: cfg.timeoutMs ?? 120_000 },
438
+ {
439
+ system:
440
+ // Estas cuatro reglas cuestan de acertar a mano y se pagan caras:
441
+ // 1. Sin la del idioma, el modelo contesta en el suyo aunque el
442
+ // cliente escriba en otro, y el cliente recibe algo inservible.
443
+ // 2. Sin la del formato, entrega Markdown y el cliente ve `**esto**`
444
+ // en crudo, porque ni el dashboard ni Telegram lo renderizan.
445
+ // 3. Sin la del registro, envuelve el trabajo en "¡Claro! Aquí
446
+ // tienes…" y el entregable parece un chat, no un producto.
447
+ 'You are a professional agent on the Panal marketplace. ' +
448
+ // El "never fall back to English" y lo de los títulos no son
449
+ // adorno: en producción, una petición en portugués volvió entera en
450
+ // inglés porque el prompt listaba los títulos de sección en inglés y
451
+ // el modelo los copiaba; y otra en chino devolvió las claves en
452
+ // inglés. Los dos fallos con la regla del idioma ya puesta.
453
+ 'RULE 1, before anything else: detect the language of the request and reply in that exact same ' +
454
+ 'language; never switch part-way, and never fall back to English because the request is not in ' +
455
+ 'English. If the instructions below name sections, headings or field names, translate those too: ' +
456
+ 'they are written in one language only because these instructions are. ' +
457
+ 'RULE 2: plain text only, never Markdown — no # headings, no ** bold, no backticks. ' +
458
+ 'RULE 3: deliver finished professional work, with no preamble or meta-commentary.\n' +
459
+ // El agente adjunta el archivo por su cuenta; el modelo no se entera
460
+ // y, sin esta regla, se disculpa por no poder generarlo. Ojo al
461
+ // matiz: prohibirle hablar de archivos A SECAS le hacía callar
462
+ // también sobre la foto que le acababan de mandar.
463
+ 'RULE 4: the client may ask for the result as a PDF or a file. The agent attaches it after ' +
464
+ 'you answer. Never mention files, PDFs or attachments you would have to produce — not even to ' +
465
+ 'say you cannot make them. This does NOT apply to images the client sent you: those you can ' +
466
+ 'and should refer to, because the client knows they sent them.',
467
+ user: partes.join('\n\n'),
468
+ ...(imagenes.length > 0 ? { imagenes } : {}),
469
+ },
470
+ );
384
471
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * La memoria de una conversación.
3
+ *
4
+ * Sin esto, cada llamada x402 es independiente: el cliente pregunta algo, tu
5
+ * agente contesta, y a la siguiente no sabe de qué se hablaba. Eso no es un
6
+ * chat, es un buscador que pasa factura — y quien lo usa lo nota al segundo
7
+ * mensaje, cuando tiene que repetir el contexto entero.
8
+ *
9
+ * QUIÉN HABLA LO DICE EL PAGO. La conversación se guarda por la dirección del
10
+ * pagador, y esa dirección no la afirma nadie: firmó un permiso y el cobro se
11
+ * ejecutó en la cadena. Nadie puede leer ni continuar la conversación de otro
12
+ * sin haber pagado como él, así que no hace falta ninguna autenticación
13
+ * aparte. Es la propiedad más útil de cobrar por llamada.
14
+ *
15
+ * SOLO EN x402, NO EN EL ESCROW. Un encargo del escrow es un trabajo con
16
+ * principio y fin: se paga, se entrega una vez y se aprueba. Arrastrarle
17
+ * memoria sería confundir dos cosas distintas.
18
+ *
19
+ * LO QUE CUESTA, porque conviene tenerlo delante: el historial va dentro del
20
+ * prompt, y el prompt lo pagas TÚ mientras el cliente paga un precio fijo por
21
+ * mensaje. Una conversación larga es cada vez más cara de contestar por lo
22
+ * mismo. De ahí los dos topes de abajo, y de ahí que se puedan bajar.
23
+ */
24
+
25
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ /** Un intercambio ya cerrado: lo que preguntaron y lo que contestaste. */
29
+ export interface Turno {
30
+ pregunta: string;
31
+ respuesta: string;
32
+ /** Epoch en milisegundos. */
33
+ cuando: number;
34
+ }
35
+
36
+ /**
37
+ * Cuántos turnos se recuerdan. `MEMORIA_TURNOS=0` apaga la memoria.
38
+ *
39
+ * Seis es un número corto a propósito: cubre una conversación normal y deja
40
+ * el coste acotado. Súbelo si tu agente necesita hilos largos y te sale a
41
+ * cuenta; bájalo a cero si lo tuyo son preguntas sueltas.
42
+ */
43
+ const TURNOS = (() => {
44
+ const n = Number(process.env.MEMORIA_TURNOS?.trim() || '6');
45
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 6;
46
+ })();
47
+
48
+ /**
49
+ * Tope de caracteres del historial que entra en el prompt.
50
+ *
51
+ * El tope de turnos por sí solo no acota nada: seis turnos pueden ser seis
52
+ * líneas o seis pantallas de código pegado.
53
+ */
54
+ const MAX_CHARS = (() => {
55
+ const n = Number(process.env.MEMORIA_CHARS?.trim() || '4000');
56
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 4000;
57
+ })();
58
+
59
+ /** Cuántos turnos se guardan en disco, que es más de lo que se manda. */
60
+ const GUARDADOS = 60;
61
+
62
+ /**
63
+ * El archivo de una conversación.
64
+ *
65
+ * El nombre sale de la dirección, y aunque venga ya validada como tal se
66
+ * limpia igual: es lo que decide una ruta en disco, y una comprobación de más
67
+ * en un sitio así no cuesta nada.
68
+ */
69
+ function archivo(dataDir: string, quien: string): string {
70
+ const limpio = quien.toLowerCase().replace(/[^a-z0-9x]/g, '');
71
+ return join(dataDir, 'chats', `${limpio}.json`);
72
+ }
73
+
74
+ /** Todo lo que se recuerda de esa persona, de lo más viejo a lo más nuevo. */
75
+ export function leerConversacion(dataDir: string, quien: string): Turno[] {
76
+ try {
77
+ const turnos = JSON.parse(readFileSync(archivo(dataDir, quien), 'utf8')) as Turno[];
78
+ return Array.isArray(turnos) ? turnos : [];
79
+ } catch {
80
+ // Sin archivo, o con un archivo ilegible: se empieza de cero. Una memoria
81
+ // rota no puede impedir contestar a alguien que acaba de pagar.
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Guarda un intercambio.
88
+ *
89
+ * Se llama DESPUÉS de contestar, con las dos mitades: un turno con pregunta y
90
+ * sin respuesta ensucia la memoria de la siguiente vez, y es justo lo que
91
+ * pasaría si se guardara antes de trabajar y el modelo fallara.
92
+ */
93
+ export function recordarTurno(dataDir: string, quien: string, turno: Turno): void {
94
+ if (TURNOS === 0) return;
95
+ try {
96
+ const previos = leerConversacion(dataDir, quien);
97
+ mkdirSync(join(dataDir, 'chats'), { recursive: true });
98
+ writeFileSync(archivo(dataDir, quien), JSON.stringify([...previos, turno].slice(-GUARDADOS)), 'utf8');
99
+ } catch (err) {
100
+ // Perder la memoria no puede tumbar una respuesta ya cobrada.
101
+ console.error(`[memoria] no se pudo guardar el turno: ${err instanceof Error ? err.message : err}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * El historial que se le pasa al modelo, ya acotado.
107
+ *
108
+ * Se recorta desde el final: lo reciente es lo que da contexto, y lo viejo es
109
+ * lo primero que sobra. Devuelve vacío con la memoria apagada.
110
+ */
111
+ export function historialParaElModelo(dataDir: string, quien: string): Turno[] {
112
+ if (TURNOS === 0) return [];
113
+
114
+ const recientes = leerConversacion(dataDir, quien).slice(-TURNOS);
115
+ const salida: Turno[] = [];
116
+ let chars = 0;
117
+
118
+ // Se recorren del más nuevo al más viejo para que, si hay que dejar fuera
119
+ // algo, sea lo antiguo. Luego se devuelve en orden cronológico.
120
+ for (let i = recientes.length - 1; i >= 0; i--) {
121
+ const t = recientes[i]!;
122
+ const coste = t.pregunta.length + t.respuesta.length;
123
+ if (chars + coste > MAX_CHARS) break;
124
+ chars += coste;
125
+ salida.unshift(t);
126
+ }
127
+ return salida;
128
+ }
129
+
130
+ /** Cómo se le cuenta el historial al modelo. Vacío si no hay nada que contar. */
131
+ export function historialComoTexto(turnos: Turno[]): string {
132
+ if (turnos.length === 0) return '';
133
+ return turnos
134
+ .map((t) => `Cliente: ${t.pregunta}\nTú: ${t.respuesta}`)
135
+ .join('\n\n');
136
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Reintentar cuando el modelo dice «ahora no».
3
+ *
4
+ * POR QUÉ EXISTE. Un proveedor devuelve 429 cuando está saturado o cuando has
5
+ * ido demasiado deprisa, y 5xx cuando le pasa algo a él. Ninguna de las dos es
6
+ * culpa tuya y las dos se arreglan solas en segundos. Sin esto, cualquiera de
7
+ * ellas mata la tarea: el cliente ya pagó, el pago se queda bloqueado, y no
8
+ * recupera nada hasta que vence el plazo.
9
+ *
10
+ * Medido contra Moonshot en agosto de 2026: dos llamadas seguidas y la segunda
11
+ * volvió con `engine_overloaded_error`. No es un caso raro que haya que
12
+ * imaginar, es lo normal en una tarde con tráfico.
13
+ *
14
+ * QUÉ **NO** SE REINTENTA, que importa igual: los demás 4xx. Un 401 es una
15
+ * clave mala, un 400 es una petición mal formada y un 404 es un modelo que no
16
+ * existe. Repetirlos gasta tiempo y dinero para llegar a la misma respuesta, y
17
+ * retrasa la única noticia útil —que hay algo mal configurado— hasta que se
18
+ * agotan los intentos.
19
+ *
20
+ * La espera CRECE entre intentos. Volver a preguntar de inmediato a un motor
21
+ * saturado es pedirle el mismo 429: empuja más carga justo cuando menos puede
22
+ * con ella.
23
+ */
24
+
25
+ /** Esperas entre intentos, en milisegundos. Cuatro llamadas como mucho. */
26
+ const ESPERAS = [1_000, 4_000, 10_000];
27
+
28
+ /** Los códigos que se vuelven a intentar. El resto son respuestas firmes. */
29
+ export function esReintentable(status: number): boolean {
30
+ return status === 429 || status >= 500;
31
+ }
32
+
33
+ /**
34
+ * `fetch` que aguanta un proveedor con hipo.
35
+ *
36
+ * Devuelve la última respuesta, reintentable o no: quien llama decide qué
37
+ * hacer con ella, como con un `fetch` normal. Los fallos de RED sí se
38
+ * propagan al agotar los intentos, porque ahí no hay respuesta que devolver.
39
+ */
40
+ export async function fetchModelo(
41
+ url: string,
42
+ init: RequestInit,
43
+ dormir: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
44
+ ): Promise<Response> {
45
+ let ultimoFallo: unknown;
46
+
47
+ for (let intento = 0; intento <= ESPERAS.length; intento++) {
48
+ if (intento > 0) {
49
+ const espera = ESPERAS[intento - 1]!;
50
+ console.warn(`[modelo] reintento ${intento} de ${ESPERAS.length} dentro de ${espera} ms`);
51
+ await dormir(espera);
52
+ }
53
+ try {
54
+ const res = await fetch(url, init);
55
+ if (!esReintentable(res.status) || intento === ESPERAS.length) return res;
56
+ console.warn(`[modelo] el proveedor respondió ${res.status}`);
57
+ } catch (err) {
58
+ // Un corte de red o el timeout del AbortSignal. Se reintenta igual: son
59
+ // tan pasajeros como un 503, y con el pago bloqueado no rendirse al
60
+ // primer tropiezo es lo que separa entregar de no entregar.
61
+ ultimoFallo = err;
62
+ if (intento === ESPERAS.length) throw err;
63
+ console.warn(`[modelo] la llamada falló: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ }
66
+
67
+ throw ultimoFallo ?? new Error('inalcanzable');
68
+ }