Stand-in import of elffuss/code
One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
This commit is contained in:
245
web/js/ceo.js
Normal file
245
web/js/ceo.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// 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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user