One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
1348 lines
70 KiB
JavaScript
1348 lines
70 KiB
JavaScript
/**
|
||
* 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, opciones) {
|
||
// 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`.
|
||
//
|
||
// CUALQUIER ALFABETO. Hasta 2026-09-13 se partía con `\w`, que en JavaScript
|
||
// es SOLO ASCII: «alérgica» salía [al, rgica] y «mañana» [ma, ana] —trozos que
|
||
// casan con palabras que no tienen nada que ver— y el ucraniano o el ruso no
|
||
// daban ni un término, así que ese historial se empaquetaba a ciegas. Los
|
||
// bancos no lo vieron porque son sesiones en inglés. Ahora se parte por letras
|
||
// y números de cualquier alfabeto.
|
||
//
|
||
// Las tildes NO se pliegan, aunque parezca lo natural. Plegadas, «qué», «está»
|
||
// y «tenía» pasan a ser «que», «esta» y «tenia», las palabras más repetidas del
|
||
// español, y en un historial con prosa entre el código la IDF no las hunde: son
|
||
// raras frente a los tokens de código. Las líneas de relleno adelantan a la
|
||
// buena. Medido, plegando: acer_real −3,6 puntos y el español del banco
|
||
// multilingüe pierde casos. Sin plegar: acer_real idéntico, y el multilingüe
|
||
// (tests/contexto-multilingue.mjs, 126 preguntas en 7 idiomas escritas por un
|
||
// modelo que no vio este código, presupuesto al 25 %) pasa de 74 a 94 aciertos
|
||
// sin perder ninguno; ucraniano y ruso, de 0/18 a 10/18 y 9/18. Quitar palabras
|
||
// vacías de la pregunta para compensar el plegado salió peor en los dos. Lo que
|
||
// se deja: «alergica» escrita sin tilde no casa con «alérgica», como antes.
|
||
//
|
||
// NFC, para que la misma letra escrita compuesta o descompuesta sea un solo
|
||
// término. El texto ASCII puro se parte con el patrón de siempre, que da los
|
||
// mismos términos que la expresión Unicode (comprobado con 200.000 cadenas al
|
||
// azar) sin pagarla.
|
||
//
|
||
// RAÍZ POR TRUNCADO. Además de la palabra, sus primeros caracteres: así
|
||
// «полиса» casa con «полис», «бронирования» con «брони» y «vorlagen» con
|
||
// «vorlage». Es el lematizador más simple que hay, sin diccionarios ni idioma.
|
||
// Cirílico: 5 caracteres desde 6 letras (la flexión eslava cambia más la
|
||
// cola); el resto: 6 desde 7, y solo palabras hechas de letras, no
|
||
// identificadores con dígitos o _. Medido frente a no hacerlo, con la variante
|
||
// diseñada mirando los fallos del banco multilingüe y validada en bancos que no
|
||
// se miraron: segundo banco multilingüe a ciegas 86 → 89 de 108 sin perder en
|
||
// ruso (9 → 12/18); acer_real 91,9 → 92,8 %; LoCoMo 163 → 173 evidencias de 354.
|
||
// NO sirve para el recuperador de herramientas (tool-router.js), que la apaga con
|
||
// `{ raices: false }`: con siete descripciones, «recuerda» (memoria) y «recuérdame»
|
||
// (tareas) comparten «recuer», que es justo lo que tiene que distinguir. Medido:
|
||
// su banco ciego baja de 57 a 56 de 60 con raíces.
|
||
const out = [];
|
||
const seen = new Set();
|
||
const push = (t) => { if (t.length >= 2 && !seen.has(t)) { seen.add(t); out.push(t); } };
|
||
const ascii = !/[^\x00-\x7f]/.test(s);
|
||
const palabras = ascii
|
||
? s.toLowerCase().match(/[a-z0-9_][\w./-]*/g)
|
||
: s.normalize('NFC').toLowerCase().match(/[\p{L}\p{N}_][\p{L}\p{N}_./-]*/gu);
|
||
for (const m of palabras || []) {
|
||
push(m);
|
||
if (/[./-]/.test(m)) for (const part of m.split(/[./-]+/)) push(part);
|
||
if (opciones && opciones.raices === false) continue;
|
||
if (ascii) { if (m.length >= 7 && /^[a-z]+$/.test(m)) push(m.slice(0, 6)); }
|
||
else if (m.length >= 6 && /\p{Script=Cyrillic}/u.test(m)) push(m.slice(0, 5));
|
||
else if (m.length >= 7 && /^\p{L}+$/u.test(m)) push(m.slice(0, 6));
|
||
}
|
||
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], opts);
|
||
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();
|
||
// La cola literal cuenta IGUAL que la cabecera: lo que ya viaja entero en los últimos
|
||
// turnos no se vuelve a pagar recuperado más arriba. Medido con un historial de juguete:
|
||
// la misma línea salía DOS veces (recuperada y literal), y la recuperada es la recortada,
|
||
// o sea, la peor copia. Lo apuntó Burton Lancaster (RiverRider) contando cómo lo resuelve
|
||
// Black Window: si un pasaje recuperado vuelve literal a la ventana, se cae del conjunto.
|
||
for (const m of [...head, ...recent]) 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,
|
||
};
|