Spaces:
Running
Running
File size: 41,737 Bytes
73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc d305959 73063cc | 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 | // RLM — Recursive Language Models (el modo «Hard Work» de Elffuss).
//
// Idea (Zhang et al., MIT, 2025): un modelo PEQUEÑO y local puede manejar
// entradas ENORMES si no las mete todas de golpe en su ventana, sino que
// TROCEA el material, pregunta a cada trozo (map = una sub-llamada al mismo
// modelo) y FUNDE las respuestas (reduce). Si lo fundido aún no cabe, se
// recurre. El modelo raíz nunca ve todo el contexto a la vez: trabaja por
// partes, como un humano leyendo un tocho capítulo a capítulo.
//
// Trade-off honesto: cambia LATENCIA por CAPACIDAD. Son muchas inferencias del
// modelo local (que es lento), así que es para tareas que PUEDEN esperar —
// auditar, resumir o revisar una carpeta/repo entero— no para tiempo real.
//
// El motor es agnóstico del proveedor: solo necesita algo con
// `chat(history, system, onToken)` (lo cumplen rules/onnx/litert/api).
const CHARS_PER_TOK = 4; // misma estimación que context.js
const DEFAULTS = {
chunkTokens: 1800, // tamaño de cada trozo (deja hueco al prompt del modelo)
overlapTokens: 120, // solape para no cortar una idea a la mitad entre trozos
maxDepth: 3, // recursión máxima del reduce jerárquico
maxChunks: 80, // techo DURO de sub-llamadas del map (coste/tiempo)
callTimeoutMs: 120000, // guarda por sub-llamada (un motor colgado no bloquea todo)
};
const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s);
// Trocea respetando límites de línea cuando es posible (no parte a mitad de
// palabra si hay un salto cerca del corte).
function chunk(text, chunkChars, overlapChars) {
const step = Math.max(1, chunkChars - overlapChars);
const out = [];
for (let i = 0; i < text.length; i += step) {
let end = Math.min(i + chunkChars, text.length);
if (end < text.length) {
const nl = text.lastIndexOf('\n', end);
if (nl > i + chunkChars * 0.6) end = nl; // corta en salto si cae en el último 40%
}
out.push(text.slice(i, end));
if (end >= text.length) break;
}
return out;
}
// sub(): UNA inferencia del modelo local sobre un fragmento. Es la recursión.
async function sub(provider, system, prompt, timeoutMs) {
const run = provider.chat([{ role: 'user', content: prompt }], system, () => {});
if (!timeoutMs) return run;
let to;
const guard = new Promise((_, rej) => { to = setTimeout(() => rej(new Error('sub-llamada agotó el tiempo')), timeoutMs); });
try { return await Promise.race([run, guard]); }
finally { clearTimeout(to); }
}
const MAP_SYS = 'Eres un analista minucioso. Te doy UN fragmento de un material más grande y una pregunta. Responde SOLO con lo que ESTE fragmento aporte a la pregunta: datos concretos, en pocas frases, con cita textual breve si procede. No inventes lo que no esté en el fragmento. Si el fragmento no aporta nada a la pregunta, responde exactamente: NADA';
const REDUCE_SYS = 'Eres un sintetizador. Te doy una pregunta y varios hallazgos extraídos de fragmentos distintos del MISMO material. Fúndelos en una única respuesta, coherente y completa, a la pregunta. Resuelve solapes, no repitas, no inventes lo que no esté en los hallazgos. Responde en el idioma de la pregunta.';
// Motor RLM. Devuelve { answer, chunks, kept }.
export async function hardWork({ question, context, provider, onProgress = () => {}, opts = {} }) {
const o = { ...DEFAULTS, ...opts };
if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo local o remoto arriba).');
const q = String(question || '').trim();
const ctx = String(context || '').trim();
if (!q) throw new Error('Hard Work: dime qué quieres saber del material.');
if (!ctx) throw new Error('Hard Work: no encontré material que procesar.');
const chunkChars = o.chunkTokens * CHARS_PER_TOK;
let parts = chunk(ctx, chunkChars, o.overlapTokens * CHARS_PER_TOK);
const truncated = parts.length > o.maxChunks;
if (truncated) parts = parts.slice(0, o.maxChunks);
onProgress({ phase: 'plan', chunks: parts.length, chars: ctx.length, truncated });
// MAP — un modelo local es UN motor, así que las sub-llamadas van en serie.
const findings = [];
for (let i = 0; i < parts.length; i++) {
onProgress({ phase: 'map', i: i + 1, n: parts.length });
let ans;
try { ans = await sub(provider, MAP_SYS, `PREGUNTA:\n${q}\n\nFRAGMENTO ${i + 1}/${parts.length}:\n${parts[i]}`, o.callTimeoutMs); }
catch { ans = 'NADA'; }
ans = String(ans || '').trim();
if (ans && !/^NADA\b/i.test(ans)) findings.push(`[${i + 1}] ${ans}`);
}
onProgress({ phase: 'mapped', kept: findings.length, of: parts.length });
if (!findings.length) return { answer: 'Revisé el material por partes y no encontré nada relevante para eso.', chunks: parts.length, kept: 0, truncated };
const answer = await reduce(provider, q, findings, o, onProgress, 0);
return { answer: String(answer || '').trim(), chunks: parts.length, kept: findings.length, truncated };
}
// REDUCE jerárquico: si los hallazgos caben, funde en uno; si no, agrupa,
// funde cada grupo y recurre con las respuestas parciales.
async function reduce(provider, q, findings, o, onProgress, depth) {
const budget = o.chunkTokens * CHARS_PER_TOK * 2; // el reduce puede ver algo más que un trozo
const joined = findings.join('\n');
if (joined.length <= budget || depth >= o.maxDepth) {
onProgress({ phase: 'reduce', depth, final: true });
return sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS:\n${clip(joined, budget)}`, o.callTimeoutMs);
}
const groups = [];
let cur = [];
let len = 0;
for (const f of findings) {
if (len + f.length + 1 > budget && cur.length) { groups.push(cur); cur = []; len = 0; }
cur.push(f); len += f.length + 1;
}
if (cur.length) groups.push(cur);
onProgress({ phase: 'reduce', depth, groups: groups.length, final: false });
const partials = [];
for (let g = 0; g < groups.length; g++) {
onProgress({ phase: 'reduce-group', depth, g: g + 1, n: groups.length });
const p = await sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS (grupo ${g + 1}/${groups.length}):\n${groups[g].join('\n')}`, o.callTimeoutMs);
partials.push(`[g${g + 1}] ${String(p || '').trim()}`);
}
return reduce(provider, q, partials, o, onProgress, depth + 1);
}
// ── Hard Work · CREACIÓN: borrador → autocrítica → reescritura ──────────────
// El hermano creativo de RLM. RLM gasta cómputo LEYENDO material enorme; esto
// lo gasta MEJORANDO lo que el modelo crea: en vez de una sola pasada, el
// modelo pequeño hace un borrador, se autocritica sin piedad y se reescribe
// corrigiendo sus propios fallos. Sube la calidad de un modelo modesto sin
// cambiar de modelo — solo pensando más veces.
const CREATE_SYS = 'Eres Elffuss creando una app web. Respondes SOLO con un documento HTML completo y autocontenido (CSS y JS inline, fondo oscuro). Si es un juego o algo visual, usa <canvas> y que se vea vivo (color, movimiento, efectos). Nada de texto fuera del HTML.';
const CRITIQUE_SYS = 'Eres un crítico de videojuegos y front-end, implacable pero útil. Te doy el código de una app/juego web. Enumera 3 a 6 defectos CONCRETOS y accionables: bugs de lógica, controles que faltan, colisiones mal hechas, falta de condición de fin o de puntuación, jugabilidad sosa y pobreza visual (sin color, sin efectos, estático). Sé específico y breve. NO reescribas el código: solo la lista de defectos.';
const REWRITE_SYS = 'Eres Elffuss mejorando tu propia app. Te doy el HTML actual y una crítica. Devuelve SOLO el HTML completo y autocontenido MEJORADO que corrige TODOS los puntos de la crítica, conservando lo que ya funcionaba y subiendo el nivel visual (color, brillo, partículas si encaja). Sin explicaciones fuera del HTML.';
// Saca el documento HTML de la respuesta del modelo (fence ```html o directo).
export function extractHtml(text) {
const s = String(text || '');
const fence = s.match(/```html\s*([\s\S]*?)```/i) || s.match(/```\s*([\s\S]*?)```/);
const body = fence ? fence[1] : s;
const m = body.match(/<!doctype[\s\S]*<\/html>/i) || body.match(/<html[\s\S]*<\/html>/i);
return (m ? m[0] : body).trim();
}
const CONT_SYS = 'Continúas un documento HTML/JS que se cortó a media escritura. Devuelve SOLO la continuación EXACTA desde donde termina —sin repetir nada de lo ya escrito, sin reabrir <html> ni <script>, sin explicaciones— hasta cerrar todas las etiquetas y </html>.';
// Genera y, si el modelo se quedó sin tokens a media escritura (el HTML no
// cierra </html>), pide la continuación desde la cola y la cose. Así el techo
// de 1024 tokens deja de truncar apps grandes: se completan por tramos.
async function genComplete(provider, system, prompt, { maxCont = 4 } = {}) {
let full = String(await provider.chat([{ role: 'user', content: prompt }], system, () => {}) || '');
for (let i = 0; i < maxCont && !/<\/html>/i.test(full); i++) {
const tail = full.slice(-1400);
let cont = String(await provider.chat(
[{ role: 'user', content: `FINAL de lo escrito hasta ahora (continúa desde aquí, sin repetirlo):\n${tail}` }],
CONT_SYS, () => {}) || '');
// Defensa: si el modelo reabre el documento en vez de continuar, recórtalo.
cont = cont.replace(/^[\s\S]*?<!doctype html>/i, '').replace(/^[\s\S]*?<html[^>]*>/i, '');
if (!cont.trim()) break;
full += cont;
}
return full;
}
// ── Jurado deliberante (Reasoning Jury, arXiv 2608.12585) ───────────────────
// Un juez único detecta mal los defectos: se inventa unos y se le escapan
// otros (lo vimos con el crítico único de Hard Work). El paper sustituye al
// juez por un JURADO que delibera: cada jurado propone defectos, luego ve los
// de los demás y VOTA cuáles son reales, y un moderador se queda con los que
// tienen apoyo. Aquí el jurado es un mismo modelo con lentes distintas —no
// modelos distintos, que no caben en el navegador—, así que el efecto es
// menor que en el paper; por eso se mide, no se asume.
const JURY_LENSES = [
{ key: 'logica', name: 'lógica', ask: 'errores de lógica y de estado: variables sin definir, condiciones al revés, bucles que no avanzan, una condición de fin que nunca se cumple, cuentas mal hechas' },
{ key: 'interaccion', name: 'interacción', ask: 'controles y respuesta: entradas que no hacen nada, colisiones que no detectan, falta de arranque o de reinicio, nada que indique al usuario qué pasa' },
{ key: 'visual', name: 'presentación', ask: 'lo que se ve: elementos con tamaño cero o fuera de pantalla, todo del mismo color, nada dibujado, texto ilegible' },
];
const asLines = t => String(t || '').split('\n')
.map(l => l.replace(/^\s*(?:[-*\u2022]|\d+[.)])\s*/, '').trim())
.filter(l => l.length > 12 && l.length < 220);
const normDefect = d => d.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(w => w.length > 3).slice(0, 6).join(' ');
// Devuelve { defects:[{text,votes,jurors}], rounds } sobre un artefacto.
// Normaliza espacios para comparar una cita con el código real.
const flat = t => String(t).replace(/\s+/g, ' ').trim();
// Elige las lentes que TIENEN algo que mirar en este artefacto. Pasarle
// «presentación» a una función pura de cálculo no aporta nada: ese jurado o
// calla o repite lo que dijo el de lógica, y los repetidos se comían las
// plazas de candidatos.
function lensesFor(material) {
const tieneUI = /<canvas|<button|<input|<form|document\.|addEventListener|innerHTML|querySelector/i.test(material);
const tienePintado = /<canvas|getContext|fillRect|drawImage|style\.|css/i.test(material);
const out = [JURY_LENSES[0]]; // lógica: siempre
if (tieneUI) out.push(JURY_LENSES[1]); // interacción
if (tienePintado) out.push(JURY_LENSES[2]); // presentación
if (out.length === 1) out.push(JURY_LENSES_EXTRA[0], JURY_LENSES_EXTRA[1]); // código puro
else if (out.length === 2) out.push(JURY_LENSES_EXTRA[0]);
return out;
}
// Lentes para código sin interfaz, donde «interacción» y «presentación» no pintan nada.
const JURY_LENSES_EXTRA = [
{ key: 'datos', name: 'datos y tipos', ask: 'tipos y datos: conversiones implícitas, valores que llegan como texto cuando se esperan números, undefined/NaN, claves que cambian de tipo' },
{ key: 'bordes', name: 'casos límite', ask: 'casos límite y validación: entradas vacías, negativas o fuera de rango, ausencia de comprobaciones, resultados absurdos que nadie detiene' },
];
export async function juryReview({ artifact, provider, onProgress = () => {}, lenses = null, requireEvidence = true }) {
const dropped = [];
if (!provider?.chat) throw new Error('el jurado necesita un cerebro cargado');
const material = String(artifact || '').slice(0, 6000);
lenses = lenses || lensesFor(material);
onProgress({ phase: 'jury-lenses', lenses: lenses.map(l => l.name) });
// Ronda 1 — cada jurado propone defectos desde SU lente, sin ver a los demás.
const proposals = [];
for (const L of lenses) {
onProgress({ phase: 'jury-propose', lens: L.name });
const sys = `Eres un revisor especializado en ${L.ask}. Te doy el código de una app web. Enumera SOLO los defectos REALES que veas desde tu especialidad. Por cada uno escribe DOS líneas seguidas:
DEFECTO: <qué está mal, en una línea>
PRUEBA: <copia LITERAL del fragmento de código que lo demuestra, tal cual aparece>
Máximo 4 defectos. Si no ves ninguno, escribe NINGUNO. La PRUEBA debe estar copiada del código palabra por palabra; si no puedes copiarla, no incluyas ese defecto.`;
const out = await provider.chat([{ role: 'user', content: 'CÓDIGO:\n' + material }], sys, () => {});
// Emparejar DEFECTO con su PRUEBA y COMPROBAR que la cita existe de verdad
// en el código. Es un filtro determinista: si el modelo no sabe citar, se
// lo ha inventado. (El modelo verifica mucho mejor de lo que propone, pero
// esto ni siquiera necesita al modelo.)
const raw = String(out).split('\n').map(x => x.trim());
const found = [];
for (let i = 0; i < raw.length; i++) {
const dm = raw[i].match(/^DEFECTO:\s*(.+)/i);
if (!dm) continue;
const pm = (raw[i + 1] || '').match(/^PRUEBA:\s*(.+)/i);
const texto = dm[1].trim();
if (texto.length < 12) continue;
if (!requireEvidence) { found.push({ texto, cita: pm ? pm[1].trim() : '' }); continue; }
const cita = pm ? pm[1].trim().replace(/^[`'"]|[`'"]$/g, '') : '';
if (cita.length >= 8 && flat(material).includes(flat(cita))) found.push({ texto, cita });
else dropped.push({ lens: L.name, texto, cita: cita.slice(0, 60) });
}
proposals.push({ lens: L, found });
}
// Candidatos únicos (los jurados repiten el mismo defecto con otras palabras).
// Deduplicar por la CITA de código, no por las palabras: dos jurados describen
// el mismo fallo con frases distintas («el bucle empieza en 1» y «ignora el
// primer elemento») y el deduplicador léxico los dejaba pasar como dos, con lo
// que la lista de candidatos se llenaba de repetidos. Si señalan el mismo
// fragmento de código, es el mismo defecto.
const seen = new Map();
for (const p of proposals) for (const d of p.found) {
const k = d.cita && d.cita.length >= 8 ? 'c:' + flat(d.cita).slice(0, 80) : 't:' + normDefect(d.texto);
if (k && !seen.has(k)) seen.set(k, d.texto);
}
const candidates = [...seen.values()].slice(0, 12);
onProgress({ phase: 'jury-candidates', n: candidates.length });
if (!candidates.length) return { defects: [], candidates: 0, dropped };
// Ronda 2 — DELIBERACIÓN: cada jurado ve TODOS los candidatos (incluidos los
// ajenos) y vota cuáles son de verdad. Aquí es donde puede retirar el suyo.
const votes = candidates.map(() => 0);
for (const L of lenses) {
onProgress({ phase: 'jury-vote', lens: L.name });
const list = candidates.map((c, i) => `${i + 1}. ${c}`).join('\n');
// OJO con el formato: poner "N: SI" como plantilla hacía que el modelo
// escribiera literalmente la letra N y no se parseara NINGÚN voto (el
// jurado parecía rechazarlo todo). Los modelos pequeños copian el ejemplo
// tal cual: hay que darles números de verdad.
const sys = `Eres un revisor riguroso (especialidad: ${L.name}). Te doy el código y una lista numerada de defectos propuestos por otros revisores. Para CADA número, mira el código y di si el defecto es REAL. Responde SOLO con una línea por defecto, con su número y SI o NO. Así:
1: SI
2: NO
3: SI
Nada más: ni explicaciones ni texto extra. Ante la duda, NO.`;
const out = await provider.chat(
[{ role: 'user', content: 'CÓDIGO:\n' + material + '\n\nDEFECTOS PROPUESTOS:\n' + list }], sys, () => {});
const txt = String(out);
let parsed = 0;
for (const m of txt.matchAll(/(\d+)\s*[:.)\-]\s*(S[IÍ]|YES|NO)\b/gi)) {
const i = +m[1] - 1;
parsed++;
if (i >= 0 && i < votes.length && /^(s[ií]|yes)$/i.test(m[2])) votes[i]++;
}
// Respaldo: si el modelo ignoró el formato numerado, leer los SI/NO en orden.
if (!parsed) {
const seq = [...txt.matchAll(/\b(S[IÍ]|YES|NO)\b/gi)].map(m => m[1]);
seq.slice(0, votes.length).forEach((v, i) => { if (/^(s[ií]|yes)$/i.test(v)) votes[i]++; });
}
onProgress({ phase: 'jury-parsed', lens: L.name, parsed });
}
// Moderador — se queda con los que tienen apoyo de la mayoría del jurado.
const need = Math.ceil(lenses.length / 2);
const defects = candidates.map((text, i) => ({ text, votes: votes[i] }))
.filter(d => d.votes >= need)
.sort((a, b) => b.votes - a.votes);
onProgress({ phase: 'jury-verdict', kept: defects.length, of: candidates.length });
onProgress({ phase: 'jury-evidence', dropped: dropped.length });
return { defects, candidates: candidates.length, jurors: lenses.length, dropped };
}
// ── Edición QUIRÚRGICA en vez de reescritura completa ───────────────────────
// Reescribir el documento entero para corregir tres cosas hace que el modelo
// regenere de cero lo que ya funcionaba, y por el camino rompe la mecánica.
// Aquí solo se le piden los CAMBIOS, y los aplica este código: si el fragmento
// a buscar no está tal cual en el documento, ese cambio se descarta. El modelo
// propone, el código decide — igual que con las citas del jurado.
const PATCH_SYS = `Eres Elffuss arreglando tu propia app. Te doy el HTML actual y una lista de defectos COMPROBADOS ejecutandola.
Arregla ESOS defectos y nada mas. No reescribas el documento. Devuelve solo los cambios, asi:
BUSCAR
<las lineas actuales, copiadas LITERALES del HTML de arriba>
CAMBIAR
<las lineas nuevas que las sustituyen>
FIN
Puedes usar VARIAS lineas en cada parte: si hace falta anadir codigo nuevo (por ejemplo unos addEventListener de teclado), copia en BUSCAR una linea de referencia que exista y ponla en CAMBIAR seguida del codigo nuevo.
Reglas: lo de BUSCAR debe existir palabra por palabra en el HTML y aparecer una sola vez. BUSCAR y CAMBIAR nunca pueden ser iguales, eso no arregla nada. Maximo 4 parches, todos dirigidos a los defectos de la lista. No toques colores, textos ni nada que ya funcione.`;
export function applyPatches(html, respuesta) {
const texto = String(respuesta || '');
let out = html;
const aplicados = [], fallidos = [];
// Los parches se recogen en los DOS formatos. El de una línea era el único
// que había, y con él los defectos importantes son INARREGLABLES por
// construcción: añadir unos listeners de teclado necesita varias líneas, así
// que el modelo solo podía hacer retoques cosméticos (medido: cambiaba el
// verde de la serpiente mientras el defecto era que no responde a las teclas).
const parches = [];
// bloque multilínea: BUSCAR \n …líneas… \n CAMBIAR \n …líneas… \n FIN
const bloque = /^[ \t]*BUSCAR[ \t]*$\r?\n([\s\S]*?)^[ \t]*CAMBIAR[ \t]*$\r?\n([\s\S]*?)^[ \t]*FIN[ \t]*$/gim;
for (const m of texto.matchAll(bloque))
parches.push({ buscar: m[1].replace(/\s+$/, ''), cambiar: m[2].replace(/\s+$/, '') });
// de una línea: BUSCAR: … / CAMBIAR: …
const lineas = texto.split('\n').map(x => x.trim());
for (let i = 0; i < lineas.length; i++) {
const b = lineas[i].match(/^BUSCAR:\s*(.+)/i); if (!b) continue;
const c = (lineas[i + 1] || '').match(/^CAMBIAR:\s*(.+)/i); if (!c) continue;
parches.push({ buscar: b[1].trim(), cambiar: c[1].trim() });
}
const norm = t => t.replace(/[ \t]+/g, ' ').trim();
for (const { buscar, cambiar } of parches) {
if (buscar.length < 6) continue;
// Un parche que no cambia nada se contaba como aplicado y gastaba uno de
// los seis huecos. Medido: el modelo los emite (BUSCAR y CAMBIAR idénticos).
if (buscar === cambiar) { fallidos.push({ buscar: buscar.slice(0, 60), motivo: 'no cambia nada' }); continue; }
const veces = out.split(buscar).length - 1;
if (veces === 1) { out = out.replace(buscar, cambiar); aplicados.push(buscar.slice(0, 60)); continue; }
if (veces > 1) { fallidos.push({ buscar: buscar.slice(0, 60), veces, motivo: 'aparece varias veces' }); continue; }
// No está literal: puede ser solo la sangría o los espacios, que es el
// error de cita más común. Se busca por líneas normalizadas, y solo se
// sustituye si el bloque es ÚNICO — nunca una coincidencia dudosa.
const bl = buscar.split('\n').map(norm);
const hl = out.split('\n'), hn = hl.map(norm);
const donde = [];
for (let i = 0; i + bl.length <= hn.length; i++) {
let igual = true;
for (let j = 0; j < bl.length; j++) if (hn[i + j] !== bl[j]) { igual = false; break; }
if (igual) { donde.push(i); if (donde.length > 1) break; }
}
if (donde.length === 1) {
hl.splice(donde[0], bl.length, ...cambiar.split('\n'));
out = hl.join('\n');
aplicados.push(buscar.slice(0, 60));
} else fallidos.push({ buscar: buscar.slice(0, 60), veces: donde.length, motivo: donde.length ? 'ambiguo ignorando espacios' : 'no existe en el HTML' });
}
return { html: out, aplicados, fallidos };
}
// ── Evaluador OBJETIVO: ejecuta el artefacto y mide, no opina ───────────────
// fast-agent (evalstate/fast-agent) usa un LLM como evaluador y refina hasta
// una nota mínima. La idea es buena, pero preguntarle al modelo qué nota se
// pone es preguntarle a la parte que ya demostró alucinar. Aquí el artefacto
// se EJECUTA en un iframe aislado y se comprueba qué hace de verdad:
//
// pinta dibuja algo
// vivo lo que dibuja cambia solo (hay bucle)
// responde cambia al recibir teclas y ratón
// arranca no aparece ya terminado
// completo el documento cierra
//
// Medir esto costó descubrir que contar píxeles premiaba una pantalla de
// «GAME OVER» a toda página por encima de un juego jugable.
export async function rateArtifact(html, { ms = 2600 } = {}) {
if (typeof document === 'undefined') return { nota: 0, detalle: {}, medible: false };
// El iframe va SIN allow-same-origin a propósito: dentro corre código que ha
// escrito el modelo y no debe poder tocar la página que lo aloja. Eso hace su
// documento ilegible desde fuera, así que se le inyecta una sonda que se mide
// a sí misma y devuelve NÚMEROS por postMessage. Nada entra; solo salen datos.
const sonda = `<script>(function(){
var W=window, P=function(m){try{parent.postMessage(m,'*')}catch(e){}};
// Un juego que revienta al mover el ratón se puntuaba 5/5: la sonda medía
// que pintaba y que cambiaba, pero era ciega a las excepciones. Se recogen
// aquí dentro porque desde fuera el sandbox no deja verlas.
var errores=[];
// «responde» se medía como «el dibujo cambió tras pulsar», pero un juego que
// anima continuamente cambia igual sin tocar nada: un juego SORDO pasaba.
// Ahora se mira si llega a registrar algún escuchador de entrada, que es la
// propiedad de verdad. Se puede porque la sonda entra ANTES que su código.
var escucha = {};
var ENTRADA = /^(keydown|keyup|keypress|mousemove|mousedown|mouseup|click|pointerdown|pointermove|touchstart|touchmove)$/;
function espia(obj, nombre){
if(!obj || !obj.addEventListener) return;
var orig = obj.addEventListener;
obj.addEventListener = function(tipo){ if(ENTRADA.test(String(tipo))) escucha[tipo]=1; return orig.apply(this, arguments); };
}
espia(W,'window'); espia(document,'document');
if(W.HTMLElement && W.HTMLElement.prototype) espia(W.HTMLElement.prototype,'elementos');
// los manejadores puestos como propiedad (onkeydown = ...) no pasan por ahí
['onkeydown','onkeyup','onclick','onmousemove','onmousedown'].forEach(function(k){
[W, document].forEach(function(o){
try{ var v; Object.defineProperty(o,k,{configurable:true,
get:function(){return v;}, set:function(f){ if(f) escucha[k]=1; v=f; }}); }catch(e){}
});
});
W.addEventListener('error',function(e){ errores.push(String((e&&e.message)||e).slice(0,120)); });
W.addEventListener('unhandledrejection',function(e){ errores.push('promesa: '+String((e&&e.reason&&e.reason.message)||e.reason).slice(0,110)); });
function leer(){ var c=document.querySelector('canvas'); if(!c) return null;
var x=c.getContext('2d'); if(!x) return null;
try{ var d=x.getImageData(0,0,c.width,c.height).data, nb=0, h=0;
for(var i=0;i<d.length;i+=16){ if(d[i]||d[i+1]||d[i+2]) nb++;
h=(h*31+d[i]+d[i+1]*3+d[i+2]*7)>>>0; }
return {nb:nb,h:h,txt:(document.body.innerText||'').slice(0,400).toLowerCase(),err:errores[0]||null,nerr:errores.length,escucha:Object.keys(escucha)};
}catch(e){ return {nb:0,h:0,txt:'',err:errores[0]||('canvas: '+e.message),nerr:errores.length+1,escucha:Object.keys(escucha)}; } }
W.addEventListener('message',function(e){
if(!e.data) return;
if(e.data.__sonda){ P({__sonda:1,r:leer()}); return; }
// Las pulsaciones hay que despacharlas DENTRO: desde fuera no cruzan el
// sandbox, y sin esto «responde» medía simplemente que seguía animando.
if(e.data.__tecla){ var k=e.data.__tecla;
try{ var ev={key:k,code:k===' '?'Space':k,keyCode:k===' '?32:0,bubbles:true};
document.dispatchEvent(new KeyboardEvent('keydown',ev));
W.dispatchEvent(new KeyboardEvent('keydown',ev));
var c=document.querySelector('canvas');
if(c){ var r=c.getBoundingClientRect();
var mv=new MouseEvent('mousemove',{clientX:r.left+r.width*0.7,clientY:r.top+r.height/2,bubbles:true});
document.dispatchEvent(mv); c.dispatchEvent(mv);
c.dispatchEvent(new MouseEvent('click',{bubbles:true})); }
}catch(e2){}
}
});
P({__sonda:'lista'});
})();<\/script>`;
const marco = document.createElement('iframe');
marco.setAttribute('sandbox', 'allow-scripts');
// DENTRO del viewport a propósito: fuera de pantalla Chrome estrangula
// requestAnimationFrame y el juego no anima, así que se medía como muerto un
// juego que funciona. Invisible por opacidad, no por posición.
marco.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:600px;border:0;' +
'opacity:.01;pointer-events:none;z-index:-1';
const doc = String(html || '');
// La sonda va lo ANTES posible: puesta antes de </body> se registraba después
// de que el artefacto ya hubiera corrido, así que era ciega al fallo más
// común —el de arranque o parseo— y esos juegos salían «sin errores».
// Detrás de <head> y no antes del doctype: delante, el documento cae en modo
// quirks y cambia el tamaño del canvas, o sea que cambiaría lo que medimos.
marco.srcdoc = /<head[^>]*>/i.test(doc) ? doc.replace(/<head[^>]*>/i, m => m + sonda)
: /<html[^>]*>/i.test(doc) ? doc.replace(/<html[^>]*>/i, m => m + sonda)
: sonda + doc;
const respuestas = [];
const oir = e => { if (e.data && e.data.__sonda === 1) respuestas.push(e.data.r); };
window.addEventListener('message', oir);
document.body.appendChild(marco);
const esperar = t => new Promise(r => setTimeout(r, t));
const pedir = async () => {
const n = respuestas.length;
try { marco.contentWindow.postMessage({ __sonda: 1 }, '*'); } catch { /* */ }
for (let i = 0; i < 40 && respuestas.length === n; i++) await esperar(50);
// Al expirar el plazo, respuestas.length sigue valiendo n y esto devolvía
// LA LECTURA ANTERIOR. Entonces b===a daba «no está vivo» y c===b daba «no
// responde» sobre un juego que funciona. Y como el evaluador se usa MIENTRAS
// el modelo genera, el iframe llega tarde justo cuando más se le mide: un
// juego jugable se puntuaba 0 por no haber contestado a tiempo.
return respuestas.length > n ? respuestas[respuestas.length - 1] : null;
};
await esperar(1300);
const a = await pedir();
await esperar(ms);
const b = await pedir();
try {
for (const k of ['ArrowLeft', 'ArrowRight', 'ArrowUp', ' ', 'w', 's'])
marco.contentWindow.postMessage({ __tecla: k }, '*');
} catch { /* */ }
await esperar(1200);
const c = await pedir();
// Cuarta lectura, SIN mandar nada más. Hace falta porque «sobrevive» empuja al
// modelo a que el juego no arranque hasta la primera pulsación —que es lo
// correcto— y entonces «vivo», medido solo ANTES de tocar nada, lo castigaba
// justo por hacerlo bien. Las dos señales se contradecían. Ahora vale con que
// se mueva solo en algún momento: antes de la entrada, o después de ella.
await esperar(1400);
const d = await pedir();
window.removeEventListener('message', oir);
marco.remove();
if (!a || !b || !c || !d) return { nota: -1, detalle: { medible: false }, medible: false,
error: 'el artefacto no contestó a tiempo (no es un fallo suyo: no se pudo medir)' };
// Las mismas cinco señales que mide el arnés externo, para que la nota de
// dentro y la de fuera signifiquen lo mismo y se puedan contrastar.
const fallo = [a, b, c, d].map(x => x && x.err).find(Boolean) || null;
// El snake generado arrancaba ya moviéndose y chocaba contra la pared en un
// segundo y medio: cuando lo abres ya ha terminado. Gráficamente impecable y
// pasaba las cinco señales anteriores. «arranca» mira el primer instante;
// esto mira si SIGUE vivo unos segundos después sin que nadie juegue.
const seAcabo = t => /game\s*over|has perdido|fin del juego|puntuaci[óo]n final/.test(t || '');
const detalle = {
pinta: a.nb > 0,
vivo: !!(a.h !== b.h || c.h !== d.h),
// escuchar Y reaccionar. Solo lo segundo daba por bueno un juego sordo que
// simplemente seguía animando; solo lo primero daría por bueno uno que
// registra el escuchador y no hace nada con él.
responde: !!((c && c.escucha && c.escucha.length) && (c && b && b.h !== c.h)),
arranca: !seAcabo(a.txt),
// Se comprueba en la ÚLTIMA lectura, no en la segunda. Al pedirle al modelo
// que no arranque hasta la primera pulsación, mirar antes de tocar nada
// daba «sobrevive» por bueno trivialmente: el juego aún no había empezado.
// Snake pasó de morir en segundo y medio a esperar y MORIR IGUAL de rápido
// en cuanto arrancaba, y así puntuaba 6/6.
sobrevive: !seAcabo(d && d.txt) && !seAcabo(b && b.txt),
sinErrores: !fallo,
};
// «pinta» es puerta: sin dibujar nada, un HTML en blanco se llevaba puntos
// gratis por «no muestra game over» y «sin errores», ciertos por vacuidad.
const nota = detalle.pinta ? Object.values(detalle).filter(Boolean).length : 0;
return { nota, maximo: Object.keys(detalle).length, detalle, medible: true, error: fallo, cerrado: /<\/html>/i.test(doc) };
}
// Pedirle a un modelo de 4B «encuentra los defectos de este HTML» es pedirle lo
// que peor hace: leer código largo y juzgarlo. Pero tras ejecutarlo YA sabemos
// objetivamente qué falla. Esto convierte la medición en la lista de defectos,
// que es información dura y no una opinión del propio modelo sobre sí mismo.
function defectosMedidos(detalle, error) {
if (!detalle) return [];
const d = [];
if (!detalle.pinta) d.push('No se dibuja NADA en el canvas: revisa que exista el <canvas>, que se coja su contexto 2d y que algo se pinte al arrancar.');
if (!detalle.vivo) d.push('La pantalla no cambia sola: falta el bucle de animación. Tiene que haber un requestAnimationFrame que se llame a sí mismo y repinte cada fotograma.');
if (!detalle.responde) d.push('No reacciona a la entrada: al pulsar las flechas, W/S o la barra, y al mover el ratón, no cambia nada. Los listeners de teclado deben estar en document o window (no en el canvas, que no tiene foco).');
if (!detalle.arranca) d.push('Arranca ya en pantalla de fin de partida: el estado inicial debe ser «jugando», no «game over».');
if (!detalle.sobrevive) d.push('La partida se acaba SOLA a los pocos segundos sin que el jugador haga nada, así que es injugable. Arréglalo de las dos formas: (1) que no empiece a moverse hasta la primera pulsación del jugador, y (2) que empiece lejos de los bordes y con velocidad más lenta, para que dé tiempo a reaccionar.');
if (!detalle.sinErrores) d.push('Lanza este error en ejecución y hay que corregirlo: ' + (error || 'error desconocido'));
return d;
}
export async function deepCreate({ brief, provider, rounds = 1, onProgress = () => {}, useJury = false,
editMode = 'rewrite', notaMinima = null, evaluar = null, bestOf = 1 }) {
if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo arriba).');
if (!String(brief || '').trim()) throw new Error('Hard Work: dime qué quieres que cree.');
onProgress({ phase: 'draft' });
let html = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
const trace = [{ round: 0, html }];
// Varias tiradas y nos quedamos con la que MEJOR puntúa al ejecutarla.
// A este tamaño de modelo, pedirle que se critique y se parchee empeora
// (medido: una ronda dejó un juego jugable sin pintar). Volver a tirar y
// juzgar por fuera no depende de que el modelo se juzgue bien a sí mismo.
// Requiere muestreo con temperatura: con decodificado voraz las N tiradas
// salen idénticas y esto sería pagar N veces por lo mismo.
const puedeMuestrear = typeof provider.setSampling === 'function';
if (bestOf > 1 && (evaluar || rateArtifact) && puedeMuestrear) {
const juzga = evaluar || rateArtifact;
let mejorHtml = html, mejorV = await juzga(html);
onProgress({ phase: 'candidato', i: 0, nota: mejorV.nota });
for (let i = 1; i < bestOf; i++) {
provider.setSampling({ temperature: 0.85, seed: 1000 + i * 137 });
const otro = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
const v = otro ? await juzga(otro) : { nota: -1 };
onProgress({ phase: 'candidato', i, nota: v.nota });
trace.push({ candidato: i, html: otro, nota: v.nota });
if (v.nota > mejorV.nota) { mejorHtml = otro; mejorV = v; }
}
provider.setSampling(null); // dejarlo como estaba: voraz
html = mejorHtml; trace[0].html = html; trace[0].nota = mejorV.nota;
onProgress({ phase: 'mejorCandidato', nota: mejorV.nota });
}
// Con criterio de nota: se guarda la MEJOR versión, no la última. Medimos que
// una ronda de más puede empeorar (un juego jugable acabó en pantalla de fin),
// así que devolver «lo último» es devolver a veces lo peor.
const juez = notaMinima != null ? (evaluar || rateArtifact) : null;
let mejor = html, mejorNota = -1, ultimoDetalle = null, ultimoError = null;
if (juez) {
const v = await juez(html);
mejorNota = v.nota; ultimoDetalle = v.detalle; ultimoError = v.error;
trace[0].nota = v.nota; trace[0].detalle = v.detalle;
onProgress({ phase: 'rated', round: 0, nota: v.nota, detalle: v.detalle });
if (v.nota >= notaMinima) { onProgress({ phase: 'done', rounds: 0, motivo: 'ya cumplía' }); return { html, trace, nota: v.nota }; }
}
for (let r = 1; r <= rounds; r++) {
onProgress({ phase: 'critique', round: r });
let critique;
// Si hemos ejecutado el artefacto, lo MEDIDO manda: son defectos
// comprobados, no sospechas. La crítica del modelo va detrás, como apoyo.
const medidos = juez ? defectosMedidos(ultimoDetalle, ultimoError) : [];
if (medidos.length) {
critique = 'DEFECTOS COMPROBADOS EJECUTANDO EL JUEGO (corrige estos, son seguros):\n'
+ medidos.map(x => '- ' + x).join('\n');
onProgress({ phase: 'critique', round: r, medidos: medidos.length });
} else if (useJury) {
const v = await juryReview({ artifact: html, provider, onProgress });
critique = v.defects.length
? v.defects.map(d => `- ${d.text} (${d.votes} de ${v.jurors} revisores)`).join('\n')
: 'Sin defectos con apoyo suficiente del jurado.';
} else {
critique = String(await provider.chat([{ role: 'user', content: `APP/JUEGO:\n${html}` }], CRITIQUE_SYS, () => {})).trim();
}
onProgress({ phase: 'rewrite', round: r, modo: editMode });
if (editMode === 'patch') {
const resp = await provider.chat(
[{ role: 'user', content: `HTML ACTUAL:\n${html}\n\nDEFECTOS A CORREGIR:\n${critique}` }], PATCH_SYS, () => {});
const { html: nuevo, aplicados, fallidos } = applyPatches(html, resp);
onProgress({ phase: 'patched', round: r, aplicados: aplicados.length, fallidos: fallidos.length });
if (aplicados.length) { html = nuevo; trace.push({ round: r, critique, html, aplicados, fallidos }); }
else trace.push({ round: r, critique, html, aplicados: [], fallidos });
} else {
const improved = extractHtml(await genComplete(provider, REWRITE_SYS, `HTML ACTUAL:\n${html}\n\nCRÍTICA A CORREGIR:\n${critique}`));
if (improved && /<\w+[\s>]/.test(improved)) { html = improved; trace.push({ round: r, critique, html }); }
}
if (juez) {
const v = await juez(html);
ultimoDetalle = v.detalle; ultimoError = v.error;
trace[trace.length - 1].nota = v.nota; trace[trace.length - 1].detalle = v.detalle;
onProgress({ phase: 'rated', round: r, nota: v.nota, detalle: v.detalle });
if (v.nota > mejorNota) { mejorNota = v.nota; mejor = html; }
if (v.nota >= notaMinima) { onProgress({ phase: 'done', rounds: r, motivo: 'alcanzó la nota' }); return { html: mejor, trace, nota: mejorNota }; }
// Antes se paraba en cuanto una ronda puntuaba por debajo de la mejor. Con
// eso una lectura floja tiraba las rondas que quedaban, y como siempre se
// devuelve la MEJOR, seguir no puede empeorar el resultado: solo cuesta
// tiempo. Se gasta el presupuesto y se elige al final.
if (v.nota < mejorNota) onProgress({ phase: 'peor', round: r, nota: v.nota, mejor: mejorNota });
}
}
onProgress({ phase: 'done', rounds: trace.length - 1 });
return juez ? { html: mejor, trace, nota: mejorNota } : { html, trace };
}
// ── Recolección de material desde una carpeta autorizada (File System Access) ──
// Camina el árbol, concatena ficheros de texto con cabecera de ruta, y corta a
// maxBytes para no reventar la memoria. Mismo código sirve a Claw (carpeta
// autorizada) y a Code (proyecto abierto): ambos entregan un directory handle.
const TEXT_EXT = new Set(('txt md markdown js mjs cjs ts tsx jsx json html htm css scss sass ' +
'py rb php go rs java kt swift c h cpp hpp cc cs sh bash zsh yml yaml toml ini cfg conf ' +
'xml csv tsv sql vue svelte astro lua r pl pm ex exs erl clj tex org rst gradle properties env').split(' '));
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'target', 'vendor', '.cache', 'coverage', '__pycache__', '.venv', 'venv']);
export async function gatherFolder(rootHandle, { maxBytes = 1.5e6, maxFiles = 400 } = {}) {
if (!rootHandle || typeof rootHandle.entries !== 'function') throw new Error('no hay carpeta que leer');
const out = [];
let bytes = 0;
let files = 0;
async function walk(dir, prefix) {
if (bytes >= maxBytes || files >= maxFiles) return;
const entries = [];
for await (const e of dir.entries()) entries.push(e);
entries.sort((a, b) => a[0].localeCompare(b[0]));
for (const [name, handle] of entries) {
if (bytes >= maxBytes || files >= maxFiles) return;
if (name.startsWith('.') && name !== '.env') { if (handle.kind === 'directory') continue; }
const path = prefix ? prefix + '/' + name : name;
if (handle.kind === 'directory') {
if (SKIP_DIR.has(name)) continue;
await walk(handle, path);
} else {
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
if (!TEXT_EXT.has(ext)) continue;
try {
const file = await handle.getFile();
if (file.size > 400000) continue; // un fichero enorme suelto no monopoliza
const text = await file.text();
const block = `\n\n===== ${path} =====\n${text}`;
out.push(block);
bytes += block.length;
files++;
} catch { /* ilegible: se salta */ }
}
}
}
await walk(rootHandle, '');
return out.join('').slice(0, maxBytes);
}
|