File size: 13,684 Bytes
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
// 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 */ }
}