// Núcleo: arranca UI, agente, planificador y el proveedor de modelo activo. import { Agent, parseToolCall } from './agent.js'; import * as ui from './ui.js'; import * as rules from './providers/rules.js'; import * as db from './db.js'; import * as settings from './settings.js'; import * as skills from './skills.js'; import { applyI18n, t } from './i18n.js'; import { ensureModelCache } from './model-cache.js'; import { tasks, watch } from './tools/index.js'; import * as ceo from './ceo.js'; import * as mind from './mind.js'; import { ceoWorkspace, CEO_DEFAULT_PROFILES } from './ceo-adapter.js'; import * as telemetry from './telemetry.js'; applyI18n(); // traduce el chrome al idioma del navegador antes de nada telemetry.init('elffuss-claw'); // opt-in, apagado por defecto — ver Ajustes // Proveedores LOCALES (corren en TU navegador). Los externos (OpenAI, // Anthropic, Ollama, servidor) son configuración avanzada y se resuelven // aparte, bajo el id `ext:`. const localProviders = { onnx: () => import('./providers/onnx.js'), litert: () => import('./providers/litert.js'), }; // Devuelve el módulo del proveedor ya configurado, o null si es 'rules'. async function resolveProvider(id) { if (id === 'rules') return null; if (localProviders[id]) return localProviders[id](); if (id.startsWith('litert:')) { // Gemma vía LiteRT-LM (build elegido) const mod = await import('./providers/litert.js'); mod.configure(id.slice(7)); return mod; } if (id.startsWith('ext:')) { const cfg = settings.get(id.slice(4)); if (!cfg) throw new Error('proveedor externo desconocido'); const mod = await import('./providers/api.js'); mod.configure(cfg); return mod; } throw new Error('proveedor desconocido: ' + id); } // El Elffuss E4B HEALED no carga aún en navegador (export prefill_decode, E-010): // se oculta hasta el reexport artisan. Los Gemma E2B/E4B OFICIALES (build -web // de Google) SÍ cargan y se ofrecen como elegibles (no default: se bajan GB solo // si el usuario los elige). Poner true cuando el healed sea artisan. const ELFFUSS_LITERT_READY = false; // navigator.gpu puede EXISTIR como API sin que haya un adaptador real (ciertos // Linux/drivers, entornos sandboxed/headless…) — comprobarlo UNA vez al // arrancar evita ofrecer/elegir Gemma (2-4 GB) cuando de todos modos va a // fallar al crear el motor (onnx.js y litert.js ya comprueban el adaptador // por su cuenta como defensa adicional, pero esto evita hasta OFRECERLO). let realGPU = !!navigator.gpu; const realGPUCheck = (async () => { if (!navigator.gpu) return (realGPU = false); try { return (realGPU = !!(await navigator.gpu.requestAdapter())); } catch { return (realGPU = false); } })(); // Opciones del selector: locales siempre; externos solo si están activados. function modelOptions() { const local = []; if (realGPU) local.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~2.8 GB) ★ — por defecto' }); if (realGPU) local.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' }); local.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' }); if (realGPU && ELFFUSS_LITERT_READY) local.push({ id: 'litert:elffuss-e4b', label: 'Local · Elffuss E4B (healed) ★' }); local.push({ id: 'rules', label: 'Básico (sin modelo)' }); return [...local, ...settings.enabledExternals()]; } const isLocal = id => id === 'onnx' || id === 'litert' || id.startsWith('litert:'); // Por defecto: Gemma-4 grande vía LiteRT-LM (build oficial -web, VERIFICADO que // carga y hace tool-calls). E4B en escritorio, E2B en móvil/GPU débil. Si no // cabe, cae al Elffuss LM healed (onnx, 850 MB) — cerebro seguro siempre. const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches; const defaultBrain = () => !realGPU ? 'onnx' : (isMobile() ? 'litert:gemma-e2b' : 'litert:gemma-e4b'); const agent = new Agent(rules); let busy = false; let activeModel = 'rules'; // qué proveedor está cargado (para el vigilante de RAM) let activeMod = null; // ---- cola de mensajes ---- // Los mensajes se apilan y se procesan en orden. La cola persiste en // IndexedDB: un refresco a media respuesta NO pierde lo pendiente (el mensaje // en vuelo solo sale de la cola cuando su turno TERMINA; el histórico también // se guarda al terminar, así que reprocesarlo tras un refresco no duplica). const queue = []; let pumping = false; let bootReady; const bootPromise = new Promise(r => { bootReady = r; }); const persistQueue = () => db.set('kv', 'queue', queue.map(q => q.text)).catch(() => {}); function send(text) { const el = ui.addMsg('user queued', text); queue.push({ text, el }); persistQueue(); if (queue.length > 1) ui.toast(`En cola (${queue.length - 1} por delante) — no se pierde ni refrescando.`); pump(); } async function pump() { if (pumping) return; pumping = true; await bootPromise; // no procesar hasta que el cerebro inicial esté resuelto while (queue.length) { const item = queue[0]; item.el?.classList.remove('queued'); await process(item.text); // Orden anti-pérdida: el histórico ya está COMMITEADO dentro de process() // (incluye este turno como lastDone). Solo entonces sacamos de la cola. // Si el refresco cae entre ambos commits, el mensaje aparece en histórico // Y en cola → restoreQueue lo deduplica; nunca se pierde. queue.shift(); await persistQueue(); } pumping = false; } async function process(text) { busy = true; const thinking = ui.thinkingBubble(); try { await agent.handle(text, ev => { if (ev.type === 'token') thinking.tick(ev.text); if (ev.type === 'text') ui.addMsg('assistant', ev.text); if (ev.type === 'tool') { thinking.tool(ev.call.tool); ui.addTool(ev.call); } if (ev.type === 'tool_result') ui.addToolResult(ev.tool, ev.result); if (ev.type === 'error') ui.addMsg('assistant err', ev.text); }); } finally { thinking.remove(); busy = false; // Histórico persistente COMMITEADO (await) antes de vaciar la cola: al // refrescar, la conversación sigue ahí y ningún mensaje se pierde. await db.set('kv', 'history', agent.history.slice(-80)).catch(() => {}); await db.set('kv', 'lastDone', text).catch(() => {}); } } // Recuperar mensajes que quedaron en cola antes de un refresco. Deduplica el // posible solapamiento con el último turno ya completado (ver pump()). async function restoreQueue() { let pending = await db.get('kv', 'queue').catch(() => null); if (!pending?.length) return; const lastDone = await db.get('kv', 'lastDone').catch(() => null); if (lastDone && pending[0] === lastDone) pending = pending.slice(1); // ya se procesó if (!pending.length) { db.set('kv', 'queue', []).catch(() => {}); return; } for (const text of pending) { const el = ui.addMsg('user queued', text); queue.push({ text, el }); } ui.toast(`${pending.length} mensaje(s) pendientes recuperados — los proceso en cuanto cargue el cerebro.`); pump(); } // Reconstruir la conversación guardada (burbujas + memoria del agente). async function restoreHistory() { const saved = await db.get('kv', 'history').catch(() => null); if (!saved?.length) return; agent.history = saved; for (const m of saved) { if (m.role === 'user') { if (m.content.startsWith('[resultado ')) { const nl = m.content.indexOf('\n'); ui.addToolResult(m.content.slice(11, nl > 0 ? nl - 1 : undefined), nl > 0 ? m.content.slice(nl + 1) : ''); } else if (!m.content.startsWith('[…')) { ui.addMsg('user', m.content); } } else { const call = parseToolCall(m.content); if (call) ui.addTool(call); else ui.addMsg('assistant', m.content); } } ui.toast('Conversación restaurada (para empezar de cero)'); } document.getElementById('btn-clear').addEventListener('click', async () => { await db.del('kv', 'history').catch(() => {}); await db.del('kv', 'queue').catch(() => {}); agent.history = []; location.reload(); }); async function changeModel(id) { if (id === 'rules') { // modo básico agent.setProvider(rules); activeModel = 'rules'; activeMod = null; ui.modelStatus('off'); localStorage.setItem('elffuss.model', 'rules'); ui.setModel('rules'); ui.toast('Modo básico: sin modelo, órdenes directas.'); return true; } ui.modelStatus('loading'); try { const mod = await resolveProvider(id); ui.modelProgress('Preparando modelo…'); await mod.load(p => ui.modelProgress(p)); ui.modelProgress(null); agent.setProvider(mod); activeModel = id; activeMod = mod; localStorage.setItem('elffuss.model', id); ui.setModel(id); const where = isLocal(id) ? (realGPU ? t('whereGpu') : t('whereCpu')) : t('whereExt'); ui.modelStatus(isLocal(id) && realGPU ? 'gpu' : 'on'); ui.toast(t('modelReady', { where })); return true; } catch (e) { ui.modelProgress(null); console.error('[elffuss] fallo cargando modelo', e); // Si un Gemma (LiteRT) no cabe/ falla, cae a LFM2.5 en vez de dejar sin cerebro. if (id.startsWith('litert') && !_fellBack) { _fellBack = true; sessionStorage.setItem('elffuss.skipGemma', '1'); // no reintentar el pesado esta sesión ui.toast('Ese Gemma no cargó aquí (memoria/GPU) — uso LFM2.5 (ligero).'); const ok = await changeModel('onnx'); _fellBack = false; if (ok) return true; } ui.modelStatus('off'); agent.setProvider(rules); activeModel = 'rules'; ui.setModel('rules'); ui.toast('⚠️ No se pudo cargar ' + id + ': ' + (e?.message || String(e))); return false; } } let _fellBack = false; // Rehacer el selector (locales + externos activados) tras tocar la config. function refreshModelOptions() { ui.rebuildModelSelect(modelOptions(), activeModel); } // Splash de primera visita: galaxia WebGL + promesa de privacidad + guiño. const splash = document.getElementById('splash'); if (!localStorage.getItem('elffuss.welcomed')) { splash.hidden = false; let galaxy = null; import('./splash-gl.js') .then(m => { galaxy = m.startGalaxy(splash); }) .catch(() => {}); // sin WebGL queda el gradiente CSS document.getElementById('splash-enter').addEventListener('click', () => { localStorage.setItem('elffuss.welcomed', '1'); galaxy?.burst(); // las estrellas salen despedidas al entrar splash.style.opacity = '0'; setTimeout(() => { splash.hidden = true; galaxy?.stop(); }, 800); }); } skills.initSkills(); ui.init({ onSend: send, onModelChange: changeModel, onSettingsChanged: refreshModelOptions }); refreshModelOptions(); restoreHistory().then(restoreQueue); // ── Cerebro CEO autónomo + Mente de Elffuss ─────────────────────────────── // Cuando NO le pides nada, Elffuss trabaja por su cuenta: revisa tus carpetas // autorizadas, tareas, apps y memoria, y propone mejoras (sin tocar nada por // su cuenta — la propuesta se ejecuta con el mismo camino real que el chat). // «Abrir en el editor» de un pensamiento no aplica aquí (no hay editor de // código) — en su lugar, pide en el chat el contenido de ese fichero. mind.setOpenFile(path => send('Muéstrame el contenido de ' + path)); mind.setExecuteProposal(md => { mind.closeMind(); send('Implementa esta propuesta de mejora usando las herramientas disponibles:\n\n' + md); }); mind.init({}); // sin ciudad de fondo (Claw v1): la Mente funciona igual sin ella const WEAK_RE = /^(no (encontr|hay|se me ocurre)|nada que|sin cambios|todo (está|esta) bien|no aplica)/i; function isNoteworthy(ev) { const solid = (ev.proposals || []).filter(p => p.text && p.text.trim().length >= 60 && !WEAK_RE.test(p.text.trim())); return solid.length >= 2; // al menos 2 líneas de pensamiento con algo sustancioso } function executeProposal(md) { send('Implementa esta propuesta de mejora usando las herramientas disponibles:\n\n' + md); } // SOLO notifica si el permiso YA está concedido — nunca lo pide aquí (fuera // de un gesto de usuario los navegadores lo bloquean en silencio de todos // modos); pedirlo vive únicamente en el clic del botón 🧠. function notify(title, body) { if (!('Notification' in window) || Notification.permission !== 'granted') return; try { new Notification(title, { body, icon: 'img/elffuss.svg' }); } catch { /* */ } } function reportImprovements(ev) { const props = ev.proposals || []; if (!props.length) return; const firstLine = t => (t || '').split('\n').find(l => l.trim())?.trim().slice(0, 160) || ''; const body = `💡 **Mientras estabas fuera revisé tu espacio y encontré ${props.length} mejora${props.length === 1 ? '' : 's'}:**\n\n` + props.map(p => `**${p.dept}** — ${firstLine(p.text)}`).join('\n\n') + (ev.path ? `\n\n_Guardado en \`${ev.path}\`. Pulsa 🧠 para verlo en la Mente._` : ''); const div = ui.addMsg('assistant', body); const md = ev.md || body; if (div) { const btn = document.createElement('button'); btn.className = 'proposal-exec-btn'; btn.textContent = '▶ Ejecutar esta propuesta'; btn.onclick = () => { btn.disabled = true; btn.textContent = '▶ enviado a la cola…'; executeProposal(md); }; div.appendChild(btn); } if (isNoteworthy(ev)) notify(`Elffuss encontró ${props.length} mejora${props.length === 1 ? '' : 's'}`, props.map(p => p.dept).join(' · ')); } ceo.init({ namespace: 'elffuss', workspace: ceoWorkspace, defaultProfiles: CEO_DEFAULT_PROFILES, defaultMission: 'Revisar tu espacio (carpetas, apps, tareas, memoria) y proponer mejoras concretas y accionables.', provider: () => agent.provider, // el usuario tiene PRIORIDAD: si hay algo en cola o procesándose, el cerebro espera isBusy: () => pumping || queue.length > 0, onEvent: (ch, ev) => { mind.pushThought(ch, ev); if (ch === 'ceo' && ev.type === 'built') reportImprovements(ev); }, }); // cualquier interacción FUERA de la Mente cuenta como actividad → pausa el CEO const noteAct = e => { if (!e.target.closest?.('#mind-overlay')) ceo.noteActivity(); }; document.addEventListener('pointerdown', noteAct, true); document.addEventListener('keydown', noteAct, true); document.getElementById('btn-mind').addEventListener('click', () => { // auto-activa SOLO la primera vez (nunca decidiste play/stop); si lo // pausaste a propósito, abrir la Mente NO debe reactivarlo por sorpresa. if (!ceo.isEnabled() && !ceo.hasDecided()) ceo.enable(); try { if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch { /* */ } mind.openMind(); }); // restaura tu última elección (play o stop) tal cual la dejaste if (ceo.wasEnabledLastSession()) ceo.enable(); // Autocarga SOLO LOCAL. Por defecto Gemma-4 E4B (escritorio) o E2B (móvil/GPU // débil), vía LiteRT-LM; si no cabe, cae a LFM2.5. Respeta lo último elegido. (async () => { try { // Caché de modelos (persistente + service worker) ANTES de descargar nada, // para que hasta la primera descarga de pesos quede cacheada y no se repita. await ensureModelCache(); await realGPUCheck; // que defaultBrain()/modelOptions() vean el adaptador real, no solo la API const saved = localStorage.getItem('elffuss.model'); if (saved === 'rules') return; // elección explícita const available = new Set(modelOptions().map(o => o.id)); // si un Gemma pesado ya falló esta sesión, no reintentar (evita re-crash) const skipGemma = sessionStorage.getItem('elffuss.skipGemma') === '1'; const def = skipGemma ? 'onnx' : defaultBrain(); const chain = [...new Set([saved, def, realGPU ? 'onnx' : null] .filter(id => id && available.has(id)))]; if (!chain.length) return; const first = chain[0]; ui.toast(first.startsWith('litert') ? 'Cargando Gemma (varios GB la 1ª vez)… mientras, el modo básico te atiende.' : 'Cargando el cerebro local… mientras, el modo básico te atiende.'); for (const id of chain) if (await changeModel(id)) return; } finally { bootReady(); // la cola puede empezar a procesar (con el cerebro que haya) } })(); // Vigilante de RAM: si el heap del navegador va muy lleno (los modelos // locales viven ahí), primero avisa y, si sigue subiendo, para y LIBERA el // modelo automáticamente antes de que la pestaña se caiga. El modelo remoto // no gasta RAM local, así que no se toca. let ramWarned = false; setInterval(async () => { const m = performance.memory; if (!m?.jsHeapSizeLimit) return; // solo Chrome const ratio = m.usedJSHeapSize / m.jsHeapSizeLimit; if (ratio < 0.7) { ramWarned = false; return; } if (ratio > 0.92 && isLocal(activeModel)) { const mod = activeMod; agent.setProvider(rules); activeModel = 'rules'; activeMod = null; ui.setModel('rules'); ui.modelStatus('off'); try { await mod?.unload?.(); } catch { /* mejor esfuerzo */ } ui.addMsg('assistant', '⚠️ Ojo: tu ordenador está muy a full de memoria — puedes experimentar caídas ' + 'o rendimiento lento si sigues usándolo. Por el momento me he parado y he ' + 'liberado el modelo. Si quieres, me arrancas de nuevo desde el selector ' + 'de arriba. '); ui.toast('⚠️ RAM muy llena: modelo local liberado automáticamente'); } else if (ratio > 0.82 && !ramWarned) { ramWarned = true; ui.toast(`⚠️ La memoria del navegador va al ${Math.round(ratio * 100)}% — si sigue subiendo, liberaré el modelo yo sola.`); } }, 15000); // Las tareas vencidas se auto-envían como prompts al agente. tasks.startScheduler(t => { ui.toast('Tarea programada: ' + t.prompt); send('[tarea programada] ' + t.prompt); }); // Automatizaciones de carpetas: dejar un archivo en la de origen → Elffuss lo // procesa y lo deja en la de destino, avisando por chat. watch.startWatcher(msg => { ui.toast(msg); ui.addMsg('assistant', msg); }); window.elffuss = { agent, send }; // consola de depuración