Spaces:
Running
Running
| // Cerebro CEO autónomo (compartido por Elffuss Code y Elffuss Claw): cuando el | |
| // usuario NO está pidiendo nada (ocioso) y hay un modelo local cargado, la elfa | |
| // «trabaja por su cuenta» — revisa el espacio de trabajo, reparte el trabajo en | |
| // varios perfiles que piensan EN PARALELO (cada uno una línea de pensamiento en | |
| // la vista Mente) y sintetiza propuestas de mejora. | |
| // | |
| // Agnóstico de herramientas a propósito: cada app inyecta su propio adaptador | |
| // de workspace (`init({ workspace, ... })`) — Code usa code.js (proyecto de | |
| // código), Claw usa fs.js (carpetas con permiso). El core no sabe ni le importa | |
| // cuál es. | |
| // | |
| // Seguridad: NO modifica tus ficheros. Deja las propuestas en una carpeta | |
| // aditiva (nunca toca lo existente) y las hace «flotar» en la Mente. Se PARA en | |
| // cuanto detecta actividad del usuario y reanuda al volver a estar ocioso. | |
| import { Agent } from './agent.js'; | |
| import { humanizeTool } from './humanize.js'; | |
| const IDLE_MS = 18000; // 18 s sin actividad → el CEO se pone a trabajar | |
| const TICK_MS = 3000; // frecuencia de comprobación | |
| // 5 min entre ciclos automáticos: con 45s, una tarde ociosa generaba MILES de | |
| // ficheros. «Pensar ahora» (forceCycle) sigue disponible sin esperar esto. | |
| const COOLDOWN_MS = 300000; | |
| const SOUL_CAP = 25; // ficheros sueltos antes de consolidar en archivo.md | |
| // perfil por defecto si la app anfitriona no da los suyos | |
| const GENERIC_PROFILES = [ | |
| { id: 'p1', name: 'Revisión', focus: 'qué mejorar de forma concreta y accionable', color: '#7c5cff' }, | |
| { id: 'p2', name: 'Calidad', focus: 'errores, casos borde, cosas que podrían fallar', color: '#49e8ff' }, | |
| ]; | |
| const slug = s => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') | |
| .replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 24) || 'perfil'; | |
| // ── namespace (localStorage) y adaptador de workspace: los da la app anfitriona ── | |
| let NS = 'elffuss'; // prefijo de las claves de localStorage | |
| let ws = null; // { isReady, tree, write, read, list, remove } | |
| let getProvider = () => null; | |
| let isBusy = () => false; // ¿el usuario tiene trabajo en cola/procesándose? → prioridad | |
| let defaultProfiles = GENERIC_PROFILES; | |
| const K = suffix => NS + '.' + suffix; | |
| function loadProfiles() { | |
| try { const s = JSON.parse(localStorage.getItem(K('ceoProfiles'))); if (Array.isArray(s) && s.length) return s; } catch { /* */ } | |
| return defaultProfiles.map(p => ({ ...p })); | |
| } | |
| let profiles = null; | |
| export function getProfiles() { if (!profiles) profiles = loadProfiles(); return profiles; } | |
| export function setProfiles(list) { | |
| const used = new Set(); | |
| profiles = (list || []).filter(p => p && p.name).map(p => { | |
| let id = p.id || slug(p.name); let n = id, i = 2; | |
| while (used.has(n)) n = id + '-' + i++; | |
| used.add(n); | |
| return { id: n, name: String(p.name).slice(0, 40), focus: String(p.focus || '').slice(0, 200), color: /^#[0-9a-f]{6}$/i.test(p.color || '') ? p.color : '#7c5cff' }; | |
| }); | |
| if (!profiles.length) profiles = defaultProfiles.map(p => ({ ...p })); | |
| try { localStorage.setItem(K('ceoProfiles'), JSON.stringify(profiles)); } catch { /* */ } | |
| emit('ceo', { type: 'reprogram', text: 'Perfiles actualizados: ' + profiles.map(p => p.name).join(', '), profiles }); | |
| return profiles; | |
| } | |
| let enabled = false, running = false, lastActivity = Date.now(), timer = null, lastCycleEnd = 0, cycleN = 0; | |
| // MISIÓN reprogramable: el usuario puede reorientar el cerebro desde la Mente | |
| // («céntrate en seguridad», «optimiza mis Excel», «documenta todo»…). | |
| let DEFAULT_MISSION = 'Revisar el espacio de trabajo y proponer mejoras concretas y accionables.'; | |
| let mission = null; | |
| function ensureMission() { if (mission == null) { try { mission = localStorage.getItem(K('ceoMission')) || DEFAULT_MISSION; } catch { mission = DEFAULT_MISSION; } } } | |
| export function getMission() { ensureMission(); return mission; } | |
| export function setMission(text) { | |
| mission = (text || '').trim() || DEFAULT_MISSION; | |
| try { localStorage.setItem(K('ceoMission'), mission); } catch { /* */ } | |
| emit('ceo', { type: 'reprogram', text: 'Nueva misión recibida: ' + mission }); | |
| lastCycleEnd = 0; lastActivity = Date.now() - IDLE_MS; // que arranque un ciclo pronto con la nueva misión | |
| return mission; | |
| } | |
| // Carpeta-«alma» donde el cerebro crea y guarda TODO (configurable). | |
| let DEFAULT_SOUL = '.elffuss/soul'; | |
| let soulDir = null; | |
| function ensureSoulDir() { if (soulDir == null) { try { soulDir = localStorage.getItem(K('ceoDir')) || DEFAULT_SOUL; } catch { soulDir = DEFAULT_SOUL; } } } | |
| export function getSoulDir() { ensureSoulDir(); return soulDir; } | |
| export function setSoulDir(dir) { | |
| soulDir = (dir || '').trim().replace(/^\/+|\/+$/g, '') || DEFAULT_SOUL; | |
| try { localStorage.setItem(K('ceoDir'), soulDir); } catch { /* */ } | |
| emit('ceo', { type: 'reprogram', text: 'Nueva carpeta-alma: ' + soulDir + '/' }); | |
| return soulDir; | |
| } | |
| // ── semáforo cross-pestaña: UN SOLO cerebro ejecuta, TODAS visualizan ─────── | |
| // El líder tiene un Web Lock exclusivo (se libera solo al cerrar la pestaña); | |
| // difunde sus pensamientos por BroadcastChannel para que el resto los vea. | |
| // (Aislado por origen por el propio navegador: Code y Claw nunca se cruzan.) | |
| let isLeader = false, bc = null, realEmit = () => {}, crossTabWired = false; | |
| function initCrossTab() { | |
| if (crossTabWired) return; | |
| crossTabWired = true; | |
| try { | |
| bc = new BroadcastChannel(NS + '-ceo'); | |
| bc.onmessage = e => { if (e.data && e.data.kind === 'thought') realEmit(e.data.channel, e.data.ev); }; | |
| } catch { /* sin BroadcastChannel */ } | |
| if (navigator.locks && navigator.locks.request) { | |
| navigator.locks.request(NS + '-ceo-leader', { mode: 'exclusive' }, () => new Promise(() => { isLeader = true; })) | |
| .catch(() => { isLeader = true; }); | |
| } else { isLeader = true; } | |
| } | |
| function emit(channel, ev) { | |
| realEmit(channel, ev); | |
| try { bc && bc.postMessage({ kind: 'thought', channel, ev }); } catch { /* ev no serializable */ } | |
| } | |
| // init({ workspace, provider, onEvent, isBusy, namespace, defaultProfiles, defaultMission, defaultSoulDir }) | |
| export function init(opts = {}) { | |
| if (opts.workspace) ws = opts.workspace; | |
| if (opts.provider) getProvider = opts.provider; | |
| if (opts.onEvent) realEmit = opts.onEvent; | |
| if (opts.isBusy) isBusy = opts.isBusy; | |
| if (opts.namespace) NS = opts.namespace; | |
| if (opts.defaultProfiles) defaultProfiles = opts.defaultProfiles; | |
| if (opts.defaultMission) DEFAULT_MISSION = opts.defaultMission; | |
| if (opts.defaultSoulDir) DEFAULT_SOUL = opts.defaultSoulDir; | |
| initCrossTab(); | |
| } | |
| export function isThisTabLeader() { return isLeader; } | |
| export function noteActivity() { lastActivity = Date.now(); if (running) running = 'interrupt'; } | |
| export function isEnabled() { return enabled; } | |
| export function isRunning() { return !!running; } | |
| // Play/stop: persistido AQUÍ (fuente única) — cualquier botón que lo toque | |
| // queda sincronizado, y la elección sobrevive a recargar la página. | |
| export function wasEnabledLastSession() { try { return localStorage.getItem(K('ceoEnabled')) === '1'; } catch { return false; } } | |
| // ¿el usuario llegó a decidir alguna vez (play o stop)? Distingue «nunca lo | |
| // tocó» de «lo pausó a propósito» — solo lo primero debe auto-activarse al | |
| // abrir la Mente; lo segundo hay que RESPETARLO, no pisarlo. | |
| export function hasDecided() { try { return localStorage.getItem(K('ceoEnabled')) != null; } catch { return false; } } | |
| export function enable() { | |
| if (enabled) return; | |
| enabled = true; lastActivity = Date.now(); schedule(); | |
| try { localStorage.setItem(K('ceoEnabled'), '1'); } catch { /* */ } | |
| emit('sys', { type: 'status', text: 'CEO en guardia — trabajaré cuando estés ocioso' }); | |
| } | |
| export function disable() { | |
| enabled = false; running = false; if (timer) clearTimeout(timer); | |
| try { localStorage.setItem(K('ceoEnabled'), '0'); } catch { /* */ } | |
| emit('sys', { type: 'status', text: 'CEO en pausa' }); | |
| } | |
| function schedule() { if (timer) clearTimeout(timer); timer = setTimeout(tick, TICK_MS); } | |
| // «Pensar ahora» — salta la espera de ociosidad. Sigue respetando el | |
| // semáforo: si otra pestaña es la líder, no compite por la GPU. | |
| export async function forceCycle() { | |
| if (!isLeader) { emit('ceo', { type: 'paused', text: 'Otra pestaña lleva el cerebro — ábrela ahí para forzar un ciclo.' }); return false; } | |
| if (running) return false; | |
| // isReady() puede ser síncrona (Code: handle en memoria) o async (Claw: consulta IndexedDB) — se espera siempre. | |
| if (!(await ws?.isReady()) || !getProvider()) { emit('ceo', { type: 'paused', text: 'Necesito un espacio de trabajo abierto y un modelo cargado.' }); return false; } | |
| try { await runCycle(); } finally { lastCycleEnd = Date.now(); } | |
| return true; | |
| } | |
| async function tick() { | |
| if (!enabled) return; | |
| const idle = Date.now() - lastActivity; | |
| const rested = Date.now() - lastCycleEnd > COOLDOWN_MS; | |
| const mightRun = isLeader && !running && !isBusy() && idle >= IDLE_MS && rested && getProvider(); | |
| if (mightRun && await ws?.isReady()) { | |
| try { await runCycle(); } catch { /* siguiente ciclo */ } | |
| lastCycleEnd = Date.now(); | |
| } | |
| schedule(); | |
| } | |
| // helper: corre el agente con el proveedor actual sobre un prompt, emitiendo | |
| // tokens/herramientas a un canal. Devuelve el texto final. Las tool-calls pasan | |
| // SIEMPRE por el mismo runTool que usa el chat normal (Agent.handle real). | |
| async function think(channel, task) { | |
| const prov = getProvider(); | |
| if (!prov) return ''; | |
| const a = new Agent({ chat: (h, s, cb) => prov.chat(h, s, cb) }); | |
| let out = ''; | |
| await a.handle(task, ev => { | |
| if (running === 'interrupt') throw new Error('interrumpido'); | |
| if (ev.type === 'token') { out += ev.text; emit(channel, { type: 'token', text: ev.text }); } | |
| else if (ev.type === 'tool') emit(channel, { type: 'tool', text: humanizeTool(ev.call.tool, ev.call.args), tool: ev.call.tool, path: ev.call.args?.path || null }); | |
| else if (ev.type === 'tool_result') emit(channel, { type: 'tool_result', tool: ev.tool, text: String(ev.result || '').replace(/\s+/g, ' ').trim().slice(0, 100) }); | |
| else if (ev.type === 'text') { out = ev.text; } | |
| }); | |
| return out; | |
| } | |
| async function runCycle() { | |
| running = true; | |
| cycleN++; | |
| emit('ceo', { type: 'cycle', n: cycleN, text: 'Nuevo ciclo: reviso el espacio de trabajo y reparto el trabajo…' }); | |
| let tree = ''; | |
| try { tree = await ws.tree({ depth: 2 }); } catch { /* sin workspace */ } | |
| emit('ceo', { type: 'survey', text: 'Panorama captado (' + tree.split('\n').length + ' entradas). Delegando…' }); | |
| const brief = (d) => `MISIÓN del equipo (fijada por el usuario): ${getMission()}\n` + | |
| `Eres el jefe de ${d.name}. Dentro de esa misión, céntrate en: ${d.focus}. ` + | |
| `Explora lo mínimo con tus herramientas y propón UNA mejora CONCRETA y accionable, ` + | |
| `entendible por un humano. Sé breve. No modifiques nada existente: solo la propuesta.`; | |
| const proposals = await Promise.all(getProfiles().map(async d => { | |
| emit(d.id, { type: 'open', name: d.name, focus: d.focus }); | |
| try { const p = await think(d.id, brief(d)); emit(d.id, { type: 'done', text: p }); return { dept: d.name, text: p }; } | |
| catch { emit(d.id, { type: 'done', text: '(interrumpido)' }); return null; } | |
| })); | |
| if (running === 'interrupt') { running = false; emit('ceo', { type: 'paused', text: 'Vuelves tú — dejo lo mío y te cedo el mando.' }); return; } | |
| const valid = proposals.filter(Boolean).filter(p => p.text && p.text.length > 8); | |
| const md = `# Propuestas de mejora — ciclo ${cycleN}\n\n**Misión:** ${getMission()}\n\n` + | |
| valid.map(p => `## ${p.dept}\n${p.text}\n`).join('\n') + | |
| `\n_— generado por el cerebro CEO de Elffuss mientras estabas ocioso._\n`; | |
| const d = new Date(); | |
| const stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`; | |
| const topic = slug((valid[0]?.text || 'ciclo').split(/[.,;\n]/)[0]) || 'ciclo'; | |
| const path = `${getSoulDir()}/${stamp}-${topic}.md`; | |
| try { | |
| await rotateSoul(); | |
| await ws.write({ path, content: md }); | |
| emit('ceo', { type: 'built', text: `Propuesta guardada en ${path}`, path, md, proposals: valid }); | |
| } catch (e) { | |
| emit('ceo', { type: 'built', text: 'Propuesta lista (no pude escribir el fichero)', md, proposals: valid }); | |
| } | |
| running = false; | |
| } | |
| // Rotación: si hay demasiados ficheros sueltos, los MÁS ANTIGUOS se consolidan | |
| // (recopilan) en un único archivo.md y se borran — para no acabar con miles. | |
| async function rotateSoul() { | |
| try { | |
| const soul = getSoulDir(); | |
| const names = (await ws.list(soul)).filter(n => n.endsWith('.md') && n !== 'archivo.md'); | |
| if (names.length < SOUL_CAP) return; | |
| names.sort(); // el nombre empieza por fecha → orden cronológico | |
| const excess = names.slice(0, names.length - SOUL_CAP + 1); | |
| let archive = ''; try { archive = await ws.read({ path: `${soul}/archivo.md` }); } catch { archive = '# Archivo histórico del cerebro\n'; } | |
| for (const name of excess) { | |
| try { archive += `\n---\n## ${name}\n${await ws.read({ path: `${soul}/${name}` })}\n`; } catch { /* */ } | |
| } | |
| await ws.write({ path: `${soul}/archivo.md`, content: archive.slice(-80000) }); | |
| for (const name of excess) { try { await ws.remove(soul, name); } catch { /* */ } } | |
| emit('ceo', { type: 'reprogram', text: `Consolidé ${excess.length} propuestas antiguas en archivo.md` }); | |
| } catch { /* soulDir aún sin crear o sin permiso: nada que rotar */ } | |
| } | |