// Gestor de contexto ACE-lite: eviction de historial por relevancia, portado // del attention-context-eviction (ACE_R) de agentic-install a nivel de // mensaje (IDF + BM25-lite; en navegador no hay acceso a las atenciones). // turboquant es cuantización de PESOS — para contexto, esto es lo que aplica. // // Reglas: el presupuesto es en tokens (~4 chars/token). Se conservan SIEMPRE // los últimos RECENT mensajes; los antiguos compiten por el hueco restante // puntuados por BM25-lite contra la consulta actual. Los evictados se // sustituyen por una marca de omisión para que el modelo sepa que falta algo. import { packHistoryACER, packHistoryACERHybrid } from './acer-core.js'; // ── ACE_R unificado ───────────────────────────────────────────────────────── // El empaquetado por MENSAJES de aquí abajo se medió contra sesiones reales de // Elffuss Code (leer/buscar/editar un proyecto durante ~50 turnos) y falla por // dos motivos: (1) los últimos RECENT mensajes por sí solos ya se salen del // presupuesto en el 55% de los turnos (un [resultado code.read] son ~1.5k tokens, // no 68 como en el test sintético), así que NINGÚN mensaje antiguo sobrevive y // se devuelven ~5.1k tokens cuando se piden 3k; (2) al conservar/tirar mensajes // ENTEROS, el dato concreto que el agente vuelve a pedir se pierde aunque BM25 // puntúe bien ese mensaje. Medido sobre 32 sesiones reales (4 proyectos × 8 // semillas, 53 turnos): recuerdo de un dato antiguo que el agente vuelve a pedir // 9.2% ± 11.3 (esto) vs 67.7% ± 7.9 (acer-core) con el MISMO gasto de tokens; // truncar por la cola da 7.9% ± 11.9. acer-core.js selecciona por LÍNEAS con un // presupuesto global. Banco: agentic-install/lab/gemma-e2b-cli/agent_test/acer_real. // AVISO honesto: la ventaja viene del solape léxico con la PREGUNTA; si la // pregunta no nombra lo que busca (solo el fichero), baja a 12.7% vs 6.8%. // // Para volver al comportamiento anterior (o comparar), en la consola: // localStorage.setItem('elffuss.acer', 'v1') → empaquetado por mensajes // localStorage.removeItem('elffuss.acer') → ACE_R unificado (por defecto) function acerUnificado() { try { return localStorage.getItem('elffuss.acer') !== 'v1'; } catch { return true; } } // ── lado SEMÁNTICO (embeddings) ───────────────────────────────────────────── // acer-core.js trae las dos vías: la léxica (BM25, síncrona) y la híbrida (BM25 // + embeddings fusionados por rangos, asíncrona). Medido en LoCoMo con el mismo // presupuesto: BM25 23,59 · embeddings 23,95 · fusión de rangos 28,09 · contexto // completo sin comprimir 22,56. La fusión vale +4,50 F1 sobre lo que corría. // // No es que lo semántico sea mejor —empatan en global— sino que cubre el punto // ciego del léxico: cuando la pregunta NO comparte vocabulario con la respuesta, // BM25 cae a 18,60 y los embeddings aguantan 24,28; cuando sí lo comparte manda // BM25 (29,33 vs 23,58). La fusión se queda con los dos (24,05 / 32,73). // // POR DEFECTO APAGADO, y no por prudencia: por lo que salió al MEDIRLO. // // · La primera carga cuesta una descarga de 235 MB (fp16, el camino rápido) o // 118 MB (q8, el de respaldo). Para quien usa un proveedor externo // (providers/api.js) eso es una descarga que hoy NO EXISTE: pasa de 0 a // 235 MB sólo por empaquetar mejor el contexto. Y para quien usa el modelo // local son ~+28 % sobre lo que ya descarga. // · Sólo compensa con adaptador WebGPU. Sin él se cae a wasm, que mide ~9× // más lento y se nota en CADA turno, no sólo en el primero. // · A cambio, con WebGPU el turno en régimen es una fracción de segundo y la // segunda sesión no descarga nada (transformers.js cachea el modelo). // // O sea: la ganancia de recuperación es real y grande (+4,50 F1), pero el precio // no es despreciable y depende del equipo de quien lo usa. Así que se cablea // entero, se deja probado, y se enciende a petición. Con la bandera apagada NI // SIQUIERA se importa embed.js: el coste de tenerlo cableado es exactamente cero. // // localStorage.setItem('elffuss.semantic', 'on') → BM25 + embeddings // localStorage.setItem('elffuss.semantic', 'off') → sólo BM25 // localStorage.removeItem('elffuss.semantic') → sólo BM25 (por defecto) function semanticoOn() { try { return localStorage.getItem('elffuss.semantic') === 'on'; } catch { return false; } } // SEM_BUDGET alto A PROPÓSITO, y esto es lo contrario de lo que parece. // // acer-core deriva el tamaño de bloque como SB = ceil(nLíneas / SEM_BUDGET) y // rearma los bloques desde el principio del historial EN CADA TURNO. Así que // SEM_BUDGET no acota el coste: acota la RESOLUCIÓN, y de paso decide si la // caché por contenido sirve para algo. Con un SEM_BUDGET bajo, cada vez que el // historial crece lo justo para que SB suba de entero, TODOS los bloques cambian // de texto y la caché falla entera; y como el bloque además engorda con la // sesión, el turno se encarece con el tiempo. Con SEM_BUDGET por encima del // número de líneas, SB vale 1: un bloque = una línea, el texto de una línea ya // codificada NO cambia nunca, y un turno sólo paga las líneas NUEVAS. // // Medido en el navegador sobre una sesión de agente de 8 turnos (cada turno = // una tool-call + 90 líneas de resultado, 752 líneas al final), con la caché // viva entre turnos y el mismo modelo (coste relativo por turno, 1,0 = el mejor // turno observado del barrido): // // SEM_BUDGET codificaciones por turno coste por turno // 64 ~64, casi todo recodificado 5,2× → 12,2× ← y SUBIENDO // 400 49–236, a saltos 0,4× – 8,0× ← picos // 4000 92, sólo lo nuevo 2,9× → 0,8× ← plano // // Con 4000 cada línea se codifica UNA vez en toda la sesión (740 codificaciones // para 752 líneas: las repetidas salen gratis por la caché) y el turno se ABARATA // según avanza la sesión en vez de encarecerse. Es exactamente el «indexar al // escribir» que describe la cabecera de acer-core. El valor está alineado con el // tope de la caché de embed.js para que no se desaloje justo lo que el turno // siguiente va a volver a pedir. const SEM_BUDGET = 4000; const RECENT = 6; const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto // Trunca por el MEDIO conservando cabeza y cola (útil para código/documentos). function clampMsg(m) { const c = m.content || ''; if (c.length <= MAX_MSG_CHARS) return m; const head = Math.floor(MAX_MSG_CHARS * 0.7); const tail = MAX_MSG_CHARS - head - 40; return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - MAX_MSG_CHARS} caracteres] …\n` + c.slice(-tail) }; } const STOP = new Set(('de la que el en y a los del se las por un para con no una su al lo como más pero sus le ' + 'ya o este sí porque esta entre cuando muy sin sobre también me hasta hay donde quien desde todo nos durante ' + 'todos uno les ni contra otros ese eso ante ellos e esto mí antes algunos qué unos yo otro otras otra él tanto ' + 'esa estos mucho quienes nada muchos cual poco ella estar estas algunas algo nosotros tu te ti mi es son era eres').split(' ')); const tokens = s => (s.toLowerCase().match(/[a-záéíóúñü0-9_.]{2,}/g) || []).filter(w => !STOP.has(w)); const tokEstimate = m => Math.ceil((m.content || '').length / 4) + 4; function bm25Scores(messages, query) { const q = [...new Set(tokens(query))]; const docs = messages.map(m => tokens(m.content)); const N = docs.length || 1; const avgLen = docs.reduce((s, d) => s + d.length, 0) / N || 1; const df = new Map(); for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) || 0) + 1); const k = 1.2, b = 0.75; return docs.map(d => { if (!d.length) return 0; const tf = new Map(); for (const w of d) tf.set(w, (tf.get(w) || 0) + 1); let score = 0; for (const w of q) { const f = tf.get(w); if (!f) continue; const idf = Math.log(1 + (N - (df.get(w) || 0) + 0.5) / ((df.get(w) || 0) + 0.5)); score += idf * (f * (k + 1)) / (f + k * (1 - b + b * (d.length / avgLen))); } return score; }); } // Recorta resultados de herramientas antiguos: la cola larga rara vez importa. const shrink = m => m.content.startsWith('[resultado') && m.content.length > 600 ? { ...m, content: m.content.slice(0, 600) + '\n… (recortado por antigüedad)' } : m; export function packHistory(history, budgetTokens = 2200) { if (!history.length) return history; if (acerUnificado()) return packHistoryACER(history, budgetTokens).messages; // los recientes también se recortan por mensaje: un solo tool-result gigante // (README de un repo grande) reventaba el contexto → «Too many tokens». const recent = history.slice(-RECENT).map(clampMsg); let used = recent.reduce((s, m) => s + tokEstimate(m), 0); const old = history.slice(0, -RECENT).map(shrink); if (!old.length || used >= budgetTokens) return recent; const query = [...history].reverse().find(m => m.role === 'user' && !m.content.startsWith('[resultado'))?.content || ''; const scores = bm25Scores(old, query); // orden por relevancia; a igualdad gana lo más nuevo const ranked = old.map((m, i) => ({ m, i, s: scores[i] })) .sort((a, b) => (b.s - a.s) || (b.i - a.i)); const keep = new Set(); for (const { m, i } of ranked) { const cost = tokEstimate(m); if (used + cost > budgetTokens) continue; used += cost; keep.add(i); } const packed = []; let dropped = 0; old.forEach((m, i) => { if (keep.has(i)) { if (dropped) { packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` }); dropped = 0; } packed.push(m); } else dropped++; }); if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` }); return [...packed, ...recent]; } /** * Igual que packHistory pero por la vía HÍBRIDA (léxica + semántica) cuando la * bandera está encendida. Es la que deben usar los proveedores. * * Degrada al empaquetado de siempre —el mismo, byte a byte— si la bandera está * apagada, si el modelo de embeddings no está o no carga, o si codificar falla. * Ese camino de vuelta es deliberado: que no haya modelo no puede significar que * la app deje de funcionar, sólo que empaqueta como hoy. * * `packHistory` sigue exportada y síncrona por compatibilidad. */ export async function packHistoryAsync(history, budgetTokens = 2200) { if (!history.length) return history; if (!acerUnificado() || !semanticoOn()) return packHistory(history, budgetTokens); try { // Import DINÁMICO: con la bandera apagada, embed.js —y con él // transformers.js y el modelo— no se piden nunca. const { embed, embedCache } = await import('./embed.js'); const r = await packHistoryACERHybrid(history, budgetTokens, { embed, cache: embedCache(), SEM_BUDGET, }); return r.messages; } catch { return packHistory(history, budgetTokens); } }