Spaces:
Running
Running
| /** | |
| * 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, | |
| }; | |