elffuss-code / js /context.js
KikoCis's picture
ACE v2: BM25 con IDF endogena sin heuristicas + reserva de cabecera + embeddings opcionales tras bandera. Medido: recall de hechos 15,1% -> 65,3%; el encargo original pasa de perderse siempre a conservarse siempre.
51ec7b5 verified
Raw
History Blame Contribute Delete
11.4 kB
// 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);
}
}