Spaces:
Running
Running
File size: 68,141 Bytes
51ec7b5 4946dda 1ff195c 4946dda 51ec7b5 0781d96 51ec7b5 4946dda 51ec7b5 0be9487 5434cf9 0be9487 1ff195c 51ec7b5 c2b45e8 5434cf9 c2b45e8 5434cf9 c2b45e8 51ec7b5 c2b45e8 51ec7b5 094ecad 51ec7b5 0be9487 f0ce937 0781d96 51ec7b5 1ff195c 51ec7b5 f0ce937 0781d96 51ec7b5 1ff195c 51ec7b5 4946dda 51ec7b5 4946dda 51ec7b5 4946dda 51ec7b5 1ff195c 51ec7b5 1ff195c 51ec7b5 0be9487 51ec7b5 0be9487 51ec7b5 0be9487 51ec7b5 1ff195c 51ec7b5 f0ce937 51ec7b5 f0ce937 51ec7b5 f0ce937 51ec7b5 1ff195c 51ec7b5 1ff195c 51ec7b5 0be9487 51ec7b5 1ff195c 51ec7b5 1ff195c 51ec7b5 0be9487 51ec7b5 1ff195c 51ec7b5 1ff195c 51ec7b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 | /**
* 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,
};
|