// 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 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(//i) || body.match(//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 ni