File size: 42,350 Bytes
97164aa
 
 
 
 
 
 
8b133cf
97164aa
8c8fcac
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f3e4e8
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
97164aa
 
 
 
 
8c8fcac
97164aa
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b133cf
97164aa
8b133cf
8c8fcac
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
// 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';

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:<nombre>`.
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('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); }
})();

// 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)' });
  local.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
  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)' });
  return [...local, ...settings.enabledExternals()];
}

const isLocal = id => id === 'onnx' || id === 'litert' || id.startsWith('litert:');
// Por defecto: Gemma-4 grande vía LiteRT-LM (build oficial -web, VERIFICADO que
// carga y hace tool-calls). E4B en escritorio, E2B en móvil/GPU débil. Si no
// cabe, cae al Elffuss LM healed (onnx, 850 MB) — cerebro seguro siempre.
const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches;
const defaultBrain = () => !realGPU ? 'onnx' : (isMobile() ? 'litert:gemma-e2b' : '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 (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 LFM2.5 (ligero).');
      const ok = await changeModel('onnx');
      _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 });
// 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
    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. Elige Gemma arriba si prefieres precisión.');
      if (await changeModel('onnx')) 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' : defaultBrain();
    const chain = [...new Set([saved, def, realGPU ? 'onnx' : null]
      .filter(id => id && available.has(id)))];
    if (!chain.length) 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=<reescríbelo entero y breve cada vez>; ideas=<las que hayan salido>; dudas=<las abiertas>. 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 dos líneas, nada más:
CONSEJO: <una línea accionable para el usuario, en su idioma>
${sk.rule}
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 `<!doctype html><html lang="es"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:">
<style>
:root{--bg:#0b0c14;--card:#12141f;--line:#232838;--brass:#c8a06a;--brass2:#e6c294;--indigo:#8272ff;--fg:#eae8f2;--dim:#8b91a3;--ok:#54dcc6}
*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:radial-gradient(130% 90% at 50% -10%,#181630,#0b0c14);color:var(--fg);padding:20px;min-height:100vh;display:flex;flex-direction:column}
h1{font-family:"Iowan Old Style",Georgia,serif;font-size:1.12rem;margin:0 0 3px;background:linear-gradient(96deg,var(--brass2),var(--indigo));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.sub{color:var(--dim);font-size:.6rem;letter-spacing:.15em;text-transform:uppercase;margin-bottom:14px;font-family:ui-monospace,monospace}
.sub b{color:var(--brass2);float:right;font-weight:500}
.bar{height:7px;border-radius:99px;background:var(--line);overflow:hidden;margin-bottom:16px}
.bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--brass),var(--indigo));transition:width .7s cubic-bezier(.2,.7,.2,1)}
.slot{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:11px;background:var(--card);margin-bottom:8px;transition:border-color .4s,background .4s,transform .3s}
.slot .dot{width:20px;height:20px;border-radius:50%;border:2px solid var(--line);flex:none;display:grid;place-items:center;font-size:.65rem;color:transparent;transition:all .4s;margin-top:1px}
.slot .k{font-size:.62rem;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);font-family:ui-monospace,monospace}
.slot .v{font-size:.9rem;color:var(--fg);line-height:1.35;word-break:break-word}
.slot .lbl{font-size:.88rem;color:var(--dim);transition:color .4s}
.slot.done{border-color:var(--brass);background:linear-gradient(120deg,rgba(200,160,106,.10),rgba(130,114,255,.05))}
.slot.done .dot{background:var(--ok);border-color:var(--ok);color:#06210b}
.slot.done .lbl{color:var(--fg)}
.slot.just{animation:pop .6s cubic-bezier(.2,.8,.2,1)}
@keyframes pop{0%{transform:scale(1)}40%{transform:scale(1.03)}100%{transform:scale(1)}}
.advice{margin-top:auto;padding:13px 15px;border:1px solid var(--line);border-left:3px solid var(--brass);border-radius:10px;background:rgba(200,160,106,.05);font-size:.9rem;line-height:1.4;color:var(--brass2)}
.advice .k{display:block;font-family:ui-monospace,monospace;font-size:.56rem;letter-spacing:.16em;text-transform:uppercase;color:var(--dim);margin-bottom:5px}
.advice.flash{animation:advin .4s ease}
@keyframes advin{0%{opacity:.4;transform:translateY(-3px)}100%{opacity:1;transform:none}}
button{margin-top:10px;width:100%;background:transparent;color:var(--brass);border:1px solid var(--line);border-radius:8px;padding:9px;font:inherit;font-size:.78rem;cursor:pointer}
button:hover{border-color:var(--brass)}
</style></head><body>
<h1>${sk.icon} ${sk.name}</h1>
<div class="sub">${sk.kind === 'goals' ? 'Objetivos' : 'Datos capturados'} <b id="count"></b></div>
<div class="bar"><i id="fill"></i></div>
<div id="slots"></div>
<div class="advice" id="advice"><span class="k">En vivo</span><span id="atext">Esperando a que empiece la conversación…</span></div>
${sk.exportable ? '<button id="exp">⬇ Exportar a Excel (CSV)</button>' : ''}
<script>
const KIND=${JSON.stringify(sk.kind)}, SLOTS=${JSON.stringify(sk.slots)}, NAME=${JSON.stringify(sk.name)};
let data={};
function render(justKey){
  const box=document.getElementById('slots'); box.innerHTML=''; let filled=0;
  for(const s of SLOTS){
    const v=data[s.key]; const has=KIND==='goals' ? v===true : !!(v&&String(v).trim());
    if(has) filled++;
    const el=document.createElement('div');
    el.className='slot'+(has?' done':'')+(s.key===justKey?' just':'');
    if(KIND==='goals'){ el.innerHTML='<div class="dot">✓</div><div class="lbl"></div>'; el.querySelector('.lbl').textContent=s.label; }
    else { el.innerHTML='<div class="dot">✓</div><div><div class="k"></div><div class="v"></div></div>';
      el.querySelector('.k').textContent=s.label; el.querySelector('.v').textContent=has?v:'—'; }
    box.appendChild(el);
  }
  document.getElementById('count').textContent=filled+' / '+SLOTS.length;
  document.getElementById('fill').style.width=(SLOTS.length?filled/SLOTS.length*100:0)+'%';
}
render(null);
addEventListener('message',e=>{
  const d=e.data||{}; if(d.type!=='surface-state') return;
  let just=null;
  if(d.data) for(const [k,v] of Object.entries(d.data)){ if(data[k]!==v&&v) just=k; data[k]=v; }
  render(just);
  if(typeof d.advice==='string'&&d.advice){ const a=document.getElementById('advice');
    document.getElementById('atext').textContent=d.advice;
    a.classList.remove('flash'); void a.offsetWidth; a.classList.add('flash'); }
});
const exp=document.getElementById('exp');
if(exp) exp.onclick=()=>{
  const rows=[['Campo','Valor']].concat(SLOTS.map(s=>[s.label,(data[s.key]==null?'':String(data[s.key]))]));
  const csv='\\ufeff'+rows.map(r=>r.map(c=>'"'+String(c).replace(/"/g,'""')+'"').join(';')).join('\\r\\n');
  const a=document.createElement('a');
  a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv);
  a.download=NAME.toLowerCase().replace(/[^a-z0-9]+/g,'-')+'.csv'; a.click();
};
try{parent.postMessage({type:'surface-ready'},'*');}catch(e){}
<\/script></body></html>`;
}

let copilotSkill = AUDIO_SKILLS.ventas;
let copilotData = {};

function copilotInitState() { copilotData = {}; }
function renderCopilotBoard() {
  try { ui.renderApp(copilotSkill.icon + ' ' + copilotSkill.name, surfaceHtml(copilotSkill)); } catch { /* */ }
}
function copilotPostState(advice) {
  // El visor de Claw es uno solo: si otra cosa (una app del agente, el cerebro
  // autónomo…) lo ocupa, la superficie desaparecía. Se re-monta sola.
  const f0 = document.getElementById('appframe');
  if (!f0 || f0.hidden || !(f0.srcdoc || '').includes(copilotSkill.name)) renderCopilotBoard();
  const frame = document.getElementById('appframe');
  const msg = { type: 'surface-state', id: copilotSkill.id, data: copilotData, advice: advice || '' };
  try { frame?.contentWindow?.postMessage(msg, '*'); } catch { /* */ }
  // Proyección a la app que abrió el copiloto: pantalla partida de verdad.
  if (window.__copilotOpener && window.opener) {
    try { window.opener.postMessage({ kind: 'surface-state', ...msg }, window.__copilotOpener); } catch { /* */ }
  }
}

// Parsea la respuesta del modelo: CONSEJO + la línea de datos de la skill.
function copilotParse(text) {
  const s = String(text || '');
  const cm = s.match(/CONSEJO:\s*(.+)/i);
  const tagRe = new RegExp(copilotSkill.tag + ':\\s*(.+)', 'i');
  // Exigir la marca CONSEJO:. Sin ella, la respuesta es el modelo poniéndose a
  // conversar (o haciendo de interlocutor) — y eso NO es un consejo: se tira.
  const advice = cm ? cm[1].trim() : '';
  const updates = {};
  const om = s.match(tagRe);
  // El modelo escribe «teléfono» con tilde y mis claves van sin ella: sin
  // normalizar, el dato se perdía. También rechaza los valores-plantilla
  // («…», «-») que a veces copia del propio ejemplo.
  const norm = k => k.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
  const VACIO = /^(…|\.\.\.|—|–|-|_+|n\/a|null|none|desconocido|sin datos)$/i;
  if (om) for (const pair of om[1].split(';')) {
    const m = pair.match(/\s*([\wáéíóúñÁÉÍÓÚÑ]+)\s*=\s*(.+)/);
    if (!m) continue;
    const key = norm(m[1]), raw = m[2].trim().replace(/^["'\u201c]|["'\u201d]$/g, '').trim();
    if (copilotSkill.kind === 'goals') { if (/^(si|sí|s|yes|y|true|1)$/i.test(raw)) updates[key] = true; }
    else if (raw && !VACIO.test(raw)) updates[key] = raw;
  }
  return { advice, updates };
}
function copilotEmit(text) {
  const { advice, updates } = copilotParse(text);
  for (const [k, v] of Object.entries(updates)) copilotData[k] = v;
  const clean = (advice || '').trim().replace(/^["'\u201c\u201d\s]+|["'\u201c\u201d\s]+$/g, '').trim();
  const show = (clean && !/^[—\-–_]+$/.test(clean)) ? clean : '';
  copilotPostState(show);
  if (show && window.__copilotOpener && window.opener) {
    try { window.opener.postMessage({ kind: 'copilot-advice', text: show }, window.__copilotOpener); } catch { /* */ }
  }
}
window.__copilotEmit = copilotEmit;

// Turno de copiloto por la VÍA RÁPIDA: no pasa por el bucle agéntico (que
// mete todas las herramientas, las skills y el estado del sistema en el
// prompt). Un modelo pequeño se pierde en ese muro y se pone a conversar en
// vez de aconsejar — además de tardar mucho más. Aquí: prompt mínimo,
// historial corto y el formato recordado en cada turno.
let copilotHist = [];
let copilotBusy = false;
async function copilotTurn(line) {
  if (copilotBusy) return;                 // un turno a la vez: el modelo es uno
  copilotBusy = true;
  const sk = copilotSkill;
  const sys = `Eres el copiloto de ${sk.name}. Escuchas una conversación ajena y AYUDAS a quien la mantiene. NUNCA respondas como si fueras un interlocutor ni continúes el diálogo.
Responde SIEMPRE exactamente en dos líneas, sin comillas y sin nada más:
CONSEJO: <una línea accionable para el usuario>
${sk.rule}
Si no hay nada útil que decir: CONSEJO: —`;
  copilotHist.push({ role: 'user', content: line });
  if (copilotHist.length > 8) copilotHist = copilotHist.slice(-8);
  const recordatorio = `\n\n(Responde SOLO con las dos líneas: CONSEJO: … y ${sk.tag}: …)`;
  const msgs = copilotHist.slice(0, -1).concat([{ role: 'user', content: line + recordatorio }]);
  try {
    const out = await agent.provider.chat(msgs, sys, () => {});
    copilotHist.push({ role: 'assistant', content: String(out).slice(0, 400) });
    ui.addMsg('assistant', String(out).slice(0, 300));
    copilotEmit(out);
  } catch (e) {
    ui.addMsg('assistant err', 'copiloto: ' + (e?.message || e));
  } finally { copilotBusy = false; }
}


// ── Pipe cross-origin: skills de audio en vivo desde el Translator ──
(function copilotPipe() {
  try { if (!/[?&]copilot=1/.test(location.search)) return; } catch { return; }
  const ALLOWED = 'https://translator.elffuss.utopiaia.com';
  // qué skill de audio se ha pedido (?skill=ficha, etc.)
  const want = (location.search.match(/[?&]skill=([\w-]+)/) || [])[1];
  if (want && AUDIO_SKILLS[want]) copilotSkill = AUDIO_SKILLS[want];
  let started = false;
  window.addEventListener('message', e => { if (e.data && e.data.type === 'surface-ready') copilotPostState(''); });
  window.addEventListener('message', async e => {
    if (e.origin !== ALLOWED) return;
    window.__copilotOpener = ALLOWED;
    const m = e.data || {};
    if (m.kind === 'copilot-init' && !started) {
      started = true;
      if (m.skill && AUDIO_SKILLS[m.skill]) copilotSkill = AUDIO_SKILLS[m.skill];
      const sk = copilotSkill;
      const sellerLang = m.sellerLangName || 'el idioma del usuario';
      try {
        if (!skills.installed().some(s => s.name === sk.name)) {
          await skills.createSkill({ name: sk.name, description: sk.desc, instructions: audioSkillMd(sk) });
        }
      } catch { /* */ }
      copilotInitState();
      renderCopilotBoard();
      // Proyectar la superficie a la app que nos abrió → pantalla partida real.
      try { window.opener?.postMessage({ kind: 'surface', v: 1, id: sk.id, title: sk.icon + ' ' + sk.name, html: surfaceHtml(sk) }, ALLOWED); } catch { /* */ }
      clearInterval(window.__copilotBoardWatch);
      window.__copilotBoardWatch = setInterval(() => {
        const f = document.getElementById('appframe');
        if (document.hidden) return;
        if (!f || f.hidden || !(f.srcdoc || '').includes(sk.name)) { renderCopilotBoard(); copilotPostState(''); }
      }, 5000);
      setTimeout(() => { if (activeModel === 'rules') copilotPostState('Cargando el cerebro… empiezo en cuanto esté listo.'); }, 800);
      copilotHist = [];
    } else if (m.kind === 'transcript' && m.text) {
      const line = '[' + (m.speaker || 'interlocutor') + '] ' + String(m.text).slice(0, 400);
      ui.addMsg('user', line);
      copilotTurn(line);
    }
  });
  try { if (window.opener) window.opener.postMessage({ kind: 'copilot-ready' }, ALLOWED); } catch { /* */ }
})();