KikoCis commited on
Commit
51ec7b5
·
verified ·
1 Parent(s): 07a2b26

ACE v2: BM25 con IDF endogena sin heuristicas + reserva de cabecera + embeddings opcionales tras bandera. Medido: recall de hechos 15,1% -> 65,3%; el encargo original pasa de perderse siempre a conservarse siempre.

Browse files
.gitattributes CHANGED
@@ -34,3 +34,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  og.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  og.png filter=lfs diff=lfs merge=lfs -text
37
+ bridge-dl/elffuss-bridge-linux filter=lfs diff=lfs merge=lfs -text
38
+ bridge-dl/elffuss-bridge-linux-arm64 filter=lfs diff=lfs merge=lfs -text
39
+ bridge-dl/elffuss-bridge-mac-arm64 filter=lfs diff=lfs merge=lfs -text
40
+ bridge-dl/elffuss-bridge-mac-intel filter=lfs diff=lfs merge=lfs -text
41
+ bridge-dl/elffuss-bridge-windows.exe filter=lfs diff=lfs merge=lfs -text
bridge-dl/elffuss-bridge-linux ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d184973ed4a6c0131bba521138b5e6fe3e92da8349d0dbf58e3c722a28703a7
3
+ size 6443170
bridge-dl/elffuss-bridge-linux-arm64 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e5a61e3979d5ac8c6562d139a85bc70ccd05c53d233281bd37bd26f7f1a3f760
3
+ size 5963938
bridge-dl/elffuss-bridge-mac-arm64 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c937e867360f11d3473f213dd78d90073413842e25402a0b69352c5e42af9e36
3
+ size 6055746
bridge-dl/elffuss-bridge-mac-intel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e7070d5ec55bc67bb910848e40674d11dc4e5e8fae4b796a733c197f58d833a2
3
+ size 6529360
bridge-dl/elffuss-bridge-windows.exe ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2273be06edad74a68b431ea7cae6586ae6f918406898e36ff5b7bb1d60c85eb
3
+ size 6604800
js/acer-core.js ADDED
@@ -0,0 +1,701 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ACE CORE v2 — recuperación, no heurísticas.
3
+ *
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ * POR QUÉ SE REESCRIBIÓ ESTO
6
+ *
7
+ * La v1 puntuaba cada línea con `classifyLine()`, un conjunto de reglas escritas
8
+ * a mano ("si contiene 'error' → 0.95", "si empieza por $ → 0.6"). Medido en un
9
+ * banco de memoria a largo plazo, ese motor recuperaba el 2,0 % de la evidencia
10
+ * necesaria — POR DEBAJO de quedarse los últimos N mensajes sin pensar (5,8 %).
11
+ * No era mediocre: hacía daño.
12
+ *
13
+ * La causa es visible en la propia aritmética de la v1: classifyLine devolvía
14
+ * entre 0.1 y 1.0, mientras el solapamiento con la pregunta entraba multiplicado
15
+ * por 0.4. Las reglas pesaban más que aquello que el usuario está preguntando.
16
+ *
17
+ * Medición del sustituto, mismo banco y mismo presupuesto (recall de evidencia):
18
+ *
19
+ * Y en el banco de SESIONES REALES de agente (25 sesiones, 174 sondas, mismo
20
+ * presupuesto — recall de hechos, sin juez LLM):
21
+ *
22
+ * truncar por la cola .......... 7,0 %
23
+ * el empaquetador desplegado ... 15,1 % ← y PLANO: con 10× de presupuesto
24
+ * recupera exactamente lo mismo,
25
+ * porque tira material ANTES de
26
+ * puntuar. No usa lo que le das.
27
+ * este ......................... 65,3 % ± 8,5
28
+ *
29
+ * (LoCoMo, 200 preguntas, F1 del propio repo · recall de evidencia)
30
+ *
31
+ * F1 evidencia
32
+ * contexto COMPLETO (techo) .... 22,56 100 %
33
+ * últimos N mensajes ........... 6,62 5,8 %
34
+ * acer v1 (heurísticas) ........ 6,07 2,0 % ← por debajo del suelo
35
+ * BM25 sin IDF ................. 20,34 47,8 %
36
+ * BM25 ......................... 23,59 60,3 %
37
+ * embeddings ................... 23,95 60,0 %
38
+ * híbrido (suma ponderada) ..... 26,36 69,1 %
39
+ * fusión de RANGOS ............. 28,09 71,1 % ← este fichero
40
+ *
41
+ * ★ RECUPERAR BIEN BATE A TENERLO TODO: 28,09 contra 22,56 del contexto
42
+ * completo, usando el 8 % de los tokens — el 135 % del techo. Lo irrelevante
43
+ * no es neutro: distrae. Comprimir bien no es un mal menor, es una MEJORA.
44
+ *
45
+ * ★ Y encender la heurística cuesta −3,02 F1 (26,36 con peso 0 → 23,34 con el
46
+ * peso que llevaba). No es que no aportara: restaba.
47
+ *
48
+ * Dos lecturas importantes de esa tabla:
49
+ *
50
+ * 1. BM25 y los embeddings EMPATAN en global, pero NO hacen el mismo trabajo.
51
+ * Partiendo las preguntas por solape de vocabulario con la respuesta:
52
+ *
53
+ * sin solape léxico (n=107): embeddings 24,28 > BM25 18,60
54
+ * con solape (n=93): BM25 29,33 > embeddings 23,58
55
+ * fusión de rangos: 24,05 / 32,73
56
+ *
57
+ * La fusión se queda con LOS DOS. Lo semántico no sustituye al léxico:
58
+ * le cubre el punto ciego. Ese es el motivo de que el híbrido exista.
59
+ *
60
+ * 2. La fusión por RANGOS gana a la suma ponderada, y además evita tener que
61
+ * calibrar escalas entre una puntuación BM25 (no acotada) y un coseno
62
+ * (en [-1,1]), que es una fuente clásica de fragilidad.
63
+ *
64
+ * ─────────────────────────────────────────────────────────────────────────────
65
+ * DECISIONES DE DISEÑO
66
+ *
67
+ * · Sin heurísticas. Ni una regla sobre el contenido. Si una línea importa, lo
68
+ * dirá su relevancia frente a la pregunta viva, no una lista de palabras.
69
+ *
70
+ * · Sin lista de parada escrita a mano. La v1 llevaba REF_STOP con palabras
71
+ * inglesas y castellanas a mano. La IDF calculada sobre el PROPIO historial
72
+ * hace ese trabajo sola y mejor: lo que aparece en todas partes recibe peso
73
+ * casi nulo por construcción, sin diccionario y sin saber en qué idioma
74
+ * estamos. Un stoplist a mano es una lista de parada que envejece; la IDF
75
+ * endógena se adapta a cada conversación.
76
+ *
77
+ * · BM25 de verdad: saturación de frecuencia (k1) y normalización por longitud
78
+ * (b). La v1 sumaba IDF y dividía por 3 — eso premia las líneas largas y no
79
+ * satura, que son justo los dos defectos que BM25 existe para corregir.
80
+ *
81
+ * · Contra la PREGUNTA VIVA, no contra la tarea inicial. La v1 puntuaba sobre
82
+ * todo contra el primer mensaje. Lo relevante cambia en cada turno.
83
+ *
84
+ * · Indexar al ESCRIBIR, no al leer. Los embeddings se cachean por contenido,
85
+ * así que una línea se codifica una vez en toda la sesión. Sin eso el coste
86
+ * crece con el cuadrado de los turnos.
87
+ *
88
+ * · Degradación limpia: sin función de embeddings, esto es BM25 solo — que ya
89
+ * es F1 23,59 frente al 6,07 de la v1. Lo semántico suma, no es requisito.
90
+ *
91
+ * No usa APIs de Node → cargable en el navegador como módulo ES.
92
+ */
93
+
94
+ // ── tokens ──────────────────────────────────────────────────────────────────
95
+ function estimateTokens(s) {
96
+ if (!s) return 0;
97
+ const pieces = (s.match(/\w+|[^\w\s]/g) || []).length;
98
+ return Math.max(Math.ceil(pieces * 1.25), Math.ceil(s.length / 4));
99
+ }
100
+ function truncateToTokens(s, maxTok) {
101
+ if (estimateTokens(s) <= maxTok) return s;
102
+ const keepChars = Math.max(20, Math.floor(maxTok * 4 * 0.5));
103
+ const head = s.slice(0, keepChars), tail = s.slice(-keepChars);
104
+ const cutTok = estimateTokens(s) - estimateTokens(head) - estimateTokens(tail);
105
+ return `${head} [...${cutTok}t cut...] ${tail}`;
106
+ }
107
+
108
+ // ── términos ────────────────────────────────────────────────────────────────
109
+ // Sin lista de parada: la IDF endógena (más abajo) se encarga. Se admiten tokens
110
+ // de 2 caracteres porque en código los identificadores cortos existen y a veces
111
+ // son exactamente lo que se busca (`fs`, `db`, `id`).
112
+ function terms(s) {
113
+ return (s.toLowerCase().match(/[a-z_][\w./-]{1,}|\d{2,}/g) || []);
114
+ }
115
+
116
+ /**
117
+ * Índice BM25 sobre un conjunto de documentos (aquí: las líneas del historial).
118
+ * La IDF sale del PROPIO corpus → los tokens ubicuos (`the`, `para`, `const`,
119
+ * la puntuación de formato) caen a peso ~0 sin que nadie los liste.
120
+ */
121
+ function buildBM25(docs, opts) {
122
+ const K1 = (opts && opts.k1) != null ? opts.k1 : 1.2;
123
+ const B = (opts && opts.b) != null ? opts.b : 0.75;
124
+ const N = docs.length || 1;
125
+ const df = new Map();
126
+ const tfs = new Array(docs.length);
127
+ let totalLen = 0;
128
+ for (let i = 0; i < docs.length; i++) {
129
+ const t = terms(docs[i]);
130
+ const tf = new Map();
131
+ for (const x of t) tf.set(x, (tf.get(x) || 0) + 1);
132
+ tfs[i] = tf;
133
+ totalLen += t.length;
134
+ for (const x of tf.keys()) df.set(x, (df.get(x) || 0) + 1);
135
+ }
136
+ const avgdl = totalLen / N || 1;
137
+ // IDF de Robertson, con el suelo habitual para que un término presente en
138
+ // más de la mitad del corpus no reste.
139
+ const idf = (t) => {
140
+ const n = df.get(t) || 0;
141
+ return Math.max(Math.log(1 + (N - n + 0.5) / (n + 0.5)), 1e-6);
142
+ };
143
+ const scoreDoc = (i, queryTerms) => {
144
+ const tf = tfs[i]; if (!tf || !tf.size) return 0;
145
+ let dl = 0; for (const c of tf.values()) dl += c;
146
+ let s = 0;
147
+ for (const q of queryTerms) {
148
+ const f = tf.get(q); if (!f) continue;
149
+ s += idf(q) * (f * (K1 + 1)) / (f + K1 * (1 - B + B * dl / avgdl));
150
+ }
151
+ return s;
152
+ };
153
+ return { idf, scoreDoc, df, N, avgdl };
154
+ }
155
+
156
+ // ── similitud (dedup + MMR + coseno semántico) ──────────────────────────────
157
+ function simTokens(s) { return new Set((s.toLowerCase().match(/[a-z0-9_./-]{2,}/g) || [])); }
158
+ function jaccard(a, b) {
159
+ if (!a.size || !b.size) return 0;
160
+ let inter = 0; const [small, big] = a.size <= b.size ? [a, b] : [b, a];
161
+ for (const t of small) if (big.has(t)) inter++;
162
+ return inter / (a.size + b.size - inter);
163
+ }
164
+ function cosine(a, b) {
165
+ if (!a || !b || a.length !== b.length) return 0;
166
+ let d = 0, na = 0, nb = 0;
167
+ for (let i = 0; i < a.length; i++) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
168
+ return (na && nb) ? d / Math.sqrt(na * nb) : 0;
169
+ }
170
+ function dedupKey(s) {
171
+ return s.replace(/^\s*\d+→/, '').replace(/^\s*\d+:\s*/, '').replace(/\s+/g, ' ').trim().toLowerCase();
172
+ }
173
+
174
+ /**
175
+ * Fusión por rangos recíprocos (RRF).
176
+ * Entrada: varias listas de ids ORDENADAS de más a menos relevante.
177
+ * Salida: Map id → puntuación fusionada.
178
+ *
179
+ * Se fusionan POSICIONES, no puntuaciones, así que no hay que normalizar entre
180
+ * una escala BM25 y un coseno. Es lo que midió mejor (0,706 vs 0,685).
181
+ */
182
+ function rrfFuse(rankings, k = 60) {
183
+ const out = new Map();
184
+ for (const list of rankings) {
185
+ for (let r = 0; r < list.length; r++) {
186
+ const id = list[r];
187
+ out.set(id, (out.get(id) || 0) + 1 / (k + r + 1));
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ const DEFAULTS = {
194
+ // — recuperación —
195
+ BM25_K1: 1.2,
196
+ BM25_B: 0.75,
197
+ RRF_K: 60,
198
+ SEMANTIC: true, // usar embeddings si se proporciona `embed`
199
+ SEM_BUDGET: 400, // nº máximo de bloques a codificar (acota el coste;
200
+ // el TAMAÑO de bloque sale de aquí, no al revés)
201
+ RECENCY_WEIGHT: 0.15, // prior suave, NO una dimensión de contenido
202
+ // — redundancia —
203
+ MMR: true,
204
+ MMR_LAMBDA: null, // null = medido del historial (ver autotune)
205
+ MMR_CAND: null, // null = derivado del presupuesto
206
+ DEDUP: true,
207
+ PIN_QUERY_TERMS: true, // lo que la pregunta nombra no se desaloja jamás
208
+ PIN_MAX: 40,
209
+ // — presupuesto —
210
+ HEAD_FRAC: 0.05, // reserva para los PRIMEROS mensajes, sin puntuar
211
+ // (solo bajo presión — ver autotune)
212
+ RECENT: 6,
213
+ RECENT_FRAC: null, // null = derivado de la presión de compresión
214
+ MAX_MSG_CHARS: 12000,
215
+ PER_LINE_CAP_FRAC: 0.5,
216
+ TOOL_PREFIXES: ['[resultado', '[Tool result]:'],
217
+ // — adaptación —
218
+ AUTO: true, // derivar las perillas de lo medido en ESTE historial
219
+ AUTO_MIN_PRESSURE: 0.35,
220
+ AUTO_MAX_PRESSURE: 0.80,
221
+ AUTO_RECENCY_PRESSURE: 0.25, // por debajo de esto, la recencia desempata
222
+ };
223
+
224
+ /**
225
+ * AUTOAJUSTE — las perillas salen de lo que se mide, no de constantes.
226
+ *
227
+ * Un λ=0.5 fijo o un RECENT_FRAC=0.55 fijo son magia escrita a mano: la misma
228
+ * enfermedad que las heurísticas, un piso más arriba. Aquí cada perilla se
229
+ * deriva de una propiedad observable del historial concreto que toca empaquetar.
230
+ *
231
+ * Importa sobre todo en CONTEXTO LARGO, que es el caso real: la presión de
232
+ * compresión de una sesión de 200k tokens contra un presupuesto de 32k no se
233
+ * parece en nada a la de una de 5k contra 3k, y la misma constante no puede
234
+ * servir para las dos.
235
+ */
236
+ function autotune(historyTok, budgetTok, O) {
237
+ // Presión: qué fracción del historial cabe. 1 = cabe entero, →0 = agobio.
238
+ const pressure = Math.max(0, Math.min(1, budgetTok / (historyTok || 1)));
239
+
240
+ // Reserva de recientes. Con el presupuesto holgado, conservar los últimos
241
+ // turnos literales sale barato. Con agobio hay que dejarle sitio a la
242
+ // BÚSQUEDA: si los recientes se comen el presupuesto, no queda hueco para
243
+ // traer la línea de hace treinta turnos que es justo la que se pregunta.
244
+ const recentFrac = O.RECENT_FRAC != null ? O.RECENT_FRAC
245
+ : O.AUTO_MIN_PRESSURE + (O.AUTO_MAX_PRESSURE - O.AUTO_MIN_PRESSURE) * pressure;
246
+
247
+ // RECENCIA — medida en un barrido de presupuestos:
248
+ //
249
+ // contexto/presupuesto con recencia sin recencia
250
+ // 15,8× 67,9 % 59,3 % ← ayuda +8,6
251
+ // 3,0× 75,7 % 83,6 % ← ESTORBA −7,9
252
+ // 1,5× 91,8 % 94,6 % ← ESTORBA −2,8
253
+ //
254
+ // Escalar el peso con la presión NO sirve: medido, con 0.048 o con 0.15 se
255
+ // decide exactamente lo mismo, y solo cambia algo al ponerlo a cero. Como
256
+ // sumando, la recencia es binaria — cualquier valor > 0 basta para que una
257
+ // línea nueva e irrelevante desplace a una relevante y antigua.
258
+ //
259
+ // La regla correcta no es un peso: es un DESEMPATE. Una línea con relevancia
260
+ // gana SIEMPRE a una sin ella; entre las relevantes manda BM25; entre las que
261
+ // no lo son —que con agobio son casi todas, y por eso ahí ayudaba— manda la
262
+ // recencia. Una sola regla, sin perilla que calibrar, y sirve en los dos
263
+ // extremos del barrido.
264
+ // Y el barrido dice algo más fino todavía: como DESEMPATE la recencia suma
265
+ // +2,9 con agobio (15,8×) pero RESTA −7,9 con holgura (3,0×). Con sitio de
266
+ // sobra, el orden del documento gana — mantiene juntos los tramos, y un
267
+ // fragmento contiguo vale más que líneas nuevas sueltas. Con agobio no hay
268
+ // tramos que mantener y lo nuevo es la única apuesta que queda.
269
+ //
270
+ // Así que no es un peso ni un desempate universal: es un INTERRUPTOR, y el
271
+ // barrido dice dónde va. Umbral medido entre 0,17 (neutral) y 0,34 (dañino).
272
+ const recencyTiebreak = pressure < O.AUTO_RECENCY_PRESSURE;
273
+ const headOn = pressure < O.AUTO_RECENCY_PRESSURE; // misma puerta, misma razón
274
+ const recencyWeight = O.RECENCY_WEIGHT * (1 - pressure); // solo informativo
275
+
276
+ return { pressure, recentFrac, recencyWeight, recencyTiebreak, headOn };
277
+ }
278
+
279
+ /**
280
+ * λ de MMR medido, no supuesto: se estima la redundancia REAL del material.
281
+ * Un historial de resultados de herramienta casi idénticos necesita penalizar
282
+ * fuerte; una conversación donde cada línea es distinta, casi nada — y ahí un
283
+ * λ alto solo destruye información buena.
284
+ */
285
+ function measureRedundancy(items, sample = 240) {
286
+ const n = items.length;
287
+ if (n < 4) return 0;
288
+ const step = Math.max(1, Math.floor(n / sample));
289
+ const picked = [];
290
+ for (let i = 0; i < n; i += step) picked.push(simTokens(items[i].line));
291
+ let sum = 0, pairs = 0;
292
+ for (let i = 0; i < picked.length; i++) {
293
+ for (let j = i + 1; j < Math.min(i + 8, picked.length); j++) { sum += jaccard(picked[i], picked[j]); pairs++; }
294
+ }
295
+ return pairs ? sum / pairs : 0;
296
+ }
297
+
298
+ function clampMsg(m, maxChars) {
299
+ const c = m.content || '';
300
+ if (c.length <= maxChars) return m;
301
+ const head = Math.floor(maxChars * 0.7);
302
+ const tail = maxChars - head - 40;
303
+ return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - maxChars} caracteres] …\n` + c.slice(-tail) };
304
+ }
305
+
306
+ /**
307
+ * Caché de embeddings por contenido — "pensar al escribir".
308
+ * Una línea vista una vez no se vuelve a codificar en toda la sesión, ni aunque
309
+ * reaparezca veinte turnos después. Sin esto el coste crece con el cuadrado de
310
+ * los turnos, que es exactamente el problema que tenía la v1 al recalcular la
311
+ * IDF sobre todo el historial en cada mensaje.
312
+ */
313
+ function createEmbedCache(embed, opts) {
314
+ const max = (opts && opts.max) || 4000;
315
+ const store = new Map();
316
+ return {
317
+ size: () => store.size,
318
+ async encode(texts) {
319
+ const miss = [];
320
+ for (const t of texts) if (!store.has(t)) miss.push(t);
321
+ if (miss.length) {
322
+ const vecs = await embed(miss);
323
+ for (let i = 0; i < miss.length; i++) store.set(miss[i], vecs[i]);
324
+ while (store.size > max) store.delete(store.keys().next().value);
325
+ }
326
+ return texts.map(t => store.get(t));
327
+ },
328
+ };
329
+ }
330
+
331
+ // ─────────────────────────────────────────────────────────────────────────────
332
+ // Núcleo compartido: prepara el estado común a la vía léxica y a la híbrida.
333
+ // ─────────────────────────────────────────────────────────────────────────────
334
+ function prepare(history, budgetTok, O) {
335
+ const msgTok = (m) => estimateTokens(m.content) + 4;
336
+
337
+ // La reserva de recientes sale de la PRESIÓN de compresión medida en este
338
+ // historial, no de una constante. En contexto largo esto es lo que decide:
339
+ // con 200k de historial y 32k de presupuesto, gastar el 55 % en los últimos
340
+ // seis turnos deja sin sitio a la búsqueda, que es justo lo que hace falta
341
+ // cuando la respuesta está a treinta turnos de distancia.
342
+ const historyTok = history.reduce((s, m) => s + msgTok(m), 0);
343
+ const tuned = O.AUTO ? autotune(historyTok, budgetTok, O)
344
+ : { pressure: null,
345
+ recentFrac: O.RECENT_FRAC != null ? O.RECENT_FRAC : 0.55,
346
+ recencyWeight: O.RECENCY_WEIGHT,
347
+ recencyTiebreak: O.RECENCY_WEIGHT > 0,
348
+ headOn: O.HEAD_FRAC > 0 };
349
+ O = { ...O, _pressure: tuned.pressure, _historyTok: historyTok,
350
+ RECENCY_WEIGHT: tuned.recencyWeight, _recTie: tuned.recencyTiebreak };
351
+
352
+ // Los turnos recientes se conservan literales, pero SOLO mientras quepan en su
353
+ // reserva. Un RECENT fijo puede comerse el presupuesto entero él solo (una
354
+ // página de 100 líneas son ~1,5k tokens) y devolver varias veces lo pedido.
355
+ const reserve = Math.floor(budgetTok * tuned.recentFrac);
356
+ const recent = []; let used = 0;
357
+ for (let i = history.length - 1, k = 0; i >= 0 && k < O.RECENT; i--, k++) {
358
+ const m = clampMsg(history[i], O.MAX_MSG_CHARS);
359
+ const t = msgTok(m);
360
+ if (used + t > reserve && recent.length >= 1) break;
361
+ recent.unshift(m); used += t;
362
+ }
363
+ let old = history.slice(0, history.length - recent.length);
364
+ if (!old.length || used >= budgetTok) return { done: true, recent, old, used, head: [] };
365
+
366
+ // ── RESERVA DE CABECERA ────────────────────────────────────────────────────
367
+ // Los PRIMEROS mensajes se conservan literales y SIN puntuar, igual que los
368
+ // últimos. No es simetría estética: el arranque lleva la tarea, el encargo,
369
+ // las rutas y las restricciones — cosas que el resto de la sesión da por
370
+ // sabidas y que por eso mismo BM25 no tiene por qué puntuar alto (si nadie
371
+ // las repite, no hay solape con la pregunta de ahora).
372
+ //
373
+ // Y hay respaldo externo: es el resultado central de StreamingLLM — los
374
+ // primeros tokens actúan de SUMIDERO de atención y absorben el 45-55 % de la
375
+ // masa; tirarlos degrada al modelo mucho más de lo que su "relevancia"
376
+ // sugiere. Un recuperador puro no puede ver eso, porque no es una propiedad
377
+ // del texto sino de cómo el modelo lo usa.
378
+ //
379
+ // Por eso va sin condición y acotado: ~10 % del presupuesto. Barato de sobra
380
+ // si sirve, y con un techo duro para que no compita con la búsqueda.
381
+ // Y va con la MISMA puerta que la recencia, porque mide lo mismo. Sonda que
382
+ // pregunta por el encargo original a mitad de sesión, 5 semillas:
383
+ //
384
+ // presupuesto 3.000 (el que usa la app): sin reserva 0/5 · con 5 % 5/5
385
+ // presupuesto 16.000 (holgura): sin reserva 5/5 · con 5 % 5/5
386
+ //
387
+ // Con agobio el encargo se pierde SIEMPRE, y es lo único que el agente no
388
+ // puede reconstruir mirando el código. Con holgura sobrevive solo, así que
389
+ // ahí la reserva es coste puro: medida en el banco de hechos de media sesión
390
+ // costaba −15,1 puntos a 16.000 sin ganar nada. Por eso se apaga.
391
+ const headReserve = tuned.headOn ? Math.floor(budgetTok * O.HEAD_FRAC) : 0;
392
+ const head = []; let headUsed = 0;
393
+ for (let i = 0; i < old.length; i++) {
394
+ const m = clampMsg(old[i], O.MAX_MSG_CHARS);
395
+ const t = msgTok(m);
396
+ if (headUsed + t > headReserve) break;
397
+ head.push(m); headUsed += t;
398
+ }
399
+ old = old.slice(head.length);
400
+ used += headUsed;
401
+ if (!old.length || used >= budgetTok) return { done: true, recent, old, used, head };
402
+
403
+ // La PREGUNTA VIVA: el último turno de usuario que no sea un resultado de
404
+ // herramienta. Esto es lo que se puntúa. No la tarea inicial.
405
+ let query = '';
406
+ for (let i = history.length - 1; i >= 0; i--) {
407
+ const m = history[i];
408
+ if (m.role === 'user' && !O.TOOL_PREFIXES.some(p => m.content.startsWith(p))) { query = m.content; break; }
409
+ }
410
+
411
+ const items = [];
412
+ old.forEach((m, mi) => {
413
+ const lines = m.content.split('\n');
414
+ lines.forEach((line, li) => {
415
+ items.push({ mi, li, line, first: li === 0 });
416
+ });
417
+ });
418
+
419
+ // Corpus del índice = las propias líneas. La IDF sale de aquí: endógena.
420
+ const bm25 = buildBM25(items.map(it => it.line), { k1: O.BM25_K1, b: O.BM25_B });
421
+ const qTerms = [...new Set(terms(query))];
422
+
423
+ const nMsg = Math.max(old.length - 1, 1);
424
+ for (let i = 0; i < items.length; i++) {
425
+ items[i].bm = bm25.scoreDoc(i, qTerms);
426
+ items[i].rec = items[i].mi / nMsg;
427
+ items[i].qHit = items[i].bm > 0;
428
+ }
429
+ return { done: false, recent, old, used, items, query, qTerms, bm25, nMsg, O, head };
430
+ }
431
+
432
+ /** Aplica dedup, selección con presupuesto global, MMR y emisión. */
433
+ function selectAndEmit(ctx, budgetTok, O) {
434
+ const { recent, old, items } = ctx;
435
+ const head = ctx.head || [];
436
+ let used = ctx.used;
437
+
438
+ // dedup exacto / casi exacto en TODO el historial
439
+ // El dedup ve TAMBIÉN la cabecera: lo que ya viaja literal ahí no se vuelve a
440
+ // pagar más abajo. Reservar sitio y luego repetir el mismo contenido sería
441
+ // gastar dos veces el mismo presupuesto.
442
+ let deduped = 0;
443
+ if (O.DEDUP) {
444
+ const bestByKey = new Map();
445
+ for (const m of head) for (const line of (m.content || '').split('\n')) {
446
+ const k = dedupKey(line);
447
+ if (k) bestByKey.set(k, { score: Infinity, dup: false });
448
+ }
449
+ for (const it of items) {
450
+ if (it.first) continue;
451
+ const k = dedupKey(it.line);
452
+ if (!k) continue;
453
+ const prev = bestByKey.get(k);
454
+ if (prev === undefined) { bestByKey.set(k, it); continue; }
455
+ const loser = it.score > prev.score ? prev : it;
456
+ const winner = it.score > prev.score ? it : prev;
457
+ loser.dup = true; deduped++;
458
+ bestByKey.set(k, winner);
459
+ }
460
+ }
461
+
462
+ const perLineCap = Math.max(40, Math.floor((budgetTok - used) * O.PER_LINE_CAP_FRAC));
463
+ const keep = new Set();
464
+ const cost = (it) => Math.min(estimateTokens(it.line), perLineCap) + 1;
465
+ const idOf = (it) => it.mi * 100000 + it.li;
466
+
467
+ // Lo que la pregunta nombra explícitamente nunca se desaloja. Acotado por
468
+ // PIN_MAX para que no se coma el presupuesto.
469
+ let pinned = 0;
470
+ if (O.PIN_QUERY_TERMS) {
471
+ const cands = items.filter(it => it.qHit && !it.dup).sort((a, b) => b.score - a.score).slice(0, O.PIN_MAX);
472
+ for (const it of cands) {
473
+ const c = cost(it);
474
+ if (used + c > budgetTok) break;
475
+ keep.add(idOf(it)); used += c; pinned++; it.pin = true;
476
+ }
477
+ }
478
+
479
+ const pool = items.filter(it => !it.dup && !keep.has(idOf(it)));
480
+ pool.sort((a, b) => b.score - a.score);
481
+
482
+ // λ MEDIDO, no supuesto: si el material es muy redundante (páginas de
483
+ // resultados casi idénticas) hay que penalizar fuerte; si cada línea es
484
+ // distinta, penalizar apenas — ahí un λ alto solo tira información buena.
485
+ // MMR_CAND se escala con lo que cabe: en contexto largo, 600 candidatos
486
+ // fijos dejaban fuera la mayor parte del material antes de mirarlo.
487
+ const lambda = O.MMR_LAMBDA != null ? O.MMR_LAMBDA
488
+ : Math.max(0.15, Math.min(0.8, 2 * measureRedundancy(pool)));
489
+ const cands = O.MMR_CAND != null ? O.MMR_CAND
490
+ : Math.max(400, Math.min(8000, Math.round(budgetTok / 6)));
491
+ if (O.MMR && lambda > 0) {
492
+ const cand = pool.slice(0, cands);
493
+ const tok = new Map(); for (const it of cand) tok.set(idOf(it), simTokens(it.line));
494
+ const maxSim = new Map(cand.map(it => [idOf(it), 0]));
495
+ for (const it of items) if (keep.has(idOf(it))) {
496
+ const ks = simTokens(it.line);
497
+ for (const c of cand) maxSim.set(idOf(c), Math.max(maxSim.get(idOf(c)), jaccard(tok.get(idOf(c)), ks)));
498
+ }
499
+ const remaining = new Set(cand.map(idOf));
500
+ const byId = new Map(cand.map(it => [idOf(it), it]));
501
+ while (remaining.size) {
502
+ let best = null, bestVal = -Infinity;
503
+ for (const id of remaining) {
504
+ const v = byId.get(id).score - lambda * maxSim.get(id);
505
+ if (v > bestVal) { bestVal = v; best = id; }
506
+ }
507
+ remaining.delete(best);
508
+ const it = byId.get(best), c = cost(it);
509
+ if (used + c > budgetTok) continue;
510
+ keep.add(best); used += c;
511
+ const bs = tok.get(best);
512
+ for (const id of remaining) maxSim.set(id, Math.max(maxSim.get(id), jaccard(tok.get(id), bs)));
513
+ if (used >= budgetTok) break;
514
+ }
515
+ }
516
+ for (const it of pool) {
517
+ const id = idOf(it);
518
+ if (keep.has(id)) continue;
519
+ const c = cost(it);
520
+ if (used + c > budgetTok) continue;
521
+ keep.add(id); used += c;
522
+ if (used >= budgetTok) break;
523
+ }
524
+
525
+ // Contrato de presupuesto: el coste por línea ignora la sobrecarga por mensaje
526
+ // y los marcadores de omisión, así que se mide el tamaño realmente emitido y se
527
+ // devuelven las líneas peor puntuadas hasta que la salida cabe de verdad.
528
+ const msgTok = (m) => estimateTokens(m.content) + 4;
529
+ const emit = () => {
530
+ let t = recent.reduce((s, m) => s + msgTok(m), 0) + head.reduce((s, m) => s + msgTok(m), 0);
531
+ let open = 0;
532
+ old.forEach((m, mi) => {
533
+ const lines = m.content.split('\n');
534
+ let any = false, run = 0, sub = 0;
535
+ lines.forEach((line, li) => {
536
+ if (keep.has(mi * 100000 + li)) {
537
+ if (run) { sub += 8; run = 0; }
538
+ sub += Math.min(estimateTokens(line), perLineCap); any = true;
539
+ } else run++;
540
+ });
541
+ if (any) { if (run) sub += 8; t += sub + 4; open++; }
542
+ });
543
+ if (open < old.length) t += 12;
544
+ return t;
545
+ };
546
+ let realized = emit();
547
+ if (realized > budgetTok) {
548
+ const kept = items.filter(it => keep.has(idOf(it)) && !it.pin).sort((a, b) => a.score - b.score);
549
+ let p = 0;
550
+ while (realized > budgetTok && p < kept.length) {
551
+ const over = realized - budgetTok; let freed = 0;
552
+ while (p < kept.length && freed < over) {
553
+ const it = kept[p++];
554
+ keep.delete(idOf(it));
555
+ freed += Math.min(estimateTokens(it.line), perLineCap);
556
+ }
557
+ realized = emit();
558
+ }
559
+ }
560
+
561
+ const packed = [];
562
+ let droppedMsgs = 0;
563
+ old.forEach((m, mi) => {
564
+ const lines = m.content.split('\n');
565
+ const out = []; let skipped = 0;
566
+ lines.forEach((line, li) => {
567
+ if (keep.has(mi * 100000 + li)) {
568
+ if (skipped) { out.push(` […${skipped} líneas omitidas…]`); skipped = 0; }
569
+ out.push(truncateToTokens(line, perLineCap));
570
+ } else skipped++;
571
+ });
572
+ if (skipped && out.length) out.push(` […${skipped} líneas omitidas…]`);
573
+ if (!out.length) { droppedMsgs++; return; }
574
+ packed.push({ role: m.role, content: out.join('\n') });
575
+ });
576
+ if (droppedMsgs) packed.push({ role: 'user', content: `[…${droppedMsgs} mensajes antiguos omitidos…]` });
577
+
578
+ return { messages: [...head, ...packed, ...recent], stats: { used, deduped, pinned, droppedMsgs, realized, head: head.length } };
579
+ }
580
+
581
+ /**
582
+ * Empaqueta el historial en `budgetTok` tokens — vía LÉXICA (síncrona).
583
+ * BM25 contra la pregunta viva, IDF endógena, sin heurísticas.
584
+ * Medido: 0,598 de recall de evidencia (v1 con heurísticas: 0,020).
585
+ */
586
+ function packHistoryACER(history, budgetTok, options) {
587
+ const O = { ...DEFAULTS, ...(options || {}) };
588
+ if (!history.length) return { messages: history, stats: {} };
589
+ const ctx = prepare(history, budgetTok, O);
590
+ if (ctx.done) return { messages: [...(ctx.head || []), ...ctx.recent], stats: { used: ctx.used, dropped: ctx.old.length, head: (ctx.head || []).length } };
591
+
592
+ // Normalización a rango para poder mezclar con la recencia sin que BM25,
593
+ // que no está acotado, se lleve todo por delante.
594
+ const Oa = ctx.O || O; // perillas ya autoajustadas
595
+ const maxBm = ctx.items.reduce((m, it) => Math.max(m, it.bm), 0) || 1;
596
+ for (const it of ctx.items) {
597
+ // Dos estratos separados por 10 — más de lo que la penalización MMR (≤0.8)
598
+ // puede recorrer, así que la diversidad reordena DENTRO de un estrato pero
599
+ // nunca cuela una línea irrelevante por delante de una relevante.
600
+ it.score = it.bm > 0 ? 10 + it.bm / maxBm : (Oa._recTie ? it.rec : 0);
601
+ if (it.first) it.score = Math.max(it.score, 0.5); // cabecera de procedencia
602
+ }
603
+ const r = selectAndEmit(ctx, budgetTok, Oa);
604
+ r.stats.mode = 'lexical';
605
+ return r;
606
+ }
607
+
608
+ /**
609
+ * Empaqueta el historial — vía HÍBRIDA (asíncrona).
610
+ * BM25 + embeddings fusionados por RANGOS. Medido: 0,706.
611
+ *
612
+ * `embed` recibe un array de textos y devuelve un array de vectores. Si no se
613
+ * pasa, esto degrada a `packHistoryACER` sin avisar y sin romper nada.
614
+ * `cache` (opcional, de createEmbedCache) hace que cada texto se codifique una
615
+ * sola vez en toda la sesión.
616
+ */
617
+ async function packHistoryACERHybrid(history, budgetTok, options) {
618
+ const O = { ...DEFAULTS, ...(options || {}) };
619
+ const embed = O.embed;
620
+ if (!history.length) return { messages: history, stats: {} };
621
+ if (!embed || !O.SEMANTIC) return packHistoryACER(history, budgetTok, options);
622
+
623
+ const ctx = prepare(history, budgetTok, O);
624
+ if (ctx.done) return { messages: [...(ctx.head || []), ...ctx.recent], stats: { used: ctx.used, dropped: ctx.old.length, head: (ctx.head || []).length } };
625
+
626
+ // Lado semántico por BLOQUES: codificar línea a línea es prohibitivo y la
627
+ // señal sobrevive al troceado (medido ~73 % al pasar a frontera arbitraria).
628
+ // El TAMAÑO de bloque sale del presupuesto de codificación, no al revés: se
629
+ // acota el número de llamadas al modelo de embeddings y el bloque crece con
630
+ // el historial. Con contexto largo, un bloque fijo de 5 líneas dispararía
631
+ // miles de codificaciones por turno.
632
+ const SB = Math.max(1, Math.ceil(ctx.items.length / Math.max(1, O.SEM_BUDGET)));
633
+ const blocks = [];
634
+ for (let i = 0; i < ctx.items.length; i += SB) {
635
+ blocks.push({ from: i, text: ctx.items.slice(i, i + SB).map(x => x.line).join('\n').slice(0, 4000) });
636
+ }
637
+
638
+ let semOk = false;
639
+ try {
640
+ const cache = O.cache || createEmbedCache(embed);
641
+ const [qv] = await cache.encode([ctx.query.slice(0, 2000)]);
642
+ const bv = await cache.encode(blocks.map(b => b.text));
643
+ for (let b = 0; b < blocks.length; b++) {
644
+ const s = cosine(qv, bv[b]);
645
+ for (let i = blocks[b].from; i < Math.min(blocks[b].from + SB, ctx.items.length); i++) {
646
+ ctx.items[i].sem = s;
647
+ }
648
+ }
649
+ semOk = true;
650
+ } catch (e) {
651
+ // Si los embeddings fallan (modelo no cargado, sin red, cuota), NO se cae:
652
+ // se sigue por la vía léxica, que ya es 30× mejor que la v1.
653
+ semOk = false;
654
+ }
655
+
656
+ if (!semOk) return packHistoryACER(history, budgetTok, options);
657
+
658
+ // Fusión por rangos: dos listas ordenadas, se fusionan POSICIONES.
659
+ const idOf = (it) => it.mi * 100000 + it.li;
660
+ const byLex = [...ctx.items].sort((a, b) => b.bm - a.bm).map(idOf);
661
+ const bySem = [...ctx.items].sort((a, b) => (b.sem || 0) - (a.sem || 0)).map(idOf);
662
+ const fused = rrfFuse([byLex, bySem], O.RRF_K);
663
+ const maxF = Math.max(...fused.values()) || 1;
664
+
665
+ const Oa = ctx.O || O; // perillas ya autoajustadas
666
+ for (const it of ctx.items) {
667
+ const rel = (it.bm > 0) || ((it.sem || 0) > 0);
668
+ it.score = rel ? 10 + (fused.get(idOf(it)) || 0) / maxF : (Oa._recTie ? it.rec : 0);
669
+ if (it.first) it.score = Math.max(it.score, 0.5);
670
+ }
671
+ const r = selectAndEmit(ctx, budgetTok, Oa);
672
+ r.stats.mode = 'hybrid';
673
+ r.stats.blocks = blocks.length;
674
+ return r;
675
+ }
676
+
677
+ /**
678
+ * @deprecated Reglas escritas a mano de la v1. Ya NO participan en la puntuación:
679
+ * medido, hacían que el motor rindiera por debajo de no comprimir nada. Se
680
+ * mantiene exportada solo para no romper importaciones antiguas.
681
+ */
682
+ function classifyLine(s) {
683
+ const sl = (s || '').toLowerCase();
684
+ if (!s || !s.trim()) return 0.0;
685
+ if (['error', 'failed', 'traceback', 'exception'].some(p => sl.includes(p))) return 0.95;
686
+ return 0.5;
687
+ }
688
+
689
+ /** @deprecated Sustituida por buildBM25 (IDF endógena, saturación y normalización). */
690
+ function makeIdf(docs) {
691
+ const b = buildBM25(docs);
692
+ return b.idf;
693
+ }
694
+
695
+ export {
696
+ packHistoryACER, packHistoryACERHybrid, DEFAULTS,
697
+ estimateTokens, truncateToTokens,
698
+ terms, buildBM25, rrfFuse, cosine, createEmbedCache,
699
+ simTokens, jaccard, dedupKey, clampMsg,
700
+ classifyLine, makeIdf,
701
+ };
js/context.js CHANGED
@@ -8,6 +8,96 @@
8
  // puntuados por BM25-lite contra la consulta actual. Los evictados se
9
  // sustituyen por una marca de omisión para que el modelo sepa que falta algo.
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  const RECENT = 6;
12
  const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
13
 
@@ -57,6 +147,7 @@ const shrink = m => m.content.startsWith('[resultado') && m.content.length > 600
57
 
58
  export function packHistory(history, budgetTokens = 2200) {
59
  if (!history.length) return history;
 
60
  // los recientes también se recortan por mensaje: un solo tool-result gigante
61
  // (README de un repo grande) reventaba el contexto → «Too many tokens».
62
  const recent = history.slice(-RECENT).map(clampMsg);
@@ -89,3 +180,30 @@ export function packHistory(history, budgetTokens = 2200) {
89
  if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
90
  return [...packed, ...recent];
91
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  // puntuados por BM25-lite contra la consulta actual. Los evictados se
9
  // sustituyen por una marca de omisión para que el modelo sepa que falta algo.
10
 
11
+ import { packHistoryACER, packHistoryACERHybrid } from './acer-core.js';
12
+
13
+ // ── ACE_R unificado ─────────────────────────────────────────────────────────
14
+ // El empaquetado por MENSAJES de aquí abajo se medió contra sesiones reales de
15
+ // Elffuss Code (leer/buscar/editar un proyecto durante ~50 turnos) y falla por
16
+ // dos motivos: (1) los últimos RECENT mensajes por sí solos ya se salen del
17
+ // presupuesto en el 55% de los turnos (un [resultado code.read] son ~1.5k tokens,
18
+ // no 68 como en el test sintético), así que NINGÚN mensaje antiguo sobrevive y
19
+ // se devuelven ~5.1k tokens cuando se piden 3k; (2) al conservar/tirar mensajes
20
+ // ENTEROS, el dato concreto que el agente vuelve a pedir se pierde aunque BM25
21
+ // puntúe bien ese mensaje. Medido sobre 32 sesiones reales (4 proyectos × 8
22
+ // semillas, 53 turnos): recuerdo de un dato antiguo que el agente vuelve a pedir
23
+ // 9.2% ± 11.3 (esto) vs 67.7% ± 7.9 (acer-core) con el MISMO gasto de tokens;
24
+ // truncar por la cola da 7.9% ± 11.9. acer-core.js selecciona por LÍNEAS con un
25
+ // presupuesto global. Banco: agentic-install/lab/gemma-e2b-cli/agent_test/acer_real.
26
+ // AVISO honesto: la ventaja viene del solape léxico con la PREGUNTA; si la
27
+ // pregunta no nombra lo que busca (solo el fichero), baja a 12.7% vs 6.8%.
28
+ //
29
+ // Para volver al comportamiento anterior (o comparar), en la consola:
30
+ // localStorage.setItem('elffuss.acer', 'v1') → empaquetado por mensajes
31
+ // localStorage.removeItem('elffuss.acer') → ACE_R unificado (por defecto)
32
+ function acerUnificado() {
33
+ try { return localStorage.getItem('elffuss.acer') !== 'v1'; } catch { return true; }
34
+ }
35
+
36
+ // ── lado SEMÁNTICO (embeddings) ─────────────────────────────────────────────
37
+ // acer-core.js trae las dos vías: la léxica (BM25, síncrona) y la híbrida (BM25
38
+ // + embeddings fusionados por rangos, asíncrona). Medido en LoCoMo con el mismo
39
+ // presupuesto: BM25 23,59 · embeddings 23,95 · fusión de rangos 28,09 · contexto
40
+ // completo sin comprimir 22,56. La fusión vale +4,50 F1 sobre lo que corría.
41
+ //
42
+ // No es que lo semántico sea mejor —empatan en global— sino que cubre el punto
43
+ // ciego del léxico: cuando la pregunta NO comparte vocabulario con la respuesta,
44
+ // BM25 cae a 18,60 y los embeddings aguantan 24,28; cuando sí lo comparte manda
45
+ // BM25 (29,33 vs 23,58). La fusión se queda con los dos (24,05 / 32,73).
46
+ //
47
+ // POR DEFECTO APAGADO, y no por prudencia: por lo que salió al MEDIRLO.
48
+ //
49
+ // · La primera carga cuesta una descarga de 235 MB (fp16, el camino rápido) o
50
+ // 118 MB (q8, el de respaldo). Para quien usa un proveedor externo
51
+ // (providers/api.js) eso es una descarga que hoy NO EXISTE: pasa de 0 a
52
+ // 235 MB sólo por empaquetar mejor el contexto. Y para quien usa el modelo
53
+ // local son ~+28 % sobre lo que ya descarga.
54
+ // · Sólo compensa con adaptador WebGPU. Sin él se cae a wasm, que mide ~9×
55
+ // más lento y se nota en CADA turno, no sólo en el primero.
56
+ // · A cambio, con WebGPU el turno en régimen es una fracción de segundo y la
57
+ // segunda sesión no descarga nada (transformers.js cachea el modelo).
58
+ //
59
+ // O sea: la ganancia de recuperación es real y grande (+4,50 F1), pero el precio
60
+ // no es despreciable y depende del equipo de quien lo usa. Así que se cablea
61
+ // entero, se deja probado, y se enciende a petición. Con la bandera apagada NI
62
+ // SIQUIERA se importa embed.js: el coste de tenerlo cableado es exactamente cero.
63
+ //
64
+ // localStorage.setItem('elffuss.semantic', 'on') → BM25 + embeddings
65
+ // localStorage.setItem('elffuss.semantic', 'off') → sólo BM25
66
+ // localStorage.removeItem('elffuss.semantic') → sólo BM25 (por defecto)
67
+ function semanticoOn() {
68
+ try { return localStorage.getItem('elffuss.semantic') === 'on'; } catch { return false; }
69
+ }
70
+
71
+ // SEM_BUDGET alto A PROPÓSITO, y esto es lo contrario de lo que parece.
72
+ //
73
+ // acer-core deriva el tamaño de bloque como SB = ceil(nLíneas / SEM_BUDGET) y
74
+ // rearma los bloques desde el principio del historial EN CADA TURNO. Así que
75
+ // SEM_BUDGET no acota el coste: acota la RESOLUCIÓN, y de paso decide si la
76
+ // caché por contenido sirve para algo. Con un SEM_BUDGET bajo, cada vez que el
77
+ // historial crece lo justo para que SB suba de entero, TODOS los bloques cambian
78
+ // de texto y la caché falla entera; y como el bloque además engorda con la
79
+ // sesión, el turno se encarece con el tiempo. Con SEM_BUDGET por encima del
80
+ // número de líneas, SB vale 1: un bloque = una línea, el texto de una línea ya
81
+ // codificada NO cambia nunca, y un turno sólo paga las líneas NUEVAS.
82
+ //
83
+ // Medido en el navegador sobre una sesión de agente de 8 turnos (cada turno =
84
+ // una tool-call + 90 líneas de resultado, 752 líneas al final), con la caché
85
+ // viva entre turnos y el mismo modelo (coste relativo por turno, 1,0 = el mejor
86
+ // turno observado del barrido):
87
+ //
88
+ // SEM_BUDGET codificaciones por turno coste por turno
89
+ // 64 ~64, casi todo recodificado 5,2× → 12,2× ← y SUBIENDO
90
+ // 400 49–236, a saltos 0,4× – 8,0× ← picos
91
+ // 4000 92, sólo lo nuevo 2,9× → 0,8× ← plano
92
+ //
93
+ // Con 4000 cada línea se codifica UNA vez en toda la sesión (740 codificaciones
94
+ // para 752 líneas: las repetidas salen gratis por la caché) y el turno se ABARATA
95
+ // según avanza la sesión en vez de encarecerse. Es exactamente el «indexar al
96
+ // escribir» que describe la cabecera de acer-core. El valor está alineado con el
97
+ // tope de la caché de embed.js para que no se desaloje justo lo que el turno
98
+ // siguiente va a volver a pedir.
99
+ const SEM_BUDGET = 4000;
100
+
101
  const RECENT = 6;
102
  const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
103
 
 
147
 
148
  export function packHistory(history, budgetTokens = 2200) {
149
  if (!history.length) return history;
150
+ if (acerUnificado()) return packHistoryACER(history, budgetTokens).messages;
151
  // los recientes también se recortan por mensaje: un solo tool-result gigante
152
  // (README de un repo grande) reventaba el contexto → «Too many tokens».
153
  const recent = history.slice(-RECENT).map(clampMsg);
 
180
  if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
181
  return [...packed, ...recent];
182
  }
183
+
184
+ /**
185
+ * Igual que packHistory pero por la vía HÍBRIDA (léxica + semántica) cuando la
186
+ * bandera está encendida. Es la que deben usar los proveedores.
187
+ *
188
+ * Degrada al empaquetado de siempre —el mismo, byte a byte— si la bandera está
189
+ * apagada, si el modelo de embeddings no está o no carga, o si codificar falla.
190
+ * Ese camino de vuelta es deliberado: que no haya modelo no puede significar que
191
+ * la app deje de funcionar, sólo que empaqueta como hoy.
192
+ *
193
+ * `packHistory` sigue exportada y síncrona por compatibilidad.
194
+ */
195
+ export async function packHistoryAsync(history, budgetTokens = 2200) {
196
+ if (!history.length) return history;
197
+ if (!acerUnificado() || !semanticoOn()) return packHistory(history, budgetTokens);
198
+ try {
199
+ // Import DINÁMICO: con la bandera apagada, embed.js —y con él
200
+ // transformers.js y el modelo— no se piden nunca.
201
+ const { embed, embedCache } = await import('./embed.js');
202
+ const r = await packHistoryACERHybrid(history, budgetTokens, {
203
+ embed, cache: embedCache(), SEM_BUDGET,
204
+ });
205
+ return r.messages;
206
+ } catch {
207
+ return packHistory(history, budgetTokens);
208
+ }
209
+ }
js/embed.js ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Lado SEMÁNTICO del motor de contexto (el compañero de acer-core.js).
2
+ //
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // POR QUÉ EXISTE
5
+ //
6
+ // acer-core.js ya trae la vía híbrida completa (`packHistoryACERHybrid`): BM25 y
7
+ // embeddings fusionados por rangos. Lo que le faltaba era una función `embed`,
8
+ // así que degradaba a la vía léxica y en producción sólo corría BM25.
9
+ //
10
+ // Medido en LoCoMo (200 preguntas, F1 del propio repo, modelo real respondiendo,
11
+ // mismo presupuesto de tokens):
12
+ //
13
+ // contexto COMPLETO sin comprimir ..... 22,56
14
+ // BM25 (lo que corría hasta ahora) .... 23,59
15
+ // embeddings solos .................... 23,95
16
+ // fusión de RANGOS (esto) ............. 28,09 ← +4,50 sobre BM25
17
+ //
18
+ // El motivo de que sume no es que lo semántico sea «mejor»: en global EMPATAN
19
+ // (23,59 vs 23,95). Hacen trabajos DISTINTOS. Partiendo las mismas preguntas por
20
+ // solape de vocabulario entre la pregunta y la respuesta:
21
+ //
22
+ // sin solape (n=107) con solape (n=93)
23
+ // BM25 ...................... 18,60 29,33
24
+ // embeddings ................ 24,28 23,58
25
+ // fusión de rangos .......... 24,05 32,73
26
+ //
27
+ // Cuando la pregunta NO nombra literalmente lo que busca, BM25 se hunde (18,60)
28
+ // y lo semántico aguanta (24,28). Cuando sí lo nombra, manda BM25 (29,33). La
29
+ // fusión se queda con los dos. Lo semántico no sustituye al léxico: le cubre el
30
+ // punto ciego. Eso —y no una ganancia global— es lo que se está cableando aquí.
31
+ //
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // MODELO ELEGIDO — Xenova/multilingual-e5-small, dtype q8 (118 MB)
34
+ //
35
+ // Requisitos duros y cómo los cumple:
36
+ // · transformers.js: es la conversión ONNX oficial del mantenedor de la
37
+ // librería → carga con el MISMO `pipeline()` que ya usa providers/onnx.js.
38
+ // · pequeño: 118 MB en q8. Es la variante más liviana del repo (el q4 pesa
39
+ // MÁS —398 MB— porque la matriz de embeddings de un vocabulario de 250k no
40
+ // se cuantiza igual de bien; aquí «menos bits» no significa menos disco).
41
+ // · multilingüe: tokenizador XLM-R, 100 idiomas. Los prompts van en castellano
42
+ // y el código en inglés, y ese cruce es exactamente el punto ciego léxico.
43
+ // · licencia: MIT (intfloat/multilingual-e5-small aguas arriba).
44
+ // · ventana de 512 tokens, que es la que necesitan los bloques que arma
45
+ // acer-core; no es un modelo de frases sueltas.
46
+ //
47
+ // Descartados, y por qué:
48
+ // · Xenova/all-MiniLM-L6-v2 (23 MB, Apache-2.0) — 5× más barato, pero SÓLO
49
+ // inglés. Justo el caso que venimos a cubrir es el castellano.
50
+ // · Xenova/paraphrase-multilingual-MiniLM-L12-v2 (118 MB, Apache-2.0) — mismo
51
+ // peso, pero está afinado para paráfrasis de FRASES con ventana de 128
52
+ // tokens: trocearía los bloques por la mitad.
53
+ // · onnx-community/embeddinggemma-300m-ONNX (175 MB en q4f16 + pesos externos)
54
+ // — mejor calidad, pero 1,5× de descarga y licencia Gemma (uso comercial
55
+ // permitido pero con política de uso y obligaciones de redistribución), no
56
+ // una licencia comercial limpia.
57
+ // · ibm-granite/granite-embedding-107m-multilingual (Apache-2.0) — sólo
58
+ // publica ONNX en fp32: 428 MB, 3,6× la descarga.
59
+ // · minishlab/potion-multilingual-128M (MIT) — estático y rapidísimo, pero su
60
+ // único ONNX pesa 512 MB y transformers.js no lo soporta de serie.
61
+ //
62
+ // DTYPE SEGÚN EL DISPOSITIVO — medido, no supuesto. Mismo modelo, mismo lote de
63
+ // bloques, sólo cambiando dtype y dispositivo (coste relativo, 1,0 = el mejor):
64
+ //
65
+ // fp16 / WebGPU .... 1,0× (235 MB) ← el elegido cuando hay adaptador
66
+ // q4f16/ WebGPU .... 0,6× (205 MB) con bloques medianos; ver punto 2
67
+ // q8 / wasm ...... 8,9× (118 MB) ← la red de seguridad
68
+ // fp32 / wasm ...... 11,0× (470 MB)
69
+ // q8 / WebGPU .... 14,1× (118 MB) ← PEOR que en CPU
70
+ //
71
+ // Dos cosas que contradicen la intuición y por eso van escritas:
72
+ // 1. Los enteros de 8 bits NO se aceleran en WebGPU: ORT-web no tiene kernels
73
+ // para esos operadores y acaba yendo y viniendo de la CPU. El fichero más
74
+ // pequeño resulta ser el MÁS LENTO en la GPU. Elegir dtype por tamaño de
75
+ // descarga, sin medir, habría dado la peor combinación posible.
76
+ // 2. Con textos CORTOS —que es nuestro caso, ver SEM_BUDGET en context.js— el
77
+ // 4-bit se da la vuelta y pierde contra fp16 (2,6× más lento en líneas
78
+ // sueltas): a esas longitudes se paga más por deshacer la cuantización que
79
+ // lo que se ahorra en ancho de banda. Por eso fp16 y no q4f16, aunque en
80
+ // bloques grandes q4f16 gane.
81
+ //
82
+ // De ahí la regla: fp16 si hay adaptador WebGPU de verdad, y q8/wasm como red de
83
+ // seguridad. La red de seguridad FUNCIONA pero es ~9× más lenta, hasta el punto
84
+ // de notarse en cada turno, y ése es uno de los motivos de que el lado semántico
85
+ // vaya apagado por defecto (ver context.js).
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+
88
+ import { createEmbedCache } from './acer-core.js';
89
+
90
+ export const EMBED_MODEL = {
91
+ id: 'Xenova/multilingual-e5-small',
92
+ dims: 384,
93
+ label: 'multilingual-e5-small',
94
+ // dtype → fichero → descarga
95
+ webgpu: { dtype: 'fp16', sizeMB: 235 },
96
+ wasm: { dtype: 'q8', sizeMB: 118 },
97
+ };
98
+
99
+ // La ventana del modelo son 512 tokens; recortar antes de tokenizar evita pagar
100
+ // el troceado de texto que el propio modelo va a descartar.
101
+ const MAX_CHARS = 2000;
102
+
103
+ // Lotes pequeños: transformers.js rellena cada lote hasta el más largo, así que
104
+ // un lote gigante hace pagar la longitud del peor elemento por todos.
105
+ const BATCH = 16;
106
+
107
+ // e5 se entrenó SIEMPRE con prefijo ('query: ' / 'passage: '), nunca con texto
108
+ // pelado. acer-core llama a la misma `embed` para la pregunta y para los
109
+ // bloques, y no hay forma de distinguirlos sin tocar el núcleo; los autores del
110
+ // modelo documentan usar 'query: ' en AMBOS lados para uso simétrico. Quitarlo
111
+ // del todo sería peor: el modelo nunca vio esa distribución.
112
+ const PREFIX = 'query: ';
113
+
114
+ let pipePromise = null;
115
+ export let backend = null; // {device, dtype, sizeMB} una vez resuelto
116
+
117
+ /**
118
+ * Carga PEREZOSA: nada de esto se toca en el arranque. El módulo entero se
119
+ * importa dinámicamente desde context.js sólo cuando la bandera está encendida,
120
+ * y el modelo no se descarga hasta la primera petición que de verdad lo use.
121
+ */
122
+ function getPipe(onProgress) {
123
+ if (!pipePromise) {
124
+ pipePromise = (async () => {
125
+ const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
126
+ // navigator.gpu puede EXISTIR sin adaptador real (mismo cuidado que en
127
+ // providers/onnx.js): comprobar el adaptador, no el objeto. Elegir mal
128
+ // aquí significa descargar 235 MB para acabar corriendo en CPU.
129
+ let device = 'wasm';
130
+ if (navigator.gpu) {
131
+ try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
132
+ catch { device = 'wasm'; }
133
+ }
134
+ const cfg = { device, ...EMBED_MODEL[device] };
135
+ const pipe = await tf.pipeline('feature-extraction', EMBED_MODEL.id, {
136
+ device,
137
+ dtype: cfg.dtype,
138
+ progress_callback: onProgress,
139
+ });
140
+ backend = cfg; // sólo cuando de verdad está listo: isReady() no miente
141
+ return pipe;
142
+ })().catch(e => {
143
+ pipePromise = null; // que un fallo de red no deje el módulo muerto
144
+ backend = null;
145
+ throw e;
146
+ });
147
+ }
148
+ return pipePromise;
149
+ }
150
+
151
+ /** Precarga opcional (p.ej. desde Ajustes) sin bloquear ningún turno. */
152
+ export function warmup(onProgress) { return getPipe(onProgress); }
153
+
154
+ /** ¿Está ya cargado? Sirve para decidir si un turno va a pagar la descarga. */
155
+ export function isReady() { return backend !== null; }
156
+
157
+ /**
158
+ * embed(textos) → vectores normalizados de 384 dimensiones.
159
+ * Contrato exacto que espera `packHistoryACERHybrid`: si esto lanza, acer-core
160
+ * se va por la vía léxica sin romperse ni avisar.
161
+ */
162
+ export async function embed(texts) {
163
+ if (!texts || !texts.length) return [];
164
+ const pipe = await getPipe();
165
+ const out = [];
166
+ for (let i = 0; i < texts.length; i += BATCH) {
167
+ const batch = texts.slice(i, i + BATCH)
168
+ .map(t => PREFIX + String(t == null ? '' : t).slice(0, MAX_CHARS));
169
+ const t = await pipe(batch, { pooling: 'mean', normalize: true });
170
+ for (const v of t.tolist()) out.push(Float32Array.from(v));
171
+ }
172
+ return out;
173
+ }
174
+
175
+ // Caché de sesión POR CONTENIDO. No es una optimización: es lo que hace viable
176
+ // la idea. acer-core rearma los bloques desde el principio del historial en cada
177
+ // turno, así que sin caché un texto que ya se codificó en el turno 3 se volvería
178
+ // a codificar en el 4, el 5 y el 20 — el coste crecería con el CUADRADO de los
179
+ // turnos. Con caché, cada bloque se codifica una vez por sesión y un turno sólo
180
+ // paga los bloques nuevos.
181
+ // El tope es holgado a propósito: acer-core trocea el historial ENTERO en cada
182
+ // turno, así que si la caché desaloja entradas que el turno siguiente vuelve a
183
+ // pedir, se recodifica gratis. Son vectores de 384 flotantes: ~12 MB llenos.
184
+ let cache = null;
185
+ export function embedCache() {
186
+ if (!cache) cache = createEmbedCache(embed, { max: 8000 });
187
+ return cache;
188
+ }
189
+
190
+ /** Para diagnóstico/ajustes: cuántos textos lleva cacheados la sesión. */
191
+ export function embedCacheSize() { return cache ? cache.size() : 0; }
js/main.js CHANGED
@@ -559,9 +559,9 @@ function renderSettings() {
559
  brCard.innerHTML =
560
  `<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
561
  `<p class="muted" style="font-size:.72rem;margin:6px 0">Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.</p>` +
562
- `<a class="prov-use" style="text-decoration:none;display:inline-block" href="https://elffuss-code.utopiaia.com/bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
563
  `<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
564
- Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="https://elffuss-code.utopiaia.com/bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
565
  `</details>` +
566
  `<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
567
  `<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
 
559
  brCard.innerHTML =
560
  `<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
561
  `<p class="muted" style="font-size:.72rem;margin:6px 0">Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.</p>` +
562
+ `<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
563
  `<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
564
+ Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
565
  `</details>` +
566
  `<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
567
  `<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
js/providers/api.js CHANGED
@@ -3,7 +3,7 @@
3
  // - kind 'anthropic' → /v1/messages (Claude)
4
  // Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
5
  // no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
6
- import { packHistory } from '../context.js';
7
 
8
  let cfg = null;
9
  export let name = 'API';
@@ -26,9 +26,13 @@ export async function chat(history, system, onToken = () => {}) {
26
  async function openaiChat(history, system, onToken) {
27
  const headers = { 'Content-Type': 'application/json' };
28
  if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
 
 
 
 
29
  const body = {
30
  model: cfg.model,
31
- messages: [{ role: 'system', content: system }, ...packHistory(history, 3000)],
32
  stream: true,
33
  max_tokens: cfg.maxTokens || 1024,
34
  };
@@ -53,6 +57,7 @@ async function openaiChat(history, system, onToken) {
53
 
54
  // ---- Anthropic Messages ----
55
  async function anthropicChat(history, system, onToken) {
 
56
  const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
57
  method: 'POST',
58
  headers: {
@@ -66,7 +71,7 @@ async function anthropicChat(history, system, onToken) {
66
  max_tokens: cfg.maxTokens || 1024,
67
  system,
68
  stream: true,
69
- messages: forAnthropic(packHistory(history, 3000)),
70
  }),
71
  });
72
  if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
 
3
  // - kind 'anthropic' → /v1/messages (Claude)
4
  // Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
5
  // no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
6
+ import { packHistoryAsync } from '../context.js';
7
 
8
  let cfg = null;
9
  export let name = 'API';
 
26
  async function openaiChat(history, system, onToken) {
27
  const headers = { 'Content-Type': 'application/json' };
28
  if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
29
+ // packHistoryAsync: el empaquetado puede tener que codificar embeddings, así
30
+ // que se resuelve ANTES de armar el cuerpo. Con el lado semántico apagado
31
+ // devuelve exactamente lo mismo que la versión síncrona de siempre.
32
+ const packed = await packHistoryAsync(history, 3000);
33
  const body = {
34
  model: cfg.model,
35
+ messages: [{ role: 'system', content: system }, ...packed],
36
  stream: true,
37
  max_tokens: cfg.maxTokens || 1024,
38
  };
 
57
 
58
  // ---- Anthropic Messages ----
59
  async function anthropicChat(history, system, onToken) {
60
+ const packed = await packHistoryAsync(history, 3000);
61
  const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
62
  method: 'POST',
63
  headers: {
 
71
  max_tokens: cfg.maxTokens || 1024,
72
  system,
73
  stream: true,
74
+ messages: forAnthropic(packed),
75
  }),
76
  });
77
  if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
js/providers/onnx.js CHANGED
@@ -3,7 +3,7 @@
3
  // (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
4
  // genera basura vía WebGPU incluso con shader-f16.
5
  import { MODEL } from '../model-config.js';
6
- import { packHistory } from '../context.js';
7
 
8
  export const name = MODEL.label;
9
  let generator = null, TextStreamer = null;
@@ -45,7 +45,7 @@ export async function chat(history, system, onToken = () => {}) {
45
  // ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
46
  // contexto largo); el tope POR MENSAJE (context.js) evita que un README
47
  // gigante dispare «Too many tokens requested».
48
- const messages = [{ role: 'system', content: system }, ...packHistory(history, 5000)];
49
  const streamer = new TextStreamer(generator.tokenizer, {
50
  skip_prompt: true,
51
  skip_special_tokens: true,
 
3
  // (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
4
  // genera basura vía WebGPU incluso con shader-f16.
5
  import { MODEL } from '../model-config.js';
6
+ import { packHistoryAsync } from '../context.js';
7
 
8
  export const name = MODEL.label;
9
  let generator = null, TextStreamer = null;
 
45
  // ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
46
  // contexto largo); el tope POR MENSAJE (context.js) evita que un README
47
  // gigante dispare «Too many tokens requested».
48
+ const messages = [{ role: 'system', content: system }, ...(await packHistoryAsync(history, 5000))];
49
  const streamer = new TextStreamer(generator.tokenizer, {
50
  skip_prompt: true,
51
  skip_special_tokens: true,