elffuss-code / js /ceo.js
KikoCis's picture
Elffuss Code — browser IDE with a local AI agent (WebGPU)
b30b7c5 verified
Raw
History Blame Contribute Delete
13.7 kB
// 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 */ }
}