pi-llamacpp-infra 1.0.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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * prompt-warmup — system-prompt header pre-cache for local llama.cpp servers
3
+ * ==========================================================================
4
+ *
5
+ * Internal module of the llamacpp-infra extension (previously the shared
6
+ * pi_extensions/lib/prompt-warmup.ts — inlined here since llamacpp-infra is
7
+ * its only consumer).
8
+ *
9
+ * The pi system prompt (identity, tools, skills, context files, docs paths)
10
+ * is a very large header (5k-25k tokens). On locally served models that
11
+ * prefill dominates time-to-first-token.
12
+ *
13
+ * llama.cpp-family servers (and forks: lucebox, ds4/DwarfStar, ZINC) keep the
14
+ * prompt KV cached in the slot between requests: if an incoming request
15
+ * shares a prefix with the previous one, the KV is reused and only the new
16
+ * tokens are evaluated. This module exploits that: when a new conversation
17
+ * starts (pi boot, /new, /fork, /resume or model switch) it sends the server
18
+ * a "warmup" request carrying the SAME header (system + tools captured from
19
+ * pi's real payload) with max_tokens=1, leaving the KV cached in the slot.
20
+ * The user's first real message then hits the cache: TTFT ~40s → ~0.3s.
21
+ *
22
+ * Key design point: the header is CAPTURED from the real payload in
23
+ * `before_provider_request` (not from ctx.getSystemPrompt()) because other
24
+ * extensions may transform it earlier. Reproducing the exact bytes of the
25
+ * payload guarantees an identical prefix.
26
+ *
27
+ * Templates persist in ~/.pi/agent/warmup-llamacpp-infra.json so the warmup
28
+ * survives pi restarts.
29
+ *
30
+ * Safety semantics:
31
+ * - Never blocks: everything is fire-and-forget, with AbortController.
32
+ * - The user's first real request aborts any in-flight warmup for that model.
33
+ * - Failures are silent (server down, timeout, 503): debug log only.
34
+ * - No user messages are stored: only the header (system + tools).
35
+ *
36
+ * Configuration (env):
37
+ * PI_WARMUP=0 globally disable warmup
38
+ * PI_WARMUP_LLAMACPP_INFRA=0 disable for this provider
39
+ * PI_WARMUP_FALLBACK=0 disable the ctx.getSystemPrompt() fallback
40
+ * PI_WARMUP_TIMEOUT_MS=180000 timeout per warmup request
41
+ * PI_WARMUP_COOLDOWN_MS=15000 cooldown between warmups of the same model
42
+ * PI_WARMUP_DEBUG=1 debug logging (console.debug → stderr)
43
+ *
44
+ * UI: silent by default. The llamacpp-infra extension passes its own key
45
+ * ("warmup-llamacpp-infra") to WarmupStatus for the ☕ footer indicator.
46
+ */
47
+
48
+
49
+ import * as http from "node:http";
50
+ import * as fs from "node:fs";
51
+ import * as path from "node:path";
52
+ import { homedir } from "node:os";
53
+ import { URL } from "node:url";
54
+
55
+ // =============================================================================
56
+ // Constantes / env
57
+ // =============================================================================
58
+
59
+ const KIND_LLAMACPP = "llamacpp";
60
+ const KIND_LUCEBOX = "lucebox";
61
+ const KIND_DS4 = "ds4";
62
+ const KIND_ZINC = "zinc";
63
+ const KIND_LMSTUDIO = "lmstudio";
64
+
65
+ const SAVE_DEBOUNCE_MS = 5_000;
66
+ const MAX_TEMPLATES = 30;
67
+ const MAX_AGE_MS = 14 * 24 * 3600 * 1000; // 14 días
68
+ const PLACEHOLDER_USER = "hi";
69
+
70
+ function envBool(name: string, def: boolean): boolean {
71
+ const v = process.env[name];
72
+ if (v === undefined) return def;
73
+ return v === "1" || v.toLowerCase() === "true";
74
+ }
75
+
76
+ function envInt(name: string, def: number): number {
77
+ const v = process.env[name];
78
+ if (v === undefined) return def;
79
+ const n = parseInt(v, 10);
80
+ return isNaN(n) || n <= 0 ? def : n;
81
+ }
82
+
83
+ const GLOBAL_ENABLED = envBool("PI_WARMUP", true);
84
+ const FALLBACK_ENABLED = envBool("PI_WARMUP_FALLBACK", true);
85
+ const WARMUP_TIMEOUT_MS = envInt("PI_WARMUP_TIMEOUT_MS", 180_000);
86
+ const COOLDOWN_MS = envInt("PI_WARMUP_COOLDOWN_MS", 15_000);
87
+ /** Log de depuración: solo con PI_WARMUP_DEBUG=1. Las escrituras a stderr en
88
+ * TUI se intercalan con el render de pi y ensucian el editor / input box. */
89
+ const DEBUG_LOG_ENABLED = envBool("PI_WARMUP_DEBUG", false);
90
+
91
+ /**
92
+ * ¿El servidor mantiene KV cache del prompt entre requests?
93
+ * - llama.cpp y forks (lucebox, ds4): sí (cache_prompt on por defecto).
94
+ * - LM Studio: sí, cache automática (backend llama.cpp) sin campo extra.
95
+ * - ZINC: server propio sin prefix-cache verificado → se descarta entero.
96
+ */
97
+ function warmupEnabledFor(kind: string | undefined): boolean {
98
+ return kind !== KIND_ZINC;
99
+ }
100
+
101
+ /** ¿Se manda el campo `cache_prompt` (extensión llama.cpp)? ZINC/LM Studio no lo necesitan. */
102
+ function cachePromptSupported(kind: string | undefined): boolean {
103
+ return kind !== KIND_ZINC && kind !== KIND_LMSTUDIO;
104
+ }
105
+
106
+ function normalizeBaseUrl(baseUrl: string | undefined): string {
107
+ if (!baseUrl) return "";
108
+ return baseUrl.replace(/\/+$/, "");
109
+ }
110
+
111
+ // =============================================================================
112
+ // Tipos
113
+ // =============================================================================
114
+
115
+ interface WarmMessage {
116
+ role: string;
117
+ content: unknown;
118
+ }
119
+
120
+ interface WarmTemplate {
121
+ model: string;
122
+ baseUrl: string;
123
+ cwd: string;
124
+ systemMessages: WarmMessage[];
125
+ tools?: unknown[] | undefined;
126
+ kind: string;
127
+ capturedAt: number;
128
+ }
129
+
130
+ interface WarmupBody {
131
+ model: string;
132
+ messages: WarmMessage[];
133
+ tools?: unknown[];
134
+ max_tokens: number;
135
+ temperature: number;
136
+ stream: boolean;
137
+ cache_prompt?: boolean;
138
+ }
139
+
140
+ interface WarmupResult {
141
+ status: number;
142
+ body: string;
143
+ }
144
+
145
+ export type WarmupEvent =
146
+ | { type: "start"; model: string }
147
+ | { type: "done"; model: string; ms: number; tokens?: number }
148
+ | { type: "abort"; model: string; ms: number }
149
+ | { type: "error"; model: string; ms: number; error: string };
150
+
151
+ /**
152
+ * Indicador visual del warmup en el footer de pi (ctx.ui.setStatus).
153
+ *
154
+ * Indicador compacto y sin trazas en stderr:
155
+ * start → "☕" (mientras se precarga la cabecera)
156
+ * done → "☕ 30.2s ✓" (duración del prefill; se borra tras ~3s)
157
+ * error → "☕ ⚠" (id.)
158
+ * abort → clear inmediato
159
+ *
160
+ * El log verbose por stderr (PI_WARMUP_DEBUG=1) se ha desactivado por defecto
161
+ * porque las escrituras a stderr en TUI se intercalan con el render de pi y
162
+ * ensucian el editor / caja de input.
163
+ *
164
+ * Pasar siempre una `key` por provider (p.ej. "warmup-llamacpp-infra" / "warmup-lmstudio")
165
+ * para que dos providers activos a la vez no se pisen la misma ranura del footer.
166
+ *
167
+ * const warmupStatus = new WarmupStatus("warmup-llamacpp-infra");
168
+ * const warmer = new PromptWarmer({ ..., onEvent: (ev) => warmupStatus.handle(ev) });
169
+ * // en session_start / model_select:
170
+ * if (ctx.hasUI) warmupStatus.bind(ctx.ui);
171
+ * // en session_shutdown:
172
+ * warmupStatus.dispose();
173
+ */
174
+ export class WarmupStatus {
175
+ private ui: { setStatus: (key: string, text: string | undefined) => void } | undefined;
176
+ private key: string;
177
+ private doneClearMs: number;
178
+ private timer: ReturnType<typeof setTimeout> | undefined;
179
+
180
+ /**
181
+ * @param key Clave de la ranura del footer (cada provider usa la suya
182
+ * para no chocar con otros warmups activos simultáneos).
183
+ * @param doneClearMs Tiempo que permanece el indicador "✓" / "⚠" tras terminar
184
+ * el warmup antes de borrarse (ms).
185
+ */
186
+ constructor(key = "warmup", doneClearMs = 3000) {
187
+ this.key = key;
188
+ this.doneClearMs = doneClearMs;
189
+ }
190
+
191
+ /** Vincula la UI (ctx.ui de un handler con hasUI). Idempotente. */
192
+ bind(ui: { setStatus: (key: string, text: string | undefined) => void }): void {
193
+ this.ui = ui;
194
+ }
195
+
196
+ private clearSoon(ms: number): void {
197
+ if (this.timer) clearTimeout(this.timer);
198
+ this.timer = setTimeout(() => {
199
+ this.timer = undefined;
200
+ this.ui?.setStatus(this.key, undefined);
201
+ }, ms);
202
+ this.timer.unref?.();
203
+ }
204
+
205
+ private clearNow(): void {
206
+ if (this.timer) clearTimeout(this.timer);
207
+ this.timer = undefined;
208
+ this.ui?.setStatus(this.key, undefined);
209
+ }
210
+
211
+ handle(ev: WarmupEvent): void {
212
+ if (!this.ui) return;
213
+ switch (ev.type) {
214
+ case "start":
215
+ this.clearNow();
216
+ // Compacto: solo el icono. El usuario ve que algo se está precargando
217
+ // sin que el texto ocupe media línea del footer.
218
+ this.ui.setStatus(this.key, "☕");
219
+ break;
220
+ case "done": {
221
+ // Mostramos la duración del prefill (dato útil y corto).
222
+ // Sin token count interno: es ruido para el usuario.
223
+ const secs = (ev.ms / 1000).toFixed(1);
224
+ this.ui.setStatus(this.key, `☕ ${secs}s ✓`);
225
+ this.clearSoon(this.doneClearMs);
226
+ break;
227
+ }
228
+ case "abort":
229
+ this.clearNow();
230
+ break;
231
+ case "error":
232
+ this.ui.setStatus(this.key, "☕ ⚠");
233
+ this.clearSoon(this.doneClearMs);
234
+ break;
235
+ }
236
+ }
237
+
238
+ dispose(): void {
239
+ this.clearNow();
240
+ this.ui = undefined;
241
+ }
242
+ }
243
+
244
+ export interface PromptWarmerOptions {
245
+ /** Nombre del provider registrado (llamacpp-infra / lmstudio). */
246
+ provider: string;
247
+ /** Ruta del fichero de plantillas persistido. */
248
+ cacheFile?: string;
249
+ /** Devuelve el tipo de servidor ("llamacpp"|"lucebox"|"ds4"|"zinc"|"lmstudio") para una baseUrl. */
250
+ kindFor?: (baseUrl: string) => string | undefined;
251
+ /** Callback opcional para eventos de warmup (estado en footer, notify, ...). */
252
+ onEvent?: (ev: WarmupEvent) => void;
253
+ /** Log opcional. Por defecto silencioso (no-op); activa con PI_WARMUP_DEBUG=1. */
254
+ log?: (msg: string) => void;
255
+ }
256
+
257
+ // =============================================================================
258
+ // HTTP mínimo (mismo patrón hardened que los providers locales)
259
+ // =============================================================================
260
+ // - family: 4 → evita la resolución IPv6-first de hostnames tailnet/LAN
261
+ // - Connection: close + agent:false → sin keep-alive (pasta/podman cierra sockets)
262
+ // - timeout largo (el prefill de 20k tokens tarda decenas de segundos)
263
+ // - AbortSignal → el primer request real aborta el warmup en vuelo
264
+
265
+ function warmupRequest(
266
+ baseUrl: string,
267
+ body: WarmupBody,
268
+ timeoutMs: number,
269
+ signal: AbortSignal,
270
+ ): Promise<WarmupResult> {
271
+ return new Promise((resolve, reject) => {
272
+ let settled = false;
273
+ const url = new URL(normalizeBaseUrl(baseUrl) + "/chat/completions");
274
+ const options: http.RequestOptions = {
275
+ hostname: url.hostname,
276
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
277
+ path: url.pathname + url.search,
278
+ method: "POST",
279
+ headers: {
280
+ "Content-Type": "application/json",
281
+ Accept: "application/json",
282
+ "User-Agent": "pi-warmup/1.0",
283
+ Connection: "close",
284
+ },
285
+ timeout: timeoutMs,
286
+ family: 4,
287
+ agent: false,
288
+ };
289
+
290
+ const req = http.request(options, (res) => {
291
+ const chunks: Buffer[] = [];
292
+ res.on("data", (c: Buffer) => chunks.push(c));
293
+ res.on("end", () => {
294
+ if (settled) return;
295
+ settled = true;
296
+ resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8") });
297
+ });
298
+ res.on("error", (e) => {
299
+ if (settled) return;
300
+ settled = true;
301
+ reject(e);
302
+ });
303
+ });
304
+
305
+ req.on("error", (e) => {
306
+ if (settled) return;
307
+ settled = true;
308
+ reject(e);
309
+ });
310
+
311
+ req.on("timeout", () => {
312
+ if (settled) return;
313
+ settled = true;
314
+ req.destroy();
315
+ const err: NodeJS.ErrnoException = new Error(`Warmup timeout after ${timeoutMs}ms`);
316
+ err.code = "WARMUP_TIMEOUT";
317
+ reject(err);
318
+ });
319
+
320
+ const onAbort = () => {
321
+ if (settled) return;
322
+ settled = true;
323
+ req.destroy();
324
+ const err: NodeJS.ErrnoException = new Error("Warmup aborted (primer mensaje del usuario)");
325
+ err.code = "ABORT_ERR";
326
+ reject(err);
327
+ };
328
+ signal.addEventListener("abort", onAbort, { once: true });
329
+ req.on("close", () => signal.removeEventListener("abort", onAbort));
330
+
331
+ req.write(JSON.stringify(body));
332
+ req.end();
333
+ });
334
+ }
335
+
336
+ function parsePromptTokens(body: string): number | undefined {
337
+ try {
338
+ const usage = (JSON.parse(body) as { usage?: { prompt_tokens?: number } })?.usage;
339
+ if (typeof usage?.prompt_tokens === "number") return usage.prompt_tokens;
340
+ } catch {
341
+ // ignorar
342
+ }
343
+ return undefined;
344
+ }
345
+
346
+ // =============================================================================
347
+ // PromptWarmer
348
+ // =============================================================================
349
+
350
+ export class PromptWarmer {
351
+ readonly provider: string;
352
+ private cacheFile: string;
353
+ private kindFor: (baseUrl: string) => string | undefined;
354
+ private onEvent?: (ev: WarmupEvent) => void;
355
+ private log: (msg: string) => void;
356
+
357
+ private templates = new Map<string, WarmTemplate>();
358
+ /** warmups en vuelo, key = modelId (abort rápido por modelo). */
359
+ private inFlight = new Map<string, AbortController>();
360
+ /** último warmup completado por key completa (baseUrl|model|cwd), para cooldown. */
361
+ private lastWarmed = new Map<string, number>();
362
+ private saveTimer: ReturnType<typeof setTimeout> | undefined;
363
+ private disposed = false;
364
+ private enabled: boolean;
365
+
366
+ constructor(opts: PromptWarmerOptions) {
367
+ this.provider = opts.provider;
368
+ this.cacheFile =
369
+ opts.cacheFile ?? path.join(homedir(), ".pi", "agent", `warmup-${opts.provider}.json`);
370
+ this.kindFor = opts.kindFor ?? (() => KIND_LLAMACPP);
371
+ this.onEvent = opts.onEvent;
372
+ // Por defecto silencioso: escribir por stderr corrompe el render de la TUI
373
+ // de pi (trazas sucias en el editor / input box). Solo se loguea con
374
+ // PI_WARMUP_DEBUG=1 o si el caller pasa un logger explícito.
375
+ this.log =
376
+ opts.log ??
377
+ (DEBUG_LOG_ENABLED ? (msg: string) => console.debug(msg) : () => {});
378
+ this.enabled =
379
+ GLOBAL_ENABLED && envBool(`PI_WARMUP_${opts.provider.toUpperCase()}`, true);
380
+ this.load();
381
+ }
382
+
383
+ private key(modelId: string, baseUrl: string, cwd: string): string {
384
+ return `${baseUrl}|${modelId}|${cwd}`;
385
+ }
386
+
387
+ private kind(baseUrl: string): string | undefined {
388
+ try {
389
+ return this.kindFor(baseUrl);
390
+ } catch {
391
+ return undefined;
392
+ }
393
+ }
394
+
395
+ // ── Warmup ────────────────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Llamar en session_start y model_select. Dispara (fire-and-forget) un
399
+ * prefill de la cabecera para el modelo actual si es de este provider.
400
+ * Usa la plantilla capturada (system+tools byte-idénticos) si existe;
401
+ * si no, fallback con ctx.getSystemPrompt() (prefijo parcial: pi-compact
402
+ * puede compactar skills después, pero el grueso de la cabecera coincide).
403
+ */
404
+ warmupForModel(
405
+ model: { id: string; provider?: string; baseUrl?: string } | undefined,
406
+ systemPrompt?: string,
407
+ cwd?: string,
408
+ ): void {
409
+ if (!this.enabled || this.disposed) return;
410
+ if (!model || !model.id || model.provider !== this.provider) return;
411
+ const baseUrl = normalizeBaseUrl(model.baseUrl);
412
+ if (!baseUrl) return;
413
+ if (!warmupEnabledFor(this.kind(baseUrl))) return;
414
+
415
+ const key = this.key(model.id, baseUrl, cwd ?? "");
416
+ const now = Date.now();
417
+ const last = this.lastWarmed.get(key);
418
+ if (last !== undefined && now - last < COOLDOWN_MS) return;
419
+ if (this.inFlight.has(model.id)) return;
420
+
421
+ const tpl = this.templates.get(key);
422
+ let body: WarmupBody;
423
+ if (tpl) {
424
+ body = {
425
+ model: tpl.model,
426
+ messages: [...tpl.systemMessages, { role: "user", content: PLACEHOLDER_USER }],
427
+ max_tokens: 1,
428
+ temperature: 0,
429
+ stream: false,
430
+ };
431
+ if (tpl.tools && tpl.tools.length > 0) body.tools = tpl.tools;
432
+ if (cachePromptSupported(tpl.kind)) body.cache_prompt = true;
433
+ } else if (systemPrompt && FALLBACK_ENABLED) {
434
+ body = {
435
+ model: model.id,
436
+ messages: [
437
+ { role: "system", content: systemPrompt },
438
+ { role: "user", content: PLACEHOLDER_USER },
439
+ ],
440
+ max_tokens: 1,
441
+ temperature: 0,
442
+ stream: false,
443
+ };
444
+ if (cachePromptSupported(this.kind(baseUrl))) body.cache_prompt = true;
445
+ } else {
446
+ return;
447
+ }
448
+
449
+ this.fire(model.id, key, baseUrl, body);
450
+ }
451
+
452
+ private fire(modelId: string, key: string, baseUrl: string, body: WarmupBody): void {
453
+ const ac = new AbortController();
454
+ this.inFlight.set(modelId, ac);
455
+ const t0 = Date.now();
456
+ this.onEvent?.({ type: "start", model: modelId });
457
+ this.log(`[warmup:${this.provider}] ${modelId} → ${normalizeBaseUrl(baseUrl)}/chat/completions`);
458
+
459
+ warmupRequest(baseUrl, body, WARMUP_TIMEOUT_MS, ac.signal)
460
+ .then((res) => {
461
+ if (this.disposed) return;
462
+ this.inFlight.delete(modelId);
463
+ this.lastWarmed.set(key, Date.now());
464
+ if (res.status >= 200 && res.status < 300) {
465
+ const tokens = parsePromptTokens(res.body);
466
+ const ms = Date.now() - t0;
467
+ this.onEvent?.({ type: "done", model: modelId, ms, tokens });
468
+ this.log(
469
+ `[warmup:${this.provider}] ${modelId}: prefill ${tokens ?? "?"} tok en ${(ms / 1000).toFixed(1)}s ✓`,
470
+ );
471
+ } else {
472
+ this.onEvent?.({ type: "error", model: modelId, ms: Date.now() - t0, error: `HTTP ${res.status}` });
473
+ this.log(`[warmup:${this.provider}] ${modelId}: HTTP ${res.status} (${res.body.slice(0, 120)})`);
474
+ }
475
+ })
476
+ .catch((err) => {
477
+ if (this.disposed) return;
478
+ this.inFlight.delete(modelId);
479
+ const ms = Date.now() - t0;
480
+ const code = (err as NodeJS.ErrnoException)?.code;
481
+ if (code === "ABORT_ERR") {
482
+ this.onEvent?.({ type: "abort", model: modelId, ms });
483
+ this.log(`[warmup:${this.provider}] ${modelId}: abortado (request real) tras ${(ms / 1000).toFixed(1)}s`);
484
+ } else {
485
+ const msg = err instanceof Error ? err.message : String(err);
486
+ this.onEvent?.({ type: "error", model: modelId, ms, error: msg });
487
+ this.log(`[warmup:${this.provider}] ${modelId}: ${msg}`);
488
+ }
489
+ });
490
+ }
491
+
492
+ /** Aborta cualquier warmup en vuelo para un modelo (antes del request real). */
493
+ abortForModel(modelId: string): void {
494
+ const ac = this.inFlight.get(modelId);
495
+ if (ac) ac.abort();
496
+ }
497
+
498
+ // ── Captura del payload real ──────────────────────────────────────────────
499
+
500
+ /**
501
+ * Llamar en before_provider_request con el payload final (tras los hooks de
502
+ * otras extensiones) + la baseUrl del modelo. Aborta el warmup en vuelo y
503
+ * captura la cabecera real (system + tools) para futuras conversaciones.
504
+ */
505
+ onProviderPayload(payload: unknown, baseUrl?: string, cwd?: string): void {
506
+ if (!this.enabled || this.disposed) return;
507
+ const p = payload as Record<string, unknown> | undefined;
508
+ if (!p || typeof p !== "object") return;
509
+ const modelId = typeof p.model === "string" ? p.model : undefined;
510
+ if (modelId) this.abortForModel(modelId);
511
+ if (!modelId || !baseUrl || !Array.isArray(p.messages)) return;
512
+ if (!warmupEnabledFor(this.kind(baseUrl))) return;
513
+
514
+ const msgs = p.messages as WarmMessage[];
515
+ const first = msgs[0];
516
+ if (!first || (first.role !== "system" && first.role !== "developer")) return;
517
+
518
+ const key = this.key(modelId, normalizeBaseUrl(baseUrl), cwd ?? "");
519
+ const tools = Array.isArray(p.tools) ? (p.tools as unknown[]) : undefined;
520
+
521
+ // No degradar una plantilla con tools por una captura sin tools.
522
+ const existing = this.templates.get(key);
523
+ if (existing && existing.tools && existing.tools.length > 0 && (!tools || tools.length === 0)) {
524
+ return;
525
+ }
526
+
527
+ this.templates.set(key, {
528
+ model: modelId,
529
+ baseUrl: normalizeBaseUrl(baseUrl),
530
+ cwd: cwd ?? "",
531
+ systemMessages: msgs.filter((m) => m.role === "system" || m.role === "developer"),
532
+ tools,
533
+ kind: this.kind(baseUrl) ?? KIND_LLAMACPP,
534
+ capturedAt: Date.now(),
535
+ });
536
+ this.scheduleSave();
537
+ }
538
+
539
+ // ── Persistencia ──────────────────────────────────────────────────────────
540
+
541
+ private load(): void {
542
+ try {
543
+ if (!fs.existsSync(this.cacheFile)) return;
544
+ const raw = JSON.parse(fs.readFileSync(this.cacheFile, "utf-8")) as {
545
+ templates?: WarmTemplate[];
546
+ };
547
+ const now = Date.now();
548
+ for (const t of raw.templates ?? []) {
549
+ if (!t || typeof t !== "object" || typeof t.model !== "string" || typeof t.baseUrl !== "string") continue;
550
+ if (now - (t.capturedAt ?? 0) > MAX_AGE_MS) continue;
551
+ if (!Array.isArray(t.systemMessages) || t.systemMessages.length === 0) continue;
552
+ this.templates.set(this.key(t.model, t.baseUrl, t.cwd ?? ""), {
553
+ ...t,
554
+ kind: t.kind ?? KIND_LLAMACPP,
555
+ });
556
+ }
557
+ } catch {
558
+ // Fichero corrupto → empezar de cero
559
+ }
560
+ }
561
+
562
+ private scheduleSave(): void {
563
+ if (this.saveTimer) return;
564
+ this.saveTimer = setTimeout(() => {
565
+ this.saveTimer = undefined;
566
+ this.save();
567
+ }, SAVE_DEBOUNCE_MS);
568
+ this.saveTimer.unref?.();
569
+ }
570
+
571
+ private save(): void {
572
+ try {
573
+ const arr = [...this.templates.values()]
574
+ .sort((a, b) => a.capturedAt - b.capturedAt)
575
+ .slice(-MAX_TEMPLATES);
576
+ fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
577
+ fs.writeFileSync(
578
+ this.cacheFile,
579
+ JSON.stringify({ version: 1, provider: this.provider, templates: arr }),
580
+ "utf-8",
581
+ );
582
+ } catch {
583
+ // Sin permisos / disco lleno → el warmup sigue funcionando en memoria
584
+ }
585
+ }
586
+
587
+ dispose(): void {
588
+ this.disposed = true;
589
+ if (this.saveTimer) clearTimeout(this.saveTimer);
590
+ for (const ac of this.inFlight.values()) ac.abort();
591
+ this.inFlight.clear();
592
+ this.save();
593
+ }
594
+ }