// 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, fs, apps } from './tools/index.js'; import { hardWork, gatherFolder, deepCreate } from './rlm.js'; import * as workspace from './workspace.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'; import * as gram from './copilot-grammar.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('onnx:')) { // ONNX concreto (Elffuss LM, Qwen3…) const mod = await import('./providers/onnx.js'); mod.configure(id.slice(5)); return mod; } if (id.startsWith('engine:')) { // runtime WebGPU propio (GGUF) // El motor no se versiona aquí: llega a web/js/engine/ por rsync, igual que // los pesos de web/models/. Si el directorio no está, el import falla y // changeModel lo trata como cualquier otro fallo de carga. const mod = await import('./engine/provider.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); } })(); // El runtime propio NO se versiona en este repo: llega a web/js/engine/ por el // rsync del deploy, igual que los pesos. Se SONDEA en vez de cablear un flag que // haya que acordarse de cambiar — así aparece en el selector solo donde está // desplegado de verdad, y ofrecerlo nunca es una promesa que falle al elegirlo. let ENGINE_READY = false; const engineCheck = (async () => { try { const r = await fetch('js/engine/provider.js', { method: 'HEAD', cache: 'no-store' }); return (ENGINE_READY = r.ok); } catch { return (ENGINE_READY = false); } })(); // El 27B se comprueba APARTE del motor, porque son dos cosas independientes: el // motor llega por rsync y el modelo es un fichero de gigabytes que puede no // estar alojado. Con el motor puesto y el modelo no, la opción aparecería y // fallaría al elegirla — y esta cuesta más que ninguna, porque el fallo llega // después de esperar. // // Un HEAD basta y no descarga nada. Si el modelo se muda a otro alojamiento hay // que cambiar esta ruta también; el precio de tenerla aquí es ese, y a cambio no // se importa el motor entero en cada carga de página solo para preguntárselo. // (RETIRADO Y DEVUELTO). Estuvo fuera porque colgaba el ordenador. La causa se // encontró y se arregló: el motor pedía a la GPU buffers temporales enormes y no // los soltaba hasta el final del forward, y sus núcleos corrían tanto sin ceder // que el sistema no llegaba a refrescar la pantalla. // · reciclado entre capas → el pico de memoria de GPU bajó unas 16 veces // · lotes de 128 → ningún despacho retiene la GPU tanto rato // Con eso, una carga completa con generación dejó de colgar nada. // // Se devuelve al selector porque el fallo tiene causa medida y corregida, no // porque haya dejado de reproducirse por suerte. Si vuelve a colgar una // máquina, el sitio correcto para mirar es el pico de memoria de GPU // (ops.ctx.picoBytes) y la duración de los despachos, no otra vez el modelo. // // El síntoma, por si hay que volver a retirarlo: despachos de GPU tan largos que // el escritorio dejaba de refrescarse y el sistema acababa reiniciándose. Colgarle // el ordenador a alguien es peor que cualquier respuesta mala. La regla que lo // evita: los núcleos troceán su trabajo en despachos cortos para que la GPU pueda // atender al escritorio entre uno y otro. const RETIRADO_27B_CUELGA_LA_MAQUINA = false; // arreglado: ver arriba // Se le pregunta AL MOTOR por sus propios ficheros, en vez de repetir aquí una // URL. Aquí había cableada la del 27B de un solo trozo; cuando pasó a dos // fragmentos en dos hosts, esta línea se quedó apuntando al fichero viejo y // seguía dando 200 de milagro, porque el fichero aún estaba en el servidor. O // sea: el selector decidía si ofrecer el modelo mirando un fichero que ya no se // usa. El día que se borre para recuperar disco, la opción desaparece y nadie // relaciona las dos cosas. // // Se importa `registro.js` y no `provider.js` a propósito: el registro son unos // kilobytes y el proveedor arrastra el motor entero, que no tiene por qué // bajarse quien nunca lo elija. let MODEL27_READY = false; const modelo27Check = (async () => { try { const reg = await import('./engine/registro.js'); return (MODEL27_READY = await reg.disponible('qwen38-27b')); } catch { return (MODEL27_READY = 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) — más ligero, carga antes' }); local.push({ id: 'onnx:qwen3.5-0.8b', label: 'Qwen3.5-0.8B · WebGPU (~600 MB) — ligero' }); // Runtime propio: solo se ofrece si el motor está desplegado (llega por rsync, // no por git). Ofrecerlo cuando no está sería prometer algo que falla al // elegirlo. Ver ENGINE_READY. if (realGPU && ENGINE_READY) local.push({ id: 'engine:qwen35-0.8b', label: 'Qwen3.5-0.8B · motor propio (~800 MB)' }); // El 27B no está aquí por dudoso: su forward está verificado contra llama.cpp // capa a capa y genera texto coherente. Está aquí por lo que CUESTA, y el coste // ya está MEDIDO, no supuesto: además de la descarga, leer un prompt largo // —persona, herramientas y contexto, que es todo prompt real de Claw— lo deja // muy por debajo de cualquier otro cerebro de la lista, y el retraso cae entero // ANTES del primer token, que es donde peor se lleva. // // Se sigue ofreciendo porque verlo correr tiene valor por sí mismo, pero la // etiqueta lo dice: quien lo elija para trabajar se va a arrepentir, y eso no // puede descubrirse después de bajar los gigas. // // Ya SÍ se guarda, y costó entender por qué no podía. El navegador corta un // fichero suelto sobre los 1,94 GB, y el broker guardaba cada modelo en uno: // 3,8 GB fallaban con «exceed its storage quota», que suena a falta de // espacio y no lo era. Troceando en partes de 1 GiB entra, y medido con el // registro del servidor la segunda carga son CERO peticiones. La etiqueta // pasa de «gigas cada vez» a «gigas una vez», que cambia la decisión. // // Aquí decía «solo conversa», y era cierto mientras el modelo tuvo 512 tokens // de contexto: no le cabía el catálogo de herramientas. Ya tiene 2048, que // supera CTX_MINIMO_COMPLETO, así que recibe el prompt completo y el catálogo // con él. La afirmación caducó y se quita. // // NO se sustituye por «puede usar herramientas»: que reciba el catálogo es un // hecho del código, pero que un modelo a 1 bit emita un bloque ```tool bien // formado no lo ha comprobado nadie. Prometer eso en una etiqueta es // exactamente el error que esta etiqueta existe para evitar. if (realGPU && ENGINE_READY && MODEL27_READY && !RETIRADO_27B_CUELGA_LA_MAQUINA) local.push({ id: 'engine:qwen38-27b', label: 'Qwen3.8-27B IQ1 · motor propio (~7,6 GB, se guarda: solo se baja la primera vez) — lento', group: '⚠ Avanzado' }); 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)' }); // Cerebros de bajo rendimiento: fuera del flujo normal, en un grupo avanzado y // avisados. Elffuss LM (LFM2.5-1.2B) medía flojo en el copiloto (no rastrea). const weak = [{ id: 'onnx:elffuss-lm', label: 'Elffuss LM (LFM2.5-1.2B) — poco rendimiento', group: '⚠ Avanzado · sin garantía de rendimiento' }]; return [...local, ...settings.enabledExternals(), ...weak]; } const isLocal = id => id === 'onnx' || id === 'litert' || id.startsWith('litert:') || id.startsWith('onnx:') || id.startsWith('engine:'); // Por defecto: Gemma-4 grande vía LiteRT-LM (build oficial -web, VERIFICADO que // carga y hace tool-calls) SOLO en escritorio (E4B). En MÓVIL no: E2B pesa ~1.9 GB // y el navegador móvil (mata la pestaña por encima de ~1-2 GB de datos vivos) no lo // sostiene, así que se re-descargaba y fallaba. Móvil → Elffuss LM (onnx, ~850 MB): // entra y funciona. E2B/E4B siguen elegibles a mano para quien tenga músculo. const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches; async function gpuCapacity() { const mem = navigator.deviceMemory || 8; if (!navigator.gpu) return { gpu: false, maxBuf: 0, mem }; try { const a = await navigator.gpu.requestAdapter(); return a ? { gpu: true, maxBuf: a.limits?.maxBufferSize || 0, mem } : { gpu: false, maxBuf: 0, mem }; } catch { return { gpu: false, maxBuf: 0, mem }; } } async function pickLocalBrain() { const c = await gpuCapacity(); if (c.gpu && !isMobile()) { if (c.maxBuf >= 2 ** 31 && c.mem >= 8) return 'litert:gemma-e4b'; if (c.maxBuf >= 2 ** 30 && c.mem >= 6) return 'litert:gemma-e2b'; } // Móvil: aunque el navegador móvil tenga WebGPU, NO cargar Gemma grande // —no cabe en su presupuesto de memoria—. Cerebro ligero: Qwen3.5-0.8B. return 'onnx:qwen3.5-0.8b'; } const defaultBrain = () => (!realGPU || isMobile()) ? 'onnx:qwen3.5-0.8b' : 'litert:gemma-e4b'; const agent = new Agent(rules); let busy = false; let hardMode = false; // Hard Work (RLM): la próxima petición se procesa por partes 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) { if (hardMode) return processHardWork(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 (window.__copilotOpener) { try { window.__copilotEmit(ev.text); } catch { /* */ } } } 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(() => {}); workspace.touch('history'); } } // Hard Work (RLM): trabaja la carpeta autorizada ENTERA por partes. El modelo // no ve todo de golpe — se trocea, se pregunta a cada parte y se funde. Tarda, // pero abarca material que no cabe en la ventana del modelo. async function processHardWork(question) { busy = true; const thinking = ui.thinkingBubble(); const fail = msg => { thinking.remove(); ui.addMsg('assistant', msg); busy = false; }; try { if (activeModel === 'rules') return fail('Hard Work necesita un cerebro cargado: elige un modelo local o remoto en el selector de arriba y vuelve a intentarlo.'); const names = await fs.folders().catch(() => []); if (!names.length) { // Sin carpeta que leer → modo CREACIÓN: el modelo hace un borrador, se // autocritica y se reescribe. Misma petición, dos pasadas → mejor app. const res = await deepCreate({ brief: question, provider: agent.provider, rounds: 1, onProgress: p => thinking.tool( p.phase === 'draft' ? 'Hard Work · borrador…' : p.phase === 'critique' ? `Hard Work · autocrítica ${p.round}` : p.phase === 'rewrite' ? `Hard Work · reescribiendo ${p.round}` : 'Hard Work · listo'), }); thinking.remove(); const name = (question.trim().slice(0, 24) || 'app'); try { await apps.create({ name, html: res.html }); } catch { /* si falla, al menos responde */ } ui.addMsg('assistant', `Listo. Lo trabajé en dos pasadas — borrador → autocrítica → reescritura — y lo dejé en el visualizador.\n\n_⛏ Hard Work (creación): ${res.trace.length - 1} ronda de mejora._`); agent.history.push({ role: 'user', content: question, ts: Date.now() }); agent.history.push({ role: 'assistant', content: '(app creada con Hard Work)', ts: Date.now() }); return; // el finally resetea busy y guarda el histórico } thinking.tool('Hard Work · abriendo carpeta…'); const root = await fs.root(); const context = await gatherFolder(root, { maxBytes: 1.5e6 }); if (!context.trim()) return fail('Esa carpeta no tiene archivos de texto que pueda leer. Autoriza otra con documentos/código y reintenta.'); const res = await hardWork({ question, context, provider: agent.provider, onProgress: p => { if (p.phase === 'plan') thinking.tool(`Hard Work · ${p.chunks} partes (${Math.round(p.chars / 1000)}k)${p.truncated ? ' — recorté al máximo' : ''}`); else if (p.phase === 'map') thinking.tool(`Hard Work · leyendo ${p.i}/${p.n}`); else if (p.phase === 'reduce-group') thinking.tool(`Hard Work · fundiendo grupo ${p.g}/${p.n}`); else if (p.phase === 'reduce') thinking.tool('Hard Work · sintetizando…'); }, }); thinking.remove(); ui.addMsg('assistant', res.answer + `\n\n_⛏ Hard Work (RLM): leí ${names[0]} en ${res.chunks} partes, ${res.kept} con señal.${res.truncated ? ' Material recortado al máximo — acota la carpeta para abarcarlo entero.' : ''}_`); agent.history.push({ role: 'user', content: question, ts: Date.now() }); agent.history.push({ role: 'assistant', content: res.answer, ts: Date.now() }); } catch (e) { thinking.remove(); ui.addMsg('assistant err', 'Hard Work falló: ' + (e?.message || String(e))); } finally { busy = false; await db.set('kv', 'history', agent.history.slice(-80)).catch(() => {}); await db.set('kv', 'lastDone', question).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 })); if (id === 'onnx:elffuss-lm') ui.toast('⚠ Cerebro de bajo rendimiento: puede no rastrear bien ni dar buenos consejos. Para ventas/precisión usa Gemma (escritorio).'); if (window.__copilotOpener) copilotPostState('Listo. Habla con el cliente y te voy soplando.'); 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 Qwen3.5-0.8B (ligero).'); const ok = await changeModel('onnx:qwen3.5-0.8b'); _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 }); ui.mountDiskChip(); // cuánto ocupan los modelos en este navegador, y cómo vaciarlo // Botón «liberar el modelo» del panel de consumo: suelta la RAM sin recargar. ui.setOnFreeModel(async () => { const mod = activeMod; agent.setProvider(rules); activeModel = 'rules'; activeMod = null; ui.setModel('rules'); ui.modelStatus('off'); try { await mod?.unload?.(); } catch { /* mejor esfuerzo */ } }); 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. EXCEPTO en // modo copiloto: ahí el modelo es para soplarte argumentos en vivo, no para // ponerse a inventar mejoras (y de paso robarle el visor al tablero). const IS_COPILOT = /[?&]copilot=1/.test(location.search); if (!IS_COPILOT && 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 // El sondeo del motor propio resuelve después de que ui.init() pintara el // selector, así que hay que repintarlo: si no, la opción no aparece hasta // que algo más fuerce un refresco, y el usuario no la ve nunca. // Los dos sondeos se esperan JUNTOS. Repintar solo con el del motor dejaría // fuera el 27B cuando su comprobación tarda un poco más, y esa opción no // volvería a aparecer hasta que algo forzara otro refresco. const [hayMotor, hay27] = await Promise.all([engineCheck, modelo27Check]); if (hayMotor || hay27) refreshModelOptions(); const saved = localStorage.getItem('elffuss.model'); if (saved === 'rules') return; // elección explícita // En modo copiloto el translator YA está usando la máquina (Whisper + // diarización), así que si NUNCA has elegido cerebro se arranca con el // ligero para no competir. Pero tu elección manda: si escogiste Gemma, // se respeta — pisarla era un error. if (/[?&]copilot=1/.test(location.search) && !saved) { ui.toast('Copiloto: arranco con el cerebro ligero para no competir con el traductor. Para ventas/precisión, elige Gemma arriba (escritorio).'); if (await changeModel('onnx:qwen3.5-0.8b')) return; } 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' : await pickLocalBrain(); const chain = [...new Set([saved, def, realGPU ? 'onnx' : null] .filter(id => id && available.has(id)))]; if (!chain.length) return; // MÓVIL: no descargar medio giga sin permiso. Hasta hoy, abrir esto en un // teléfono arrancaba SOLO la descarga del cerebro (~600 MB) al cargar la // página. Dos motivos para no hacerlo: son los datos de alguien que no ha // pedido nada, y encima el navegador de móvil mata la pestaña bastante // antes de sostener el modelo, así que muchas veces se gastaban para nada. // Se avisa, se deja el botón por si quiere insistir, y se recuerda su // elección. El modo básico sigue atendiendo mientras tanto. if (isMobile() && localStorage.getItem('elffuss.movil.ok') !== '1') { ui.mobileGate('~600 MB', () => { (async () => { for (const id of chain) if (await changeModel(id)) return; })(); }); 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); }); // ── Espacio de trabajo: qué se puede guardar en tu disco / ver / borrar ── // El manifiesto es lo ÚNICO que Claw le cuenta al core sobre sus almacenes. workspace.init({ app: 'claw', ns: 'elffuss', db, stores: [ { id: 'apps', label: 'Apps generadas', icon: '🎨', kind: 'idb-store', store: 'apps', files: { ext: '.html', name: a => a.name, body: a => a.html } }, { id: 'skills', label: 'Skills', icon: '🧩', kind: 'idb-key', store: 'kv', key: 'skills', files: { ext: '.md', name: s => s.name, body: s => `---\nname: ${s.name}\ndescription: ${s.description || ''}\n---\n\n${s.content || ''}` } }, { id: 'history', label: 'Conversación', icon: '💬', kind: 'idb-key', store: 'kv', key: 'history' }, { id: 'memory', label: 'Memoria', icon: '🧠', kind: 'idb-store', store: 'memory' }, { id: 'tasks', label: 'Tareas programadas', icon: '⏰', kind: 'idb-store', store: 'tasks' }, { id: 'vault', label: 'Bóveda (cifrada)', icon: '🔐', kind: 'idb-store', store: 'vault', sensitive: true }, { id: 'prefs', label: 'Preferencias', icon: '⚙️', kind: 'ls', keys: ['elffuss.model', 'elffuss.acer', 'elffuss.resumen', 'elffuss.semantic'] }, ], }); workspace.restore().catch(() => {}); if (workspace.autosaveEnabled()) workspace.autosave({ enabled: true }); window.elffuss = { agent, send, workspace }; // consola de depuración // ── Hard Work (RLM): trabaja la carpeta autorizada entera por partes ── const hwBtn = document.getElementById('btn-hardwork'); hwBtn?.addEventListener('click', () => { hardMode = !hardMode; hwBtn.classList.toggle('on', hardMode); const prompt = document.getElementById('prompt'); if (hardMode) { prompt.placeholder = '⛏ Hard Work: crea algo, o pregunta sobre TODA tu carpeta…'; ui.toast('Hard Work activado. Con carpeta autorizada: la leo entera por partes y respondo. Sin carpeta: creo lo que pidas en dos pasadas (borrador → autocrítica → mejora). Tarda más, sale mejor.'); } else { prompt.placeholder = 'Pídeme lo que necesites… (/ para comandos)'; ui.toast('Hard Work desactivado: vuelvo al modo normal.'); } }); // ── Skills de audio en vivo + superficies (SSP v0.1) ──────────────────────── // Una skill de audio escucha la conversación que llega del Translator y pinta // su propia SUPERFICIE: una vista viva, aparte de las notificaciones, que el // host monta, actualiza y gestiona. La superficie se proyecta también a la app // que la abrió (pantalla partida real). // // Cada skill declara: qué ranuras rellena (objetivos o campos), qué instruye al // modelo y cómo se parsea su respuesta. const AUDIO_SKILLS = { ventas: { id: 'ventas', icon: '🎯', name: 'Copiloto de ventas', desc: 'Te sopla el argumento exacto y marca los objetivos de la llamada.', kind: 'goals', tag: 'OBJETIVOS', slots: [ { key: 'nombre', label: 'El cliente da su nombre' }, { key: 'interes', label: 'El cliente muestra interés' }, { key: 'telefono', label: 'El cliente da su teléfono' }, { key: 'compra', label: 'El cliente quiere comprar' }, ], rule: 'OBJETIVOS: nombre=si|no; interes=si|no; telefono=si|no; compra=si|no', role: 'Eres mi copiloto de ventas EN VIVO. Persigue los objetivos en orden y dime el argumento exacto, la objeción a rebatir o el siguiente paso.', }, ficha: { id: 'ficha', icon: '📇', name: 'Ficha de cliente', desc: 'Rellena sola la ficha con lo que el cliente va diciendo. Se exporta a Excel.', kind: 'fields', tag: 'DATOS', exportable: true, slots: [ { key: 'nombre', label: 'Nombre' }, { key: 'empresa', label: 'Empresa' }, { key: 'cargo', label: 'Cargo' }, { key: 'telefono', label: 'Teléfono' }, { key: 'email', label: 'Email' }, { key: 'necesidad', label: 'Necesidad' }, { key: 'presupuesto', label: 'Presupuesto' }, { key: 'plazo', label: 'Plazo' }, { key: 'siguiente', label: 'Siguiente paso' }, ], rule: 'DATOS: solo los campos que se hayan dicho, separados por punto y coma. Ejemplo: DATOS: nombre=Ana Ruiz; empresa=Acme; telefono=600123456. Omite los que no se hayan dicho; no escribas puntos suspensivos.', role: 'Eres mi asistente de ficha de cliente. Extraes los datos que el cliente dice y me avisas de qué falta por preguntar.', }, facilitador: { id: 'facilitador', icon: '🧑‍🏫', name: 'Facilitador de reunión', desc: 'Temas, decisiones y tareas de la reunión, en vivo. Se exporta.', kind: 'fields', tag: 'DATOS', exportable: true, slots: [ { key: 'tema', label: 'Tema actual' }, { key: 'decisiones', label: 'Decisiones' }, { key: 'tareas', label: 'Tareas (quién/qué)' }, { key: 'riesgos', label: 'Riesgos' }, { key: 'siguiente', label: 'Siguientes pasos' }, ], rule: 'DATOS: solo lo que se haya dicho. Ejemplo: DATOS: tema=presupuesto 2027; decisiones=aprobado el piloto; tareas=Ana envía la propuesta. Acumula, no borres lo anterior; no escribas puntos suspensivos.', role: 'Eres el facilitador de la reunión. Sigues el hilo, anotas decisiones y tareas, y avisas si se desvía o si algo queda sin dueño.', }, resumen: { id: 'resumen', icon: '📝', name: 'Resumen e ideas', desc: 'Resumen progresivo y las ideas que van saliendo.', kind: 'fields', tag: 'DATOS', exportable: true, slots: [ { key: 'resumen', label: 'Resumen hasta ahora' }, { key: 'ideas', label: 'Ideas' }, { key: 'dudas', label: 'Dudas abiertas' }, ], rule: 'DATOS: resumen=; ideas=; dudas=. No escribas puntos suspensivos.', role: 'Resumes la conversación según avanza y capturas las ideas y dudas que surgen.', }, }; function audioSkillMd(sk) { return `Esta skill SOLO actúa cuando llegan intervenciones marcadas con "[Hablante N]" (Hablante 1 = el usuario; Hablante 2 = el interlocutor). En una conversación normal de Claw, ignórala salvo que el usuario te pida editarla. ${sk.role} POR CADA intervención responde EXACTAMENTE en tres líneas, nada más: ${gram.contrato(sk)} ACCION es UNA de esas palabras, tal cual. En ${sk.tag} solo lo que se haya DICHO: un dato que nadie ha dicho se descarta. Si no hay nada útil que aportar ahora, usa "CONSEJO: —". Nunca borres un dato ya capturado. AUTO-EDICIÓN: puedes cambiarte a ti misma. Si el usuario te pide por el chat de Claw algo como "añade un campo email", "quita el presupuesto" o "sé más breve", edita ESTA misma skill con skill.edit y sigue la nueva versión desde ya.`; } // Superficie genérica: pinta las ranuras de la skill (objetivos o campos), // el consejo en vivo y, si procede, el botón de exportar a Excel. function surfaceHtml(sk) { return `

${sk.icon} ${sk.name}

${sk.kind === 'goals' ? 'Objetivos' : 'Datos capturados'}
En vivoEsperando a que empiece la conversación…
${sk.exportable ? '' : ''}