elffuss-code / js /acer-core.js
KikoCis's picture
perf: MMR off by default (measured at zero contribution)
094ecad verified
Raw
History Blame Contribute Delete
68.1 kB
/**
* ACE CORE v2 — recuperación, no heurísticas.
*
* ─────────────────────────────────────────────────────────────────────────────
* POR QUÉ SE REESCRIBIÓ ESTO
*
* La v1 puntuaba cada línea con `classifyLine()`, un conjunto de reglas escritas
* a mano ("si contiene 'error' → 0.95", "si empieza por $ → 0.6"). Medido en un
* banco de memoria a largo plazo, ese motor recuperaba el 2,0 % de la evidencia
* necesaria — POR DEBAJO de quedarse los últimos N mensajes sin pensar (5,8 %).
* No era mediocre: hacía daño.
*
* La causa es visible en la propia aritmética de la v1: classifyLine devolvía
* entre 0.1 y 1.0, mientras el solapamiento con la pregunta entraba multiplicado
* por 0.4. Las reglas pesaban más que aquello que el usuario está preguntando.
*
* Medición del sustituto, mismo banco y mismo presupuesto (recall de evidencia):
*
* Y en el banco de SESIONES REALES de agente (25 sesiones, 174 sondas, mismo
* presupuesto — recall de hechos, sin juez LLM):
*
* truncar por la cola .......... 7,0 %
* el empaquetador desplegado ... 15,1 % ← y PLANO: con 10× de presupuesto
* recupera exactamente lo mismo,
* porque tira material ANTES de
* puntuar. No usa lo que le das.
* este ......................... 65,3 % ± 8,5
*
* (LoCoMo, 200 preguntas, F1 del propio repo · recall de evidencia)
*
* F1 evidencia
* contexto COMPLETO (techo) .... 22,56 100 %
* últimos N mensajes ........... 6,62 5,8 %
* acer v1 (heurísticas) ........ 6,07 2,0 % ← por debajo del suelo
* BM25 sin IDF ................. 20,34 47,8 %
* BM25 ......................... 23,59 60,3 %
* embeddings ................... 23,95 60,0 %
* híbrido (suma ponderada) ..... 26,36 69,1 %
* fusión de RANGOS ............. 28,09 71,1 % ← este fichero
*
* ★ RECUPERAR BIEN BATE A TENERLO TODO: 28,09 contra 22,56 del contexto
* completo, usando el 8 % de los tokens — el 135 % del techo. Lo irrelevante
* no es neutro: distrae. Comprimir bien no es un mal menor, es una MEJORA.
*
* ★ Y encender la heurística cuesta −3,02 F1 (26,36 con peso 0 → 23,34 con el
* peso que llevaba). No es que no aportara: restaba.
*
* Dos lecturas importantes de esa tabla:
*
* 1. BM25 y los embeddings EMPATAN en global, pero NO hacen el mismo trabajo.
* Partiendo las preguntas por solape de vocabulario con la respuesta:
*
* sin solape léxico (n=107): embeddings 24,28 > BM25 18,60
* con solape (n=93): BM25 29,33 > embeddings 23,58
* fusión de rangos: 24,05 / 32,73
*
* La fusión se queda con LOS DOS. Lo semántico no sustituye al léxico:
* le cubre el punto ciego. Ese es el motivo de que el híbrido exista.
*
* 2. La fusión por RANGOS gana a la suma ponderada, y además evita tener que
* calibrar escalas entre una puntuación BM25 (no acotada) y un coseno
* (en [-1,1]), que es una fuente clásica de fragilidad.
*
* ─────────────────────────────────────────────────────────────────────────────
* DECISIONES DE DISEÑO
*
* · Sin heurísticas. Ni una regla sobre el contenido. Si una línea importa, lo
* dirá su relevancia frente a la pregunta viva, no una lista de palabras.
*
* · Sin lista de parada escrita a mano. La v1 llevaba REF_STOP con palabras
* inglesas y castellanas a mano. La IDF calculada sobre el PROPIO historial
* hace ese trabajo sola y mejor: lo que aparece en todas partes recibe peso
* casi nulo por construcción, sin diccionario y sin saber en qué idioma
* estamos. Un stoplist a mano es una lista de parada que envejece; la IDF
* endógena se adapta a cada conversación.
*
* · BM25 de verdad: saturación de frecuencia (k1) y normalización por longitud
* (b). La v1 sumaba IDF y dividía por 3 — eso premia las líneas largas y no
* satura, que son justo los dos defectos que BM25 existe para corregir.
*
* · Contra la PREGUNTA VIVA, no contra la tarea inicial. La v1 puntuaba sobre
* todo contra el primer mensaje. Lo relevante cambia en cada turno.
*
* · Indexar al ESCRIBIR, no al leer. Los embeddings se cachean por contenido,
* así que una línea se codifica una vez en toda la sesión. Sin eso el coste
* crece con el cuadrado de los turnos.
*
* · Degradación limpia: sin función de embeddings, esto es BM25 solo — que ya
* es F1 23,59 frente al 6,07 de la v1. Lo semántico suma, no es requisito.
*
*
* ─────────────────────────────────────────────────────────────────────────────
* LO QUE NO ES UN PROBLEMA DE RECUPERACIÓN
*
* Dos fallos no los arregla ninguna perilla de BM25, porque no son de recuperar:
*
* · «ayer» escrito en el turno 3 es MENTIRA en el turno 40. La línea se
* recupera perfectamente; lo que lleva dentro es falso. Se resuelve al
* ESCRIBIR, anotando la fecha absoluta del turno junto al original (ver
* annotateDates). Sonda propia —el banco de siempre no puede verlo, sus
* preguntas son por identificadores— 32 sesiones reales, la frase colocada
* en cuatro posiciones distintas de la sesión:
*
* la línea se recupera ........................ 100 %
* la FECHA está en el contexto, sin anotar .... 0 %
* la FECHA está en el contexto, anotada ....... 100 %
* fecha FALSA (si se usara «ahora») ........... 0 %
*
* Coste: +7 tokens sobre 2.223 (0,3 %). Recuperación perfecta y respuesta
* imposible es exactamente el caso de markSuperseded, un piso más abajo.
*
* · «¿cuántos ficheros has tocado?» no está en ninguna línea: 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. Se resuelve contando al
* escribir (ver buildLedger / renderCard). Misma sonda propia, 32 sesiones
* con 6 ficheros editados de verdad, presupuesto 3.000:
*
* sin tarjeta con tarjeta
* el RECUENTO está en el contexto 0 % 100 %
* cobertura de los ficheros editados 58,9 % 90,6 %
*
* ⚠️ Y NO es gratis: en el banco de hechos de siempre (32 sesiones reales,
* 8 semillas × 4 proyectos) cuesta −1,1 puntos a presupuesto 3.000 (65,7 %
* → 64,6 %) y ±0,0 a 16.000. Con el presupuesto que usan los productos, el
* recuento se paga con recall. Por eso va TRAS BANDERA y apagada: quien
* pregunte «cuántos ficheros» la quiere; quien no, está pagando por nada.
*
*
* ─────────────────────────────────────────────────────────────────────────────
* REFERENCIAS
*
* BM25 (Okapi BM25) — Robertson, Walker, Jones, Hancock-Beaulieu & Gatford,
* «Okapi at TREC-3», TREC-3, 1994. Formulacion moderna y justificacion de
* k1 / b: Robertson & Zaragoza, «The Probabilistic Relevance Framework:
* BM25 and Beyond», Foundations and Trends in IR 3(4), 2009.
* https://doi.org/10.1561/1500000019
*
* IDF — Sparck Jones, «A statistical interpretation of term specificity and
* its application in retrieval», Journal of Documentation 28(1), 1972.
* El origen de la idea que sostiene todo esto: lo raro informa.
*
* RRF (fusion por rangos reciprocos) — Cormack, Clarke & Buettcher,
* «Reciprocal Rank Fusion outperforms Condorcet and individual Rank
* Learning Methods», SIGIR 2009. https://doi.org/10.1145/1571941.1572114
*
* MMR (relevancia marginal maxima) — Carbonell & Goldstein, «The use of MMR,
* diversity-based reranking for reordering documents and producing
* summaries», SIGIR 1998. https://doi.org/10.1145/290941.291025
*
* Sumideros de atencion (por que la CABECERA se protege sin puntuar) —
* Xiao, Tian, Chen, Han & Lewis, «Efficient Streaming Language Models with
* Attention Sinks», ICLR 2024. https://arxiv.org/abs/2309.17453
*
* Contraste con lo que se publica hoy en compresion de KV, donde el
* solapamiento lexico aparece solo como bandera BINARIA sobre un ranking que
* sigue siendo de atencion: CodeComp, «Structural KV Cache Compression for
* Agentic Coding», 2026, §4.4. https://arxiv.org/abs/2604.10235
* -> nuestro margen medido frente a esa regla binaria: F1 23,59 vs 8,03;
* y quitar la IDF cuesta -3,25 F1, o sea que la gradacion hace trabajo.
*
* No usa APIs de Node → cargable en el navegador como módulo ES.
*/
// ── tokens ──────────────────────────────────────────────────────────────────
function estimateTokens(s) {
if (!s) return 0;
const pieces = (s.match(/\w+|[^\w\s]/g) || []).length;
return Math.max(Math.ceil(pieces * 1.25), Math.ceil(s.length / 4));
}
function truncateToTokens(s, maxTok) {
if (estimateTokens(s) <= maxTok) return s;
const keepChars = Math.max(20, Math.floor(maxTok * 4 * 0.5));
const head = s.slice(0, keepChars), tail = s.slice(-keepChars);
const cutTok = estimateTokens(s) - estimateTokens(head) - estimateTokens(tail);
return `${head} [...${cutTok}t cut...] ${tail}`;
}
// ── términos ────────────────────────────────────────────────────────────────
// Sin lista de parada: la IDF endógena (más abajo) se encarga. Se admiten tokens
// de 2 caracteres porque en código los identificadores cortos existen y a veces
// son exactamente lo que se busca (`fs`, `db`, `id`).
function terms(s) {
// Emite el token COMPUESTO y además sus PARTES.
//
// El tokenizador anterior estaba afinado para código y en diálogo perdía:
// · exigía empezar por letra → «3pm», «2nd», «5» desaparecían enteros, y en
// conversación eso son horas, ordinales y fechas;
// · mantenía `process.env.HOME` y `src/utils.js` como UN token, así que una
// pregunta por «env» no casaba con la línea que lo contiene.
//
// Medido en un banco de diálogo (LoCoMo, 200 preguntas, n=200): cambiar SOLO
// el tokenizador y dejar todo lo demás igual explicaba 3,5 de los 4,8 puntos
// que nos separaban de un BM25 con el partidor del banco. No era la política
// de empaquetado: era el partidor de palabras.
//
// La forma compuesta se conserva porque para código ES el identificador —
// `src/utils.js` casa exacto y puntúa alto — y las partes se añaden para que
// el emparejamiento parcial también funcione. Se pagan más términos por línea,
// pero BM25 normaliza por longitud, que para eso está la `b`.
const out = [];
const seen = new Set();
const push = (t) => { if (t.length >= 2 && !seen.has(t)) { seen.add(t); out.push(t); } };
for (const m of s.toLowerCase().match(/[a-z0-9_][\w./-]*/g) || []) {
push(m);
if (/[./-]/.test(m)) for (const part of m.split(/[./-]+/)) push(part);
}
return out;
}
/**
* Índice BM25 (Okapi BM25 — Robertson et al., TREC-3 1994; formulación moderna
* en Robertson & Zaragoza 2009) sobre un conjunto de documentos: aquí, las
* líneas del historial.
* La IDF sale del PROPIO corpus → los tokens ubicuos (`the`, `para`, `const`,
* la puntuación de formato) caen a peso ~0 sin que nadie los liste.
*/
function buildBM25(docs, opts) {
const K1 = (opts && opts.k1) != null ? opts.k1 : 1.2;
const B = (opts && opts.b) != null ? opts.b : 0.75;
const N = docs.length || 1;
const df = new Map();
const tfs = new Array(docs.length);
let totalLen = 0;
for (let i = 0; i < docs.length; i++) {
const t = terms(docs[i]);
const tf = new Map();
for (const x of t) tf.set(x, (tf.get(x) || 0) + 1);
tfs[i] = tf;
totalLen += t.length;
for (const x of tf.keys()) df.set(x, (df.get(x) || 0) + 1);
}
const avgdl = totalLen / N || 1;
// IDF de Robertson, con el suelo habitual para que un término presente en
// más de la mitad del corpus no reste.
const idf = (t) => {
const n = df.get(t) || 0;
return Math.max(Math.log(1 + (N - n + 0.5) / (n + 0.5)), 1e-6);
};
const scoreDoc = (i, queryTerms) => {
const tf = tfs[i]; if (!tf || !tf.size) return 0;
let dl = 0; for (const c of tf.values()) dl += c;
let s = 0;
for (const q of queryTerms) {
const f = tf.get(q); if (!f) continue;
s += idf(q) * (f * (K1 + 1)) / (f + K1 * (1 - B + B * dl / avgdl));
}
return s;
};
return { idf, scoreDoc, df, N, avgdl };
}
/**
* CADUCIDAD — el mismo problema que ya resolvimos en el vigilante de carpetas,
* un piso más arriba.
*
* Un agente lee `shell.js` en el turno 3 y lo EDITA en el 20. BM25 recupera las
* dos versiones y la vieja puntúa igual de alto, porque comparte todo el
* vocabulario con la nueva — de hecho puede ganarle. El modelo ve contenido
* obsoleto sin ninguna señal de que lo es, y eso no es ineficiencia: es
* INCORRECCIÓN. Ninguna cantidad de relevancia arregla que el dato sea falso.
*
* En el observador de carpetas ya identificábamos el contenido por «nombre +
* mtime», de modo que volver a guardar invalida lo anterior. Aquí la marca
* temporal es el turno, y el nombre es el objetivo de la llamada.
*
* Se DEGRADA, no se borra — pero con una salvedad medida que conviene no
* maquillar: preguntando EXPRESAMENTE por el valor anterior, la versión vieja
* vuelve 0 de 8 veces. Está en el conjunto y no se recupera nunca, porque una
* vez degradada compite entre cientos de líneas de puntuación cero y quién
* vuelve de ahí lo decide el pase de diversidad, no la pregunta. ALCANZABLE NO
* ES RECUPERABLE. La diferencia entre «esto ya no es cierto» y «esto no existió
* nunca» es real en la estructura de datos y todavía NO lo es en el
* comportamiento. Recuperar el historial a petición pide otra vía: que la
* pregunta señale temporalidad y se busque explícitamente en lo caducado.
*
* Devuelve un Set con los índices de mensaje cuyo resultado quedó superado.
*/
function markSuperseded(msgs, toolPrefixes) {
const lastFor = new Map(); // objetivo -> índice del resultado más nuevo
const targetOf = new Map(); // índice del resultado -> objetivo
for (let i = 0; i < msgs.length; i++) {
const c = msgs[i].content || '';
if (!toolPrefixes.some(p => c.startsWith(p))) continue;
// el objetivo viene en la llamada del asistente inmediatamente anterior:
// el resultado no lo lleva, solo dice qué herramienta fue.
const call = i > 0 ? (msgs[i - 1].content || '') : '';
const m = call.match(/"tool"\s*:\s*"([^"]+)"[\s\S]{0,200}?"(?:path|file|command)"\s*:\s*"([^"]+)"/);
if (!m) continue;
const key = m[1].split('.')[0] + ':' + m[2]; // p.ej. code:vcc/builder.js
targetOf.set(i, key);
lastFor.set(key, i);
}
const stale = new Set();
for (const [i, key] of targetOf) if (lastFor.get(key) !== i) stale.add(i);
return stale;
}
/**
* FECHAS ABSOLUTAS AL ESCRIBIR — la otra cara de la caducidad.
*
* «lo desplegamos ayer», escrito en el turno 3, es una MENTIRA en el turno 40.
* Y esto BM25 no puede arreglarlo jamás, porque no es un fallo de recuperación:
* la línea se recupera perfectamente y el dato que lleva es falso. Es el mismo
* fallo que markSuperseded — relevancia perfecta, contenido incorrecto — solo
* que aquí lo que caduca no es el fichero, es la palabra.
*
* Por eso se resuelve al ESCRIBIR (al indexar), no al leer: en el momento de
* indexar todavía se sabe cuándo se dijo. Un turno después ya no.
*
* · Se ANOTA junto al original, no se sustituye: «ayer (2026-08-07)».
* Reescribir lo que dijo el usuario es peor que anotarlo — si la resolución
* se equivoca, con la anotación el modelo todavía ve la frase original y
* puede desconfiar; con la sustitución, no.
*
* · La fecha de referencia es la DEL TURNO, no la de ahora: `m.ts` (o time /
* timestamp / date / createdAt). Si el mensaje no la lleva se usa `NOW`, y
* si tampoco hay `NOW` NO SE ANOTA. Inventar una fecha es exactamente el
* fallo que esto viene a quitar, así que el camino por defecto es callarse.
*
* · Precisión honesta: lo que el idioma dice con precisión de DÍA se anota con
* un día; lo que dice con precisión de semana o de mes se anota con el rango
* de la semana o con el mes. «hace tres semanas (2026-07-13…2026-07-19)» es
* verdad; «hace tres semanas (2026-07-16)» sería una precisión inventada.
*
* · ⚠️ NO se tocan bloques de código ni resultados de herramienta. Un
* `2026-08-07` dentro de un diff o de un log NO es una referencia temporal,
* y anotarlo sería corromper datos. Los `[resultado …]` se saltan enteros y
* dentro del texto se saltan las vallas ``` y los tramos entre acentos
* graves.
*
* Coste: un solo barrido de la expresión combinada por mensaje; si no hay
* ninguna referencia temporal —el caso normal, y siempre el de una página de
* código— se sale por ahí sin partir nada.
*/
const ISO_DAY = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const ISO_MONTH = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const shiftDays = (ref, n) => { const d = new Date(ref.getTime()); d.setDate(d.getDate() + n); return d; };
const shiftMonths = (ref, n) => { const d = new Date(ref.getTime()); d.setDate(1); d.setMonth(d.getMonth() + n); return d; };
// Semana de lunes a domingo (ISO-8601) que contiene `d`.
function weekRange(d) {
const start = shiftDays(d, -((d.getDay() + 6) % 7));
return `${ISO_DAY(start)}${ISO_DAY(shiftDays(start, 6))}`;
}
const WEEKDAY = { domingo: 0, lunes: 1, martes: 2, miércoles: 3, miercoles: 3, jueves: 4, viernes: 5, sábado: 6, sabado: 6,
sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
// dir < 0 = la anterior estricta · dir > 0 = la siguiente estricta · dir = 0 =
// la más reciente contando hoy. El bare («el lunes», sin «pasado» ni «que
// viene») usa dir = 0: es AMBIGUO en los dos idiomas, y en una bitácora de
// trabajo la enorme mayoría de las menciones son retrospectivas. Es la única
// regla de aquí que puede equivocarse, y por eso la frase original se conserva.
function weekdayNear(ref, target, dir) {
const cur = ref.getDay();
if (dir > 0) { const f = (target - cur + 7) % 7; return shiftDays(ref, f || 7); }
const b = (cur - target + 7) % 7;
return shiftDays(ref, -(dir < 0 ? (b || 7) : b));
}
const NUMWORD = { un: 1, una: 1, uno: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
const numOf = (s) => (/^\d+$/.test(s) ? parseInt(s, 10) : (NUMWORD[s.toLowerCase()] || null));
function shiftUnit(ref, n, unit, sign) {
if (n == null || n > 500) return null;
const u = unit.toLowerCase();
if (u[0] === 'd') return ISO_DAY(shiftDays(ref, sign * n)); // día(s) / day(s)
if (u[0] === 's' || u[0] === 'w') return weekRange(shiftDays(ref, sign * 7 * n)); // semana(s) / week(s)
if (u[0] === 'm') return ISO_MONTH(shiftMonths(ref, sign * n)); // mes(es) / month(s)
if (u[0] === 'a' || u[0] === 'y') return String(shiftMonths(ref, sign * 12 * n).getFullYear());
return null;
}
const N_ = '\\d{1,3}|un[ao]?|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|an?|one|two|three|four|five|six|seven|eight|nine|ten';
const U_ = 'd[ií]as?|semanas?|mes(?:es)?|años?|anos?|days?|weeks?|months?|years?';
const WD_ = 'lunes|martes|mi[ée]rcoles|jueves|viernes|s[áa]bado|domingo';
const WD_EN = 'monday|tuesday|wednesday|thursday|friday|saturday|sunday';
// Orden = precedencia: la alternancia de JS se queda con la PRIMERA que encaja
// en cada posición, así que lo específico va antes que lo corto.
const TIME_RULES = [
// «por la mañana» no es «mañana». Se reconoce a propósito para NO anotarla y,
// de paso, para que la regla de «mañana» no llegue a verla.
{ p: '\\b(?:por|de|a|en|desde|hasta)\\s+la\\s+mañana\\b|\\b(?:esta|una|cada|toda\\s+la|la)\\s+mañana\\b', f: () => null },
{ p: '\\bantes\\s+de\\s+ayer\\b|\\banteayer\\b|\\bthe\\s+day\\s+before\\s+yesterday\\b', f: (m, r) => ISO_DAY(shiftDays(r, -2)) },
{ p: '\\bpasado\\s+mañana\\b|\\bthe\\s+day\\s+after\\s+tomorrow\\b', f: (m, r) => ISO_DAY(shiftDays(r, 2)) },
{ p: `\\bhace\\s+(${N_})\\s+(${U_})\\b`, f: (m, r) => shiftUnit(r, numOf(m[1]), m[2], -1) },
{ p: `\\b(${N_})\\s+(${U_})\\s+ago\\b`, f: (m, r) => shiftUnit(r, numOf(m[1]), m[2], -1) },
{ p: `\\bdentro\\s+de\\s+(${N_})\\s+(${U_})\\b`, f: (m, r) => shiftUnit(r, numOf(m[1]), m[2], 1) },
{ p: `\\bin\\s+(${N_})\\s+(${U_})\\b`, f: (m, r) => shiftUnit(r, numOf(m[1]), m[2], 1) },
{ p: '\\b(?:la\\s+)?semana\\s+(pasada|anterior|que\\s+viene|pr[óo]xima)\\b',
f: (m, r) => weekRange(shiftDays(r, /pasada|anterior/i.test(m[1]) ? -7 : 7)) },
{ p: '\\b(last|next)\\s+week\\b', f: (m, r) => weekRange(shiftDays(r, /last/i.test(m[1]) ? -7 : 7)) },
{ p: '\\b(?:el\\s+)?mes\\s+(pasado|anterior|que\\s+viene|pr[óo]ximo)\\b',
f: (m, r) => ISO_MONTH(shiftMonths(r, /pasado|anterior/i.test(m[1]) ? -1 : 1)) },
{ p: '\\b(last|next)\\s+month\\b', f: (m, r) => ISO_MONTH(shiftMonths(r, /last/i.test(m[1]) ? -1 : 1)) },
{ p: `\\bel\\s+(${WD_})\\s+(pasado|que\\s+viene|pr[óo]ximo)\\b`,
f: (m, r) => ISO_DAY(weekdayNear(r, WEEKDAY[m[1].toLowerCase()], /pasado/i.test(m[2]) ? -1 : 1)) },
{ p: `\\b(last|next|this)\\s+(${WD_EN})\\b`,
f: (m, r) => ISO_DAY(weekdayNear(r, WEEKDAY[m[2].toLowerCase()], /last/i.test(m[1]) ? -1 : (/next/i.test(m[1]) ? 1 : 0))) },
// Sin modificador hace falta el artículo («el lunes») o la preposición inglesa
// («on Monday»): un «Monday» suelto puede ser un nombre propio o un fichero.
{ p: `\\bel\\s+(${WD_})\\b`, f: (m, r) => ISO_DAY(weekdayNear(r, WEEKDAY[m[1].toLowerCase()], 0)) },
{ p: `\\bon\\s+(${WD_EN})\\b`, f: (m, r) => ISO_DAY(weekdayNear(r, WEEKDAY[m[1].toLowerCase()], 0)) },
{ p: '\\banoche\\b|\\blast\\s+night\\b', f: (m, r) => ISO_DAY(shiftDays(r, -1)) },
{ p: '\\bayer\\b|\\byesterday\\b', f: (m, r) => ISO_DAY(shiftDays(r, -1)) },
{ p: '\\bhoy\\b|\\btoday\\b', f: (m, r) => ISO_DAY(r) },
{ p: '\\bmañana\\b|\\btomorrow\\b', f: (m, r) => ISO_DAY(shiftDays(r, 1)) },
];
const TIME_RE = new RegExp(TIME_RULES.map(r => `(?:${r.p})`).join('|'), 'gi');
const TIME_ONE = TIME_RULES.map(r => new RegExp(`^(?:${r.p})$`, 'i'));
// Vallas de código, tramos entre acentos graves y valla sin cerrar (un mensaje
// a medio llegar): todo eso es contenido literal y no se anota.
const CODE_SPAN = /```[\s\S]*?```|```[\s\S]*$|~~~[\s\S]*?~~~|`[^`\n]+`/g;
function annotateDates(text, refMs) {
if (!text) return text;
TIME_RE.lastIndex = 0;
if (!TIME_RE.test(text)) return text; // barrido único: el caso normal sale por aquí
const ref = new Date(refMs);
if (isNaN(ref.getTime())) return text;
const out = [];
let last = 0, m;
CODE_SPAN.lastIndex = 0;
while ((m = CODE_SPAN.exec(text))) {
out.push({ s: text.slice(last, m.index), code: false });
out.push({ s: m[0], code: true });
last = m.index + m[0].length;
}
out.push({ s: text.slice(last), code: false });
return out.map(seg => seg.code ? seg.s : seg.s.replace(TIME_RE, (hit, ...rest) => {
const whole = rest[rest.length - 1], at = rest[rest.length - 2];
// idempotente: si ya lleva la anotación detrás, no se anota otra vez
if (/^\s*\(\d{4}-\d{2}/.test(whole.slice(at + hit.length))) return hit;
// Segunda red bajo la de las vallas, por si el código llega sin valla:
// `today()`, `memory::today`, `hoy_str` o `a.ayer` son CÓDIGO. Barriendo a
// pelo las 25.724 líneas de código real de los cuatro proyectos (peor caso
// absoluto: sin valla y sin la guarda de resultados) disparaba 30 veces; con
// esta puerta, 25 — y las cinco que desaparecen son justo las llamadas y las
// rutas. Las 25 que quedan son prosa dentro de comentarios («run today»),
// que es lo que el anotador debe hacer. La segunda mitad de la puerta cubre
// `ayer.js` / `today.py`: una llamada a herramienta que llegue SIN valla
// lleva rutas, y anotar dentro de una ruta la rompe. Un punto seguido de
// espacio o de final de frase («lo hicimos ayer.») sí se anota.
if (/[.:_/\\]$/.test(whole.slice(0, at)) || /^[(_]|^\.\w/.test(whole.slice(at + hit.length))) return hit;
for (let i = 0; i < TIME_RULES.length; i++) {
const g = TIME_ONE[i].exec(hit);
if (!g) continue;
const v = TIME_RULES[i].f(g, ref);
return v ? `${hit} (${v})` : hit;
}
return hit;
})).join('');
}
function timeValue(v) {
if (v == null) return null;
if (typeof v === 'number') {
if (!Number.isFinite(v) || v <= 0) return null;
return v < 1e11 ? v * 1000 : v; // epoch en segundos o en milisegundos
}
if (typeof v.getTime === 'function') { const t = v.getTime(); return Number.isFinite(t) ? t : null; }
const t = Date.parse(v);
return Number.isFinite(t) ? t : null;
}
const msgTime = (m, O) => {
const own = timeValue(m.ts != null ? m.ts : m.time != null ? m.time : m.timestamp != null ? m.timestamp
: m.date != null ? m.date : m.createdAt);
return own != null ? own : timeValue(O.NOW);
};
/** Historial con las referencias temporales resueltas. Devuelve el MISMO array
* si no hubo nada que anotar, para no pagar copias en el caso normal. */
function datedHistory(history, O) {
if (!O.DATES) return history;
let touched = false;
const out = history.map(m => {
const c = m.content || '';
if (!c || O.TOOL_PREFIXES.some(p => c.startsWith(p))) return m; // salida de herramienta: intocable
const ref = msgTime(m, O);
if (ref == null) return m; // sin fecha conocida NO se inventa
const a = annotateDates(c, ref);
if (a === c) return m;
touched = true;
return { ...m, content: a };
});
return touched ? out : history;
}
/**
* AGREGACIÓN — lo que no está en ninguna línea.
*
* «¿Cuántos ficheros has tocado?», «lístame todo lo que cambiaste»: la respuesta
* no está repartida entre cuarenta líneas, es que NO EXISTE la línea que buscar.
* Ningún top-k la encuentra, y no por puntuar mal: por construcción. Es el
* segundo fallo que ninguna perilla de BM25 arregla, y como el de las fechas se
* resuelve al ESCRIBIR: contando según pasan los resultados.
*
* Contador y nada más — sin modelo, sin resumen generado, sin juicio sobre el
* contenido. Es un recuento, no una respuesta: por eso puede ir en el contexto
* sin que nadie tenga que fiarse de él más de lo que se fía de `wc -l`.
*
* El objetivo de cada llamada sale de la llamada del ASISTENTE (el resultado no
* lo lleva), igual que en markSuperseded. La familia se decide por el verbo del
* nombre de la herramienta, no por una lista de nombres: los dos productos que
* comparten este fichero tienen herramientas distintas (`code.read` / `fs.read`)
* y una lista cerrada envejecería con el primer producto nuevo.
*/
const LEDGER_EDIT = /^(?:write|edit|create|save|patch|append|delete|remove|rm|move|rename|copy)$/;
const LEDGER_READ = /^(?:read|view|open|cat|show)$/;
const LEDGER_RUN = /^(?:run|exec|shell|bash|cmd)$/;
const CALL_TARGET = /"tool"\s*:\s*"([^"]+)"[\s\S]{0,300}?"(?:path|file|filename|command|cmd)"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
const ERR_LINE = /^\s*(?:ERROR\b|Error:|error:|Traceback \(most recent call last\))/;
function buildLedger(msgs, O) {
const read = new Map(), edited = new Map(), ran = new Map(), errs = new Map();
let errN = 0;
for (let i = 0; i < msgs.length; i++) {
const c = msgs[i].content || '';
if (O.TOOL_PREFIXES.some(p => c.startsWith(p))) {
for (const line of c.split('\n')) {
if (!ERR_LINE.test(line)) continue;
errN++; errs.set(line.trim().slice(0, 90), i);
break; // se cuentan RESULTADOS que fallaron, no líneas de traza
}
continue;
}
if (msgs[i].role !== 'assistant') continue;
CALL_TARGET.lastIndex = 0;
let m;
while ((m = CALL_TARGET.exec(c))) {
const verb = m[1].split('.').pop().toLowerCase(), arg = m[2];
if (!arg) continue;
if (LEDGER_EDIT.test(verb)) edited.set(arg, i);
else if (LEDGER_READ.test(verb)) read.set(arg, i);
else if (LEDGER_RUN.test(verb)) ran.set(arg, i);
}
}
// Un fichero editado no vuelve a contarse como leído: «cuántos has tocado» no
// puede contar dos veces el mismo fichero.
for (const k of edited.keys()) read.delete(k);
return { read, edited, ran, errs, errN };
}
/**
* La tarjeta, acotada. Tres reglas, y las tres salen de medir:
*
* 1. El RECUENTO va siempre y es el total de verdad; la enumeración es una
* ayuda y se recorta. Un recuento truncado que parece completo («has
* tocado 8 ficheros» cuando fueron 200) es peor que no dar ninguno, así
* que el número y la lista van por separado y la lista dice «+N más».
* Cuando no cabe ni una entrada, la fila se queda en el número pelado: eso
* sigue siendo información, y barata.
*
* 2. El techo sale del PRESUPUESTO, no de una constante (SUMMARY_FRAC).
*
* 3. El recorte NO es un tope igual para todas las filas: se le quita sitio a
* la que más TOKENS gasta. Así se enumeran ENTERAS las categorías que caben
* —típicamente los ficheros editados, que son pocos y son justo lo que se
* pregunta— en vez de dejar todas a medias. Medido en la sonda de
* agregación (presupuesto 3.000, 32 sesiones, cobertura de los ficheros
* realmente editados), a IGUAL coste de tarjeta (~148 tokens):
* tope igual para todas ..... 67,7 %
* recorte por coste ......... 90,6 % ← esto
* Y el techo manda de verdad: con SUMMARY_FRAC 0,03 la cobertura baja a
* 57,3 %, por debajo del 58,9 % que ya había SIN tarjeta. Con una tarjeta
* demasiado apretada el recuento sale gratis pero la enumeración estorba.
*
* Dentro de cada fila se enumeran las entradas MÁS RECIENTES primero: si hay que
* recortar, lo que el agente acaba de tocar es lo que más probablemente le van a
* preguntar.
*/
function renderCard(L, maxTok) {
const rows = [['ficheros leídos', L.read], ['ficheros editados', L.edited],
['comandos', L.ran], ['errores', L.errs]];
const totals = [L.read.size, L.edited.size, L.ran.size, L.errN];
if (!totals.some(Boolean)) return null;
const byRecency = rows.map(([, map]) => [...map.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0]));
const caps = rows.map(() => 8);
const line = (i) => {
const items = byRecency[i].slice(0, caps[i]).map(s => s.length > 60 ? s.slice(0, 57) + '…' : s);
const rest = totals[i] - items.length;
return `${rows[i][0]} (${totals[i]})${items.length ? ': ' + items.join(' · ') : ''}` +
`${rest > 0 && items.length ? ` · +${rest} más` : ''}`;
};
const compose = () => ['[recuento de la sesión · automático]']
.concat(rows.map((r, i) => totals[i] ? line(i) : null).filter(Boolean)).join('\n');
let text = compose();
while (estimateTokens(text) > maxTok) {
// Se le quita a la fila que más TOKENS está gastando, no a la que más
// entradas tiene: dos rutas de error largas cuestan más que ocho nombres de
// fichero cortos, y recortar por número de entradas no lo ve.
let worst = -1, cost = 0;
rows.forEach((r, i) => {
if (!totals[i] || !caps[i]) return;
const c = estimateTokens(line(i));
if (c > cost) { cost = c; worst = i; }
});
if (worst < 0) break; // ya no queda enumeración que quitar
caps[worst] = Math.min(caps[worst], totals[worst]) - 1;
text = compose();
}
return { role: 'user', content: text };
}
// ── similitud (dedup + MMR + coseno semántico) ──────────────────────────────
function simTokens(s) { return new Set((s.toLowerCase().match(/[a-z0-9_./-]{2,}/g) || [])); }
function jaccard(a, b) {
if (!a.size || !b.size) return 0;
let inter = 0; const [small, big] = a.size <= b.size ? [a, b] : [b, a];
for (const t of small) if (big.has(t)) inter++;
return inter / (a.size + b.size - inter);
}
function cosine(a, b) {
if (!a || !b || a.length !== b.length) return 0;
let d = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return (na && nb) ? d / Math.sqrt(na * nb) : 0;
}
function dedupKey(s) {
// ⚠️ El número de línea NO se borra cuando es lo único que distingue dos
// líneas idénticas. Antes se quitaba siempre, y eso colapsaba la salida de
// `grep -n`: "10: return 42;" y "87: return 42;" caían en la misma clave y
// se quedaba UNA. Pero «¿dónde está definido X?» es la pregunta más común de
// un agente de código, y la respuesta ES el número: darle un sitio de tres es
// darle una respuesta incompleta que parece completa.
//
// Regla: se normaliza el espaciado (eso sí es ruido) pero la posición se
// conserva como parte de la identidad de la línea. Solo colapsan las líneas
// que son iguales TAMBIÉN en dónde estaban — repeticiones de verdad, como
// "}" o el encabezado "[resultado …]" saliendo veinte veces.
//
// Y el ESTILO del canalón sí es ruido: la misma línea del mismo fichero vista
// una vez por la herramienta de lectura (`42→foo`) y otra por una búsqueda
// (`42: foo`) es la MISMA línea, y debe colapsar. Por eso el número se extrae
// aparte y el cuerpo se compara ya sin canalón: se conserva DÓNDE estaba y se
// descarta CÓMO se imprimió.
const at = s.match(/^\s*(\d+)\s*[:→]/);
const body = (at ? s.slice(at[0].length) : s).replace(/\s+/g, ' ').trim().toLowerCase();
return at ? at[1] + '|' + body : body;
}
/**
* Fusión por rangos recíprocos (RRF).
* Entrada: varias listas de ids ORDENADAS de más a menos relevante.
* Salida: Map id → puntuación fusionada.
*
* Se fusionan POSICIONES, no puntuaciones, así que no hay que normalizar entre
* una escala BM25 y un coseno. Es lo que midió mejor (0,706 vs 0,685).
*/
function rrfFuse(rankings, k = 60) {
const out = new Map();
for (const list of rankings) {
for (let r = 0; r < list.length; r++) {
const id = list[r];
out.set(id, (out.get(id) || 0) + 1 / (k + r + 1));
}
}
return out;
}
const DEFAULTS = {
// — recuperación —
BM25_K1: 1.2,
BM25_B: 0.75,
RRF_K: 60,
SEMANTIC: true, // usar embeddings si se proporciona `embed`
SEM_BUDGET: 400, // nº máximo de bloques a codificar (acota el coste;
// el TAMAÑO de bloque sale de aquí, no al revés)
RECENCY_WEIGHT: 0.15, // prior suave, NO una dimensión de contenido
// — redundancia —
// MMR OFF by default — measured dead, twice over.
//
// It earned its place with +2.4 points. That was before the elastic window
// and before the word splitter was fixed. Re-measured on 2026-08-09 across
// 3 projects and 8 seeds: 94.9% with it and 94.9% without at a 3,000 budget,
// 100.0% and 100.0% at 16,000. It still changes the output in a third of
// requests and never once changes the outcome. On the dialogue benchmark it
// fires and decides nothing either.
//
// The reason is upstream: the elastic window only admits relevant material
// now, and the fixed splitter finds more of it, so a diversity penalty has
// nothing left to arbitrate. A mechanism that justified itself with a number,
// stopped justifying itself when something above it changed, and nobody
// re-checked. Kept behind the flag rather than deleted, because the day it
// earns its keep again the evidence should be a measurement, not a memory.
MMR: false,
MMR_LAMBDA: null, // null = medido del historial (ver autotune)
MMR_CAND: null, // null = derivado del presupuesto
DEDUP: true,
PIN_QUERY_TERMS: true, // lo que la pregunta nombra no se desaloja jamás
PIN_MAX: 40,
// — presupuesto —
SUPERSEDE: true, // degradar resultados caducados (ver markSuperseded)
ELASTIC: true, // no rellenar con lo irrelevante (ver selectAndEmit)
TAIL_MIN_FRAC: 0.05, // SUELO garantizado para los ultimos turnos
HEAD_FRAC: 0, // desactivada: ver nota en autotune
RECENT: 6,
RECENT_FRAC: null, // null = derivado de la presión de compresión
MAX_MSG_CHARS: 12000,
PER_LINE_CAP_FRAC: 0.5,
TOOL_PREFIXES: ['[resultado', '[Tool result]:'],
// — lo que no es recuperación —
DATES: true, // anotar «ayer» con la fecha del turno (ver annotateDates)
NOW: null, // fecha de respaldo para los mensajes SIN marca propia;
// sin ella no se anota nada — no se inventa una fecha
SUMMARY: false, // tarjeta de recuento (ver renderCard). TRAS BANDERA y
// apagada: medida, cuesta presupuesto de verdad
SUMMARY_FRAC: 0.05, // techo de la tarjeta como fracción del presupuesto:
// por debajo de 0,05 el recuento sigue saliendo pero
// la enumeración deja de compensar (ver renderCard)
// — adaptación —
AUTO: true, // derivar las perillas de lo medido en ESTE historial
AUTO_MIN_PRESSURE: 0.35,
AUTO_MAX_PRESSURE: 0.80,
AUTO_RECENCY_PRESSURE: 0.25, // por debajo de esto, la recencia desempata
};
/**
* AUTOAJUSTE — las perillas salen de lo que se mide, no de constantes.
*
* Un λ=0.5 fijo o un RECENT_FRAC=0.55 fijo son magia escrita a mano: la misma
* enfermedad que las heurísticas, un piso más arriba. Aquí cada perilla se
* deriva de una propiedad observable del historial concreto que toca empaquetar.
*
* Importa sobre todo en CONTEXTO LARGO, que es el caso real: la presión de
* compresión de una sesión de 200k tokens contra un presupuesto de 32k no se
* parece en nada a la de una de 5k contra 3k, y la misma constante no puede
* servir para las dos.
*/
function autotune(historyTok, budgetTok, O) {
// Presión: qué fracción del historial cabe. 1 = cabe entero, →0 = agobio.
const pressure = Math.max(0, Math.min(1, budgetTok / (historyTok || 1)));
// Reserva de recientes. Con el presupuesto holgado, conservar los últimos
// turnos literales sale barato. Con agobio hay que dejarle sitio a la
// BÚSQUEDA: si los recientes se comen el presupuesto, no queda hueco para
// traer la línea de hace treinta turnos que es justo la que se pregunta.
const recentFrac = O.RECENT_FRAC != null ? O.RECENT_FRAC
: O.AUTO_MIN_PRESSURE + (O.AUTO_MAX_PRESSURE - O.AUTO_MIN_PRESSURE) * pressure;
// RECENCIA — medida en un barrido de presupuestos:
//
// contexto/presupuesto con recencia sin recencia
// 15,8× 67,9 % 59,3 % ← ayuda +8,6
// 3,0× 75,7 % 83,6 % ← ESTORBA −7,9
// 1,5× 91,8 % 94,6 % ← ESTORBA −2,8
//
// Escalar el peso con la presión NO sirve: medido, con 0.048 o con 0.15 se
// decide exactamente lo mismo, y solo cambia algo al ponerlo a cero. Como
// sumando, la recencia es binaria — cualquier valor > 0 basta para que una
// línea nueva e irrelevante desplace a una relevante y antigua.
//
// La regla correcta no es un peso: es un DESEMPATE. Una línea con relevancia
// gana SIEMPRE a una sin ella; entre las relevantes manda BM25; entre las que
// no lo son —que con agobio son casi todas, y por eso ahí ayudaba— manda la
// recencia. Una sola regla, sin perilla que calibrar, y sirve en los dos
// extremos del barrido.
// Y el barrido dice algo más fino todavía: como DESEMPATE la recencia suma
// +2,9 con agobio (15,8×) pero RESTA −7,9 con holgura (3,0×). Con sitio de
// sobra, el orden del documento gana — mantiene juntos los tramos, y un
// fragmento contiguo vale más que líneas nuevas sueltas. Con agobio no hay
// tramos que mantener y lo nuevo es la única apuesta que queda.
//
// Así que no es un peso ni un desempate universal: es un INTERRUPTOR, y el
// barrido dice dónde va. Umbral medido entre 0,17 (neutral) y 0,34 (dañino).
const recencyTiebreak = pressure < O.AUTO_RECENCY_PRESSURE;
// La cabecera va SIEMPRE, sin puerta. Antes se apagaba con holgura porque
// "ahí el encargo sobrevive solo" — y era cierto, pero solo porque
// rellenábamos el presupuesto hasta el borde con todo lo que cupiera. Al
// añadir la ventana elástica y dejar de rellenar, el encargo se cayó del
// 100 % al 0 % a presupuesto 16.000. Su supervivencia era un accidente del
// relleno, no una propiedad de tener sitio. Cuesta un 5 % y ya está medido
// que no resta: incondicional.
// CABECERA DESACTIVADA (decisión del usuario, 2026-08-09).
// Medida, ayudaba en sesiones de agente: el encargo original pasaba de 0/5 a
// 5/5 con presupuesto apretado, porque el arranque lleva la tarea y nadie la
// repite, así que BM25 no puede rescatarla. Pero en DIÁLOGO no hay encargo
// que proteger y la reserva cobra sin dar nada: en LoCoMo el motor con ella
// recupera 56,2 % de la evidencia donde BM25 pelado recupera 62,5 %, con los
// mismos tokens. Una reserva incondicional para algo que puede no existir es
// la misma enfermedad que las constantes: presupone en vez de medir.
// Se deja el mecanismo, con HEAD_FRAC a 0. Ponerlo a 0.05 lo reactiva.
const headOn = O.HEAD_FRAC > 0;
const recencyWeight = O.RECENCY_WEIGHT * (1 - pressure); // solo informativo
return { pressure, recentFrac, recencyWeight, recencyTiebreak, headOn };
}
/**
* λ de MMR medido, no supuesto: se estima la redundancia REAL del material.
* Un historial de resultados de herramienta casi idénticos necesita penalizar
* fuerte; una conversación donde cada línea es distinta, casi nada — y ahí un
* λ alto solo destruye información buena.
*/
function measureRedundancy(items, sample = 240) {
const n = items.length;
if (n < 4) return 0;
const step = Math.max(1, Math.floor(n / sample));
const picked = [];
for (let i = 0; i < n; i += step) picked.push(simTokens(items[i].line));
let sum = 0, pairs = 0;
for (let i = 0; i < picked.length; i++) {
for (let j = i + 1; j < Math.min(i + 8, picked.length); j++) { sum += jaccard(picked[i], picked[j]); pairs++; }
}
return pairs ? sum / pairs : 0;
}
function clampMsg(m, maxChars) {
const c = m.content || '';
if (c.length <= maxChars) return m;
const head = Math.floor(maxChars * 0.7);
const tail = maxChars - head - 40;
return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - maxChars} caracteres] …\n` + c.slice(-tail) };
}
/**
* Caché de embeddings por contenido — "pensar al escribir".
* Una línea vista una vez no se vuelve a codificar en toda la sesión, ni aunque
* reaparezca veinte turnos después. Sin esto el coste crece con el cuadrado de
* los turnos, que es exactamente el problema que tenía la v1 al recalcular la
* IDF sobre todo el historial en cada mensaje.
*/
function createEmbedCache(embed, opts) {
const max = (opts && opts.max) || 4000;
const store = new Map();
return {
size: () => store.size,
async encode(texts) {
const miss = [];
for (const t of texts) if (!store.has(t)) miss.push(t);
if (miss.length) {
const vecs = await embed(miss);
for (let i = 0; i < miss.length; i++) store.set(miss[i], vecs[i]);
while (store.size > max) store.delete(store.keys().next().value);
}
return texts.map(t => store.get(t));
},
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Núcleo compartido: prepara el estado común a la vía léxica y a la híbrida.
// ─────────────────────────────────────────────────────────────────────────────
function prepare(history, budgetTok, O) {
const msgTok = (m) => estimateTokens(m.content) + 4;
// Las dos cosas que se resuelven al ESCRIBIR van antes que nada, porque
// cambian el material que se va a puntuar y lo que va a caber:
// · las fechas relativas se anotan sobre el historial (y pesan un poco más);
// · la tarjeta de recuento se cobra del presupuesto ANTES de repartirlo, que
// es lo que la hace incondicional sin romper el contrato de tokens.
history = datedHistory(history, O);
const card = O.SUMMARY
? renderCard(buildLedger(history, O), Math.max(24, Math.floor(budgetTok * O.SUMMARY_FRAC)))
: null;
const cardTok = card ? estimateTokens(card.content) + 4 : 0;
budgetTok = Math.max(1, budgetTok - cardTok);
// La reserva de recientes sale de la PRESIÓN de compresión medida en este
// historial, no de una constante. En contexto largo esto es lo que decide:
// con 200k de historial y 32k de presupuesto, gastar el 55 % en los últimos
// seis turnos deja sin sitio a la búsqueda, que es justo lo que hace falta
// cuando la respuesta está a treinta turnos de distancia.
const historyTok = history.reduce((s, m) => s + msgTok(m), 0);
const tuned = O.AUTO ? autotune(historyTok, budgetTok, O)
: { pressure: null,
recentFrac: O.RECENT_FRAC != null ? O.RECENT_FRAC : 0.55,
recencyWeight: O.RECENCY_WEIGHT,
recencyTiebreak: O.RECENCY_WEIGHT > 0,
headOn: O.HEAD_FRAC > 0 };
O = { ...O, _pressure: tuned.pressure, _historyTok: historyTok,
RECENCY_WEIGHT: tuned.recencyWeight, _recTie: tuned.recencyTiebreak };
// Los turnos recientes se conservan literales, pero SOLO mientras quepan en su
// reserva. Un RECENT fijo puede comerse el presupuesto entero él solo (una
// página de 100 líneas son ~1,5k tokens) y devolver varias veces lo pedido.
// ── RESERVA DE COLA — es un SUELO, no solo un techo ────────────────────────
// La version anterior paraba en el primer mensaje que no cabia. Eso convierte
// la reserva en un tope y no en una garantia: medido, con presupuesto 3.000 la
// cola se quedaba con el 3-5 % en vez del ~38 % reservado, porque UN resultado
// de herramienta grande en la penultima posicion bloqueaba todo lo anterior.
// Los ultimos turnos son lo que el modelo necesita si o si para saber donde
// esta, y eso no puede depender de que el turno de antes fuera voluminoso.
//
// Ahora, mientras no se alcance el suelo, el mensaje que no cabe se TRUNCA por
// el medio (cabeza y cola, que es lo que importa de un resultado) en vez de
// descartarse entero. Por encima del suelo se vuelve al comportamiento de
// siempre: se para y el resto compite por relevancia.
const reserve = Math.floor(budgetTok * tuned.recentFrac);
const floorTok = Math.floor(budgetTok * O.TAIL_MIN_FRAC);
const recent = []; let used = 0;
for (let i = history.length - 1, k = 0; i >= 0 && k < O.RECENT; i--, k++) {
const m = clampMsg(history[i], O.MAX_MSG_CHARS);
const t = msgTok(m);
if (used + t <= reserve) { recent.unshift(m); used += t; continue; }
if (used >= floorTok || recent.length >= 1 && used + t > reserve && used >= floorTok) break;
// aun por debajo del suelo: cabe recortado, no se tira
const room = Math.max(40, Math.min(reserve, floorTok) - used - 4);
if (room < 40) break;
recent.unshift({ ...m, content: truncateToTokens(m.content, room) });
used += room + 4;
if (used >= floorTok) break;
}
let old = history.slice(0, history.length - recent.length);
if (!old.length || used >= budgetTok) return { done: true, recent, old, used, head: [], card, cardTok, budget: budgetTok };
// ── RESERVA DE CABECERA ────────────────────────────────────────────────────
// Los PRIMEROS mensajes se conservan literales y SIN puntuar, igual que los
// últimos. No es simetría estética: el arranque lleva la tarea, el encargo,
// las rutas y las restricciones — cosas que el resto de la sesión da por
// sabidas y que por eso mismo BM25 no tiene por qué puntuar alto (si nadie
// las repite, no hay solape con la pregunta de ahora).
//
// Y hay respaldo externo: es el resultado central de StreamingLLM — los
// primeros tokens actúan de SUMIDERO de atención y absorben el 45-55 % de la
// masa; tirarlos degrada al modelo mucho más de lo que su "relevancia"
// sugiere. Un recuperador puro no puede ver eso, porque no es una propiedad
// del texto sino de cómo el modelo lo usa.
//
// Por eso va sin condición y acotado: ~10 % del presupuesto. Barato de sobra
// si sirve, y con un techo duro para que no compita con la búsqueda.
// Y va con la MISMA puerta que la recencia, porque mide lo mismo. Sonda que
// pregunta por el encargo original a mitad de sesión, 5 semillas:
//
// presupuesto 3.000 (el que usa la app): sin reserva 0/5 · con 5 % 5/5
// presupuesto 16.000 (holgura): sin reserva 5/5 · con 5 % 5/5
//
// Con agobio el encargo se pierde SIEMPRE, y es lo único que el agente no
// puede reconstruir mirando el código. Con holgura sobrevive solo, así que
// ahí la reserva es coste puro: medida en el banco de hechos de media sesión
// costaba −15,1 puntos a 16.000 sin ganar nada. Por eso se apaga.
const headReserve = tuned.headOn ? Math.floor(budgetTok * O.HEAD_FRAC) : 0;
const head = []; let headUsed = 0;
for (let i = 0; i < old.length; i++) {
const m = clampMsg(old[i], O.MAX_MSG_CHARS);
const t = msgTok(m);
if (headUsed + t > headReserve) break;
head.push(m); headUsed += t;
}
old = old.slice(head.length);
used += headUsed;
if (!old.length || used >= budgetTok) return { done: true, recent, old, used, head, card, cardTok, budget: budgetTok };
// La PREGUNTA VIVA: el último turno de usuario que no sea un resultado de
// herramienta. Esto es lo que se puntúa. No la tarea inicial.
let query = '';
for (let i = history.length - 1; i >= 0; i--) {
const m = history[i];
if (m.role === 'user' && !O.TOOL_PREFIXES.some(p => m.content.startsWith(p))) { query = m.content; break; }
}
const stale = O.SUPERSEDE ? markSuperseded(old, O.TOOL_PREFIXES) : new Set();
const items = [];
old.forEach((m, mi) => {
const lines = m.content.split('\n');
lines.forEach((line, li) => {
items.push({ mi, li, line, first: li === 0, stale: stale.has(mi) });
});
});
// Corpus del índice = las propias líneas. La IDF sale de aquí: endógena.
const bm25 = buildBM25(items.map(it => it.line), { k1: O.BM25_K1, b: O.BM25_B });
const qTerms = [...new Set(terms(query))];
const nMsg = Math.max(old.length - 1, 1);
for (let i = 0; i < items.length; i++) {
items[i].bm = bm25.scoreDoc(i, qTerms);
items[i].rec = items[i].mi / nMsg;
// El pin NO puede ignorar la caducidad: la versión vieja lleva el mismo
// identificador que la nueva, así que se fijaba sola y el degradado no
// servía de nada. Que la pregunta nombre algo no lo vuelve cierto.
items[i].qHit = items[i].bm > 0 && !items[i].stale;
}
return { done: false, recent, old, used, items, query, qTerms, bm25, nMsg, O, head, card, cardTok, budget: budgetTok };
}
/** Aplica dedup, selección con presupuesto global, MMR y emisión. */
function selectAndEmit(ctx, budgetTok, O) {
const { recent, old, items } = ctx;
const head = ctx.head || [];
let used = ctx.used;
// dedup exacto / casi exacto en TODO el historial
// El dedup ve TAMBIÉN la cabecera: lo que ya viaja literal ahí no se vuelve a
// pagar más abajo. Reservar sitio y luego repetir el mismo contenido sería
// gastar dos veces el mismo presupuesto.
let deduped = 0;
if (O.DEDUP) {
const bestByKey = new Map();
for (const m of head) for (const line of (m.content || '').split('\n')) {
const k = dedupKey(line);
if (k) bestByKey.set(k, { score: Infinity, dup: false });
}
for (const it of items) {
if (it.first) continue;
const k = dedupKey(it.line);
if (!k) continue;
const prev = bestByKey.get(k);
if (prev === undefined) { bestByKey.set(k, it); continue; }
const loser = it.score > prev.score ? prev : it;
const winner = it.score > prev.score ? it : prev;
loser.dup = true; deduped++;
bestByKey.set(k, winner);
}
}
const perLineCap = Math.max(40, Math.floor((budgetTok - used) * O.PER_LINE_CAP_FRAC));
const keep = new Set();
const cost = (it) => Math.min(estimateTokens(it.line), perLineCap) + 1;
const idOf = (it) => it.mi * 100000 + it.li;
// Lo que la pregunta nombra explícitamente nunca se desaloja. Acotado por
// PIN_MAX para que no se coma el presupuesto.
let pinned = 0;
if (O.PIN_QUERY_TERMS) {
const cands = items.filter(it => it.qHit && !it.dup).sort((a, b) => b.score - a.score).slice(0, O.PIN_MAX);
for (const it of cands) {
const c = cost(it);
if (used + c > budgetTok) break;
keep.add(idOf(it)); used += c; pinned++; it.pin = true;
}
}
const pool = items.filter(it => !it.dup && !keep.has(idOf(it)));
pool.sort((a, b) => b.score - a.score);
// λ MEDIDO, no supuesto: si el material es muy redundante (páginas de
// resultados casi idénticas) hay que penalizar fuerte; si cada línea es
// distinta, penalizar apenas — ahí un λ alto solo tira información buena.
// MMR_CAND se escala con lo que cabe: en contexto largo, 600 candidatos
// fijos dejaban fuera la mayor parte del material antes de mirarlo.
const lambda = O.MMR_LAMBDA != null ? O.MMR_LAMBDA
: Math.max(0.15, Math.min(0.8, 2 * measureRedundancy(pool)));
const cands = O.MMR_CAND != null ? O.MMR_CAND
: Math.max(400, Math.min(8000, Math.round(budgetTok / 6)));
if (O.MMR && lambda > 0) {
const cand = pool.slice(0, cands);
const tok = new Map(); for (const it of cand) tok.set(idOf(it), simTokens(it.line));
const maxSim = new Map(cand.map(it => [idOf(it), 0]));
for (const it of items) if (keep.has(idOf(it))) {
const ks = simTokens(it.line);
for (const c of cand) maxSim.set(idOf(c), Math.max(maxSim.get(idOf(c)), jaccard(tok.get(idOf(c)), ks)));
}
const remaining = new Set(cand.map(idOf));
const byId = new Map(cand.map(it => [idOf(it), it]));
while (remaining.size) {
let best = null, bestVal = -Infinity;
for (const id of remaining) {
const v = byId.get(id).score - lambda * maxSim.get(id);
if (v > bestVal) { bestVal = v; best = id; }
}
remaining.delete(best);
const it = byId.get(best), c = cost(it);
// La ventana elástica manda TAMBIÉN aquí. El MMR ordena por diversidad
// DENTRO de lo relevante; no es una excusa para colar relleno. Sin esta
// línea la puerta no servía de nada: el material sin relevancia entraba
// por este bucle antes de llegar al relleno final, y la medida salía
// idéntica con y sin ella.
if (O.ELASTIC && it.score < 10) continue;
if (used + c > budgetTok) continue;
keep.add(best); used += c;
const bs = tok.get(best);
for (const id of remaining) maxSim.set(id, Math.max(maxSim.get(id), jaccard(tok.get(id), bs)));
if (used >= budgetTok) break;
}
}
// ── VENTANA ELÁSTICA ───────────────────────────────────────────────────────
// El presupuesto es un TECHO, no una cuota que haya que agotar. Medido, sin
// esto se iba en líneas sin una sola palabra en común con la pregunta el
// 38 % del presupuesto a 3.000 y el 56 % a 16.000 — más de la mitad, relleno.
//
// Y eso no es inofensivo: nuestro propio resultado dice que recuperar bien
// BATE al contexto completo (F1 28,09 contra 22,56 del historial entero), o
// sea que lo irrelevante no es lastre neutro, DISTRAE. Rellenar hasta el
// borde con material de relevancia cero es reintroducir a mano justo aquello
// que la compresión venía a quitar.
//
// Así que la ventana se dimensiona con la EVIDENCIA: se para cuando se acaba
// lo relevante (estrato ≥ 10) en vez de cuando se acaban los tokens. Es el
// umbral de corte por puntuación de toda la vida en recuperación; la cabecera
// y la cola siguen siendo incondicionales, que para eso son reservas.
//
// ⚠️ NO es un regalo, es un INTERCAMBIO, y el banco solo ve un lado. Medido
// (8 semillas, presencia del dato — NO calidad de la respuesta):
//
// presupuesto ahorro de tokens coste en hechos
// 3.000 −1 % ±0,0 ← régimen de la app
// 8.000 2 % ±0,0
// 16.000 26 % −1,8
// 32.000 60 % −8,9
//
// Con el presupuesto que usan los productos (3.000 y 5.000) sale GRATIS: ahí
// hay más material relevante que sitio, y la puerta no llega a dispararse.
// Con holgura canja recall por tokens, y si eso compensa depende de algo que
// ESTE banco no puede ver: mide si el dato está presente, no si la respuesta
// sale mejor. La hipótesis a favor es nuestro propio titular —recuperar bien
// BATE al contexto completo, luego lo irrelevante resta— pero mientras no se
// corra el banco de F1 con un modelo respondiendo, el −8,9 a 32.000 es un
// número real y la ganancia es una conjetura. Queda dicho, no disimulado.
const RELEVANT = 10;
for (const it of pool) {
const id = idOf(it);
if (keep.has(id)) continue;
if (O.ELASTIC && it.score < RELEVANT) break; // se acabó la evidencia
const c = cost(it);
if (used + c > budgetTok) continue;
keep.add(id); used += c;
if (used >= budgetTok) break;
}
// Contrato de presupuesto: el coste por línea ignora la sobrecarga por mensaje
// y los marcadores de omisión, así que se mide el tamaño realmente emitido y se
// devuelven las líneas peor puntuadas hasta que la salida cabe de verdad.
const msgTok = (m) => estimateTokens(m.content) + 4;
const emit = () => {
let t = recent.reduce((s, m) => s + msgTok(m), 0) + head.reduce((s, m) => s + msgTok(m), 0);
let open = 0;
old.forEach((m, mi) => {
const lines = m.content.split('\n');
let any = false, run = 0, sub = 0;
lines.forEach((line, li) => {
if (keep.has(mi * 100000 + li)) {
if (run) { sub += 8; run = 0; }
sub += Math.min(estimateTokens(line), perLineCap); any = true;
} else run++;
});
if (any) { if (run) sub += 8; t += sub + 4; open++; }
});
if (open < old.length) t += 12;
return t;
};
let realized = emit();
if (realized > budgetTok) {
const kept = items.filter(it => keep.has(idOf(it)) && !it.pin).sort((a, b) => a.score - b.score);
let p = 0;
while (realized > budgetTok && p < kept.length) {
const over = realized - budgetTok; let freed = 0;
while (p < kept.length && freed < over) {
const it = kept[p++];
keep.delete(idOf(it));
freed += Math.min(estimateTokens(it.line), perLineCap);
}
realized = emit();
}
}
const packed = [];
let droppedMsgs = 0;
old.forEach((m, mi) => {
const lines = m.content.split('\n');
const out = []; let skipped = 0;
lines.forEach((line, li) => {
if (keep.has(mi * 100000 + li)) {
if (skipped) { out.push(` […${skipped} líneas omitidas…]`); skipped = 0; }
out.push(truncateToTokens(line, perLineCap));
} else skipped++;
});
if (skipped && out.length) out.push(` […${skipped} líneas omitidas…]`);
if (!out.length) { droppedMsgs++; return; }
packed.push({ role: m.role, content: out.join('\n') });
});
if (droppedMsgs) packed.push({ role: 'user', content: `[…${droppedMsgs} mensajes antiguos omitidos…]` });
// La tarjeta va PROTEGIDA, como la cabecera y la cola, y por el mismo motivo
// que ellas: no compite por relevancia porque no puede ganar. Es un recuento,
// no comparte vocabulario con casi nada, y BM25 la tiraría siempre — que es
// exactamente el fallo que viene a tapar. Va pegada a los últimos turnos,
// junto a la pregunta viva, no al principio.
const card = ctx.card ? [ctx.card] : [];
return { messages: [...head, ...packed, ...card, ...recent],
stats: { used, deduped, pinned, droppedMsgs, realized, head: head.length, card: ctx.cardTok || 0 } };
}
/**
* Empaqueta el historial en `budgetTok` tokens — vía LÉXICA (síncrona).
* BM25 contra la pregunta viva, IDF endógena, sin heurísticas.
* Medido: 0,598 de recall de evidencia (v1 con heurísticas: 0,020).
*/
function packHistoryACER(history, budgetTok, options) {
const O = { ...DEFAULTS, ...(options || {}) };
if (!history.length) return { messages: history, stats: {} };
const ctx = prepare(history, budgetTok, O);
if (ctx.done) return { messages: [...(ctx.head || []), ...(ctx.card ? [ctx.card] : []), ...ctx.recent],
stats: { used: ctx.used, dropped: ctx.old.length, head: (ctx.head || []).length, card: ctx.cardTok || 0 } };
// Normalización a rango para poder mezclar con la recencia sin que BM25,
// que no está acotado, se lleve todo por delante.
const Oa = ctx.O || O; // perillas ya autoajustadas
const maxBm = ctx.items.reduce((m, it) => Math.max(m, it.bm), 0) || 1;
for (const it of ctx.items) {
// Dos estratos separados por 10 — más de lo que la penalización MMR (≤0.8)
// puede recorrer, así que la diversidad reordena DENTRO de un estrato pero
// nunca cuela una línea irrelevante por delante de una relevante.
// Degradado, no borrado: baja del estrato relevante al de relleno, así que
// solo entra si no hay nada mejor — y con la ventana elástica, casi nunca.
it.score = (it.bm > 0 && !it.stale) ? 10 + it.bm / maxBm : (Oa._recTie ? it.rec : 0);
if (it.first) it.score = Math.max(it.score, 0.5); // cabecera de procedencia
}
const r = selectAndEmit(ctx, ctx.budget, Oa);
r.stats.mode = 'lexical';
return r;
}
/**
* Empaqueta el historial — vía HÍBRIDA (asíncrona).
* BM25 + embeddings fusionados por RANGOS. Medido: 0,706.
*
* `embed` recibe un array de textos y devuelve un array de vectores. Si no se
* pasa, esto degrada a `packHistoryACER` sin avisar y sin romper nada.
* `cache` (opcional, de createEmbedCache) hace que cada texto se codifique una
* sola vez en toda la sesión.
*/
async function packHistoryACERHybrid(history, budgetTok, options) {
const O = { ...DEFAULTS, ...(options || {}) };
const embed = O.embed;
if (!history.length) return { messages: history, stats: {} };
if (!embed || !O.SEMANTIC) return packHistoryACER(history, budgetTok, options);
const ctx = prepare(history, budgetTok, O);
if (ctx.done) return { messages: [...(ctx.head || []), ...(ctx.card ? [ctx.card] : []), ...ctx.recent],
stats: { used: ctx.used, dropped: ctx.old.length, head: (ctx.head || []).length, card: ctx.cardTok || 0 } };
// Lado semántico por BLOQUES: codificar línea a línea es prohibitivo y la
// señal sobrevive al troceado (medido ~73 % al pasar a frontera arbitraria).
// El TAMAÑO de bloque sale del presupuesto de codificación, no al revés: se
// acota el número de llamadas al modelo de embeddings y el bloque crece con
// el historial. Con contexto largo, un bloque fijo de 5 líneas dispararía
// miles de codificaciones por turno.
const SB = Math.max(1, Math.ceil(ctx.items.length / Math.max(1, O.SEM_BUDGET)));
const blocks = [];
for (let i = 0; i < ctx.items.length; i += SB) {
blocks.push({ from: i, text: ctx.items.slice(i, i + SB).map(x => x.line).join('\n').slice(0, 4000) });
}
let semOk = false;
try {
const cache = O.cache || createEmbedCache(embed);
const [qv] = await cache.encode([ctx.query.slice(0, 2000)]);
const bv = await cache.encode(blocks.map(b => b.text));
for (let b = 0; b < blocks.length; b++) {
const s = cosine(qv, bv[b]);
for (let i = blocks[b].from; i < Math.min(blocks[b].from + SB, ctx.items.length); i++) {
ctx.items[i].sem = s;
}
}
semOk = true;
} catch (e) {
// Si los embeddings fallan (modelo no cargado, sin red, cuota), NO se cae:
// se sigue por la vía léxica, que ya es 30× mejor que la v1.
semOk = false;
}
if (!semOk) return packHistoryACER(history, budgetTok, options);
// Fusión por rangos: dos listas ordenadas, se fusionan POSICIONES.
const idOf = (it) => it.mi * 100000 + it.li;
const byLex = [...ctx.items].sort((a, b) => b.bm - a.bm).map(idOf);
const bySem = [...ctx.items].sort((a, b) => (b.sem || 0) - (a.sem || 0)).map(idOf);
const fused = rrfFuse([byLex, bySem], O.RRF_K);
const maxF = Math.max(...fused.values()) || 1;
const Oa = ctx.O || O; // perillas ya autoajustadas
for (const it of ctx.items) {
const rel = !it.stale && ((it.bm > 0) || ((it.sem || 0) > 0));
it.score = rel ? 10 + (fused.get(idOf(it)) || 0) / maxF : (Oa._recTie ? it.rec : 0);
if (it.first) it.score = Math.max(it.score, 0.5);
}
const r = selectAndEmit(ctx, ctx.budget, Oa);
r.stats.mode = 'hybrid';
r.stats.blocks = blocks.length;
return r;
}
/**
* @deprecated Reglas escritas a mano de la v1. Ya NO participan en la puntuación:
* medido, hacían que el motor rindiera por debajo de no comprimir nada. Se
* mantiene exportada solo para no romper importaciones antiguas.
*/
function classifyLine(s) {
const sl = (s || '').toLowerCase();
if (!s || !s.trim()) return 0.0;
if (['error', 'failed', 'traceback', 'exception'].some(p => sl.includes(p))) return 0.95;
return 0.5;
}
/** @deprecated Sustituida por buildBM25 (IDF endógena, saturación y normalización). */
function makeIdf(docs) {
const b = buildBM25(docs);
return b.idf;
}
export {
packHistoryACER, packHistoryACERHybrid, DEFAULTS,
estimateTokens, truncateToTokens,
terms, buildBM25, rrfFuse, cosine, createEmbedCache,
simTokens, jaccard, dedupKey, clampMsg,
annotateDates, buildLedger, renderCard,
classifyLine, makeIdf,
};