Spaces:
Running
Running
File size: 12,660 Bytes
b30b7c5 51ec7b5 1ff195c 51ec7b5 b30b7c5 1ff195c b30b7c5 51ec7b5 1ff195c 51ec7b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | // 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; }
}
// ── TARJETA DE RECUENTO (agregación) ────────────────────────────────────────
// «¿cuántos ficheros has tocado?» no está en ninguna línea del historial: está
// repartida en cuarenta. Ningún top-k la encuentra POR CONSTRUCCIÓN — no es que
// puntúe mal, es que no existe la línea que buscar. acer-core lo cuenta al
// indexar y lo emite como un bloque protegido (ver buildLedger / renderCard).
//
// APAGADO por defecto, y por lo que salió al MEDIRLO: la tarjeta ocupa ~150
// tokens del presupuesto y en el banco de hechos cuesta −1,1 puntos a
// presupuesto 3.000 (el de esta app) y ±0,0 a 16.000. A cambio, con ella el
// recuento pasa de estar el 0 % de las veces a estar el 100 %, y la enumeración
// de lo editado del 58,9 % al 90,6 %. O sea: si la sesión es de las que acaban
// con «hazme la lista de lo que tocaste», compensa; si no, se paga por nada.
//
// localStorage.setItem('elffuss.resumen', 'on') → con tarjeta
// localStorage.removeItem('elffuss.resumen') → sin tarjeta (por defecto)
function resumenOn() {
try { return localStorage.getItem('elffuss.resumen') === '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, { SUMMARY: resumenOn() }).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, SUMMARY: resumenOn(),
});
return r.messages;
} catch {
return packHistory(history, budgetTokens);
}
}
|