File size: 55,939 Bytes
97164aa
 
 
 
 
 
 
8b133cf
97164aa
8c8fcac
 
 
97164aa
 
 
 
c0868b7
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0868b7
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0868b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
 
7f3e4e8
c0868b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
c0868b7
 
 
 
97164aa
 
c0868b7
 
97164aa
c0868b7
 
 
 
97164aa
c0868b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
 
8c8fcac
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
97164aa
 
 
 
 
8c8fcac
97164aa
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b133cf
97164aa
8b133cf
c0868b7
8c8fcac
97164aa
 
 
 
 
 
 
 
c0868b7
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0868b7
8c8fcac
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
c0868b7
 
 
 
 
 
 
 
97164aa
 
8c8fcac
 
 
 
 
c0868b7
 
8c8fcac
97164aa
 
 
c0868b7
97164aa
 
 
c0868b7
 
 
 
 
 
 
 
 
 
 
 
 
97164aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0868b7
 
 
8c8fcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0868b7
8c8fcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0868b7
 
 
 
8c8fcac
c0868b7
8c8fcac
c0868b7
 
 
 
 
8c8fcac
c0868b7
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
c0868b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c8fcac
 
 
 
 
 
 
 
 
 
 
c0868b7
 
8c8fcac
c0868b7
 
 
8c8fcac
 
 
c0868b7
8c8fcac
 
 
 
 
c0868b7
 
 
 
 
 
 
 
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
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
// 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';
import * as gram from './copilot-grammar.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('onnx:')) {             // ONNX concreto (Elffuss LM, Qwen3…)
    const mod = await import('./providers/onnx.js');
    mod.configure(id.slice(5));
    return mod;
  }
  if (id.startsWith('engine:')) {           // runtime WebGPU propio (GGUF)
    // El motor no se versiona aquí: llega a web/js/engine/ por rsync, igual que
    // los pesos de web/models/. Si el directorio no está, el import falla y
    // changeModel lo trata como cualquier otro fallo de carga.
    const mod = await import('./engine/provider.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); }
})();

// El runtime propio NO se versiona en este repo: llega a web/js/engine/ por el
// rsync del deploy, igual que los pesos. Se SONDEA en vez de cablear un flag que
// haya que acordarse de cambiar — así aparece en el selector solo donde está
// desplegado de verdad, y ofrecerlo nunca es una promesa que falle al elegirlo.
let ENGINE_READY = false;
const engineCheck = (async () => {
  try {
    const r = await fetch('js/engine/provider.js', { method: 'HEAD', cache: 'no-store' });
    return (ENGINE_READY = r.ok);
  } catch { return (ENGINE_READY = false); }
})();

// El 27B se comprueba APARTE del motor, porque son dos cosas independientes: el
// motor llega por rsync y el modelo es un fichero de gigabytes que puede no
// estar alojado. Con el motor puesto y el modelo no, la opción aparecería y
// fallaría al elegirla — y esta cuesta más que ninguna, porque el fallo llega
// después de esperar.
//
// Un HEAD basta y no descarga nada. Si el modelo se muda a otro alojamiento hay
// que cambiar esta ruta también; el precio de tenerla aquí es ese, y a cambio no
// se importa el motor entero en cada carga de página solo para preguntárselo.
// (RETIRADO Y DEVUELTO). Estuvo fuera porque colgaba el ordenador. La causa se
// encontró y se arregló: el motor pedía a la GPU buffers temporales enormes y no
// los soltaba hasta el final del forward, y sus núcleos corrían tanto sin ceder
// que el sistema no llegaba a refrescar la pantalla.
//   · reciclado entre capas → el pico de memoria de GPU bajó unas 16 veces
//   · lotes de 128 → ningún despacho retiene la GPU tanto rato
// Con eso, una carga completa con generación dejó de colgar nada.
//
// Se devuelve al selector porque el fallo tiene causa medida y corregida, no
// porque haya dejado de reproducirse por suerte. Si vuelve a colgar una
// máquina, el sitio correcto para mirar es el pico de memoria de GPU
// (ops.ctx.picoBytes) y la duración de los despachos, no otra vez el modelo.
//
// El síntoma, por si hay que volver a retirarlo: despachos de GPU tan largos que
// el escritorio dejaba de refrescarse y el sistema acababa reiniciándose. Colgarle
// el ordenador a alguien es peor que cualquier respuesta mala. La regla que lo
// evita: los núcleos troceán su trabajo en despachos cortos para que la GPU pueda
// atender al escritorio entre uno y otro.
const RETIRADO_27B_CUELGA_LA_MAQUINA = false;   // arreglado: ver arriba
// Se le pregunta AL MOTOR por sus propios ficheros, en vez de repetir aquí una
// URL. Aquí había cableada la del 27B de un solo trozo; cuando pasó a dos
// fragmentos en dos hosts, esta línea se quedó apuntando al fichero viejo y
// seguía dando 200 de milagro, porque el fichero aún estaba en el servidor. O
// sea: el selector decidía si ofrecer el modelo mirando un fichero que ya no se
// usa. El día que se borre para recuperar disco, la opción desaparece y nadie
// relaciona las dos cosas.
//
// Se importa `registro.js` y no `provider.js` a propósito: el registro son unos
// kilobytes y el proveedor arrastra el motor entero, que no tiene por qué
// bajarse quien nunca lo elija.
let MODEL27_READY = false;
const modelo27Check = (async () => {
  try {
    const reg = await import('./engine/registro.js');
    return (MODEL27_READY = await reg.disponible('qwen38-27b'));
  } catch { return (MODEL27_READY = 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) — más ligero, carga antes' });
  local.push({ id: 'onnx:qwen3.5-0.8b', label: 'Qwen3.5-0.8B · WebGPU (~600 MB) — ligero' });
  // Runtime propio: solo se ofrece si el motor está desplegado (llega por rsync,
  // no por git). Ofrecerlo cuando no está sería prometer algo que falla al
  // elegirlo. Ver ENGINE_READY.
  if (realGPU && ENGINE_READY) local.push({ id: 'engine:qwen35-0.8b', label: 'Qwen3.5-0.8B · motor propio (~800 MB)' });
  // El 27B no está aquí por dudoso: su forward está verificado contra llama.cpp
  // capa a capa y genera texto coherente. Está aquí por lo que CUESTA, y el coste
  // ya está MEDIDO, no supuesto: además de la descarga, leer un prompt largo
  // —persona, herramientas y contexto, que es todo prompt real de Claw— lo deja
  // muy por debajo de cualquier otro cerebro de la lista, y el retraso cae entero
  // ANTES del primer token, que es donde peor se lleva.
  //
  // Se sigue ofreciendo porque verlo correr tiene valor por sí mismo, pero la
  // etiqueta lo dice: quien lo elija para trabajar se va a arrepentir, y eso no
  // puede descubrirse después de bajar los gigas.
  //
  // Ya SÍ se guarda, y costó entender por qué no podía. El navegador corta un
  // fichero suelto sobre los 1,94 GB, y el broker guardaba cada modelo en uno:
  // 3,8 GB fallaban con «exceed its storage quota», que suena a falta de
  // espacio y no lo era. Troceando en partes de 1 GiB entra, y medido con el
  // registro del servidor la segunda carga son CERO peticiones. La etiqueta
  // pasa de «gigas cada vez» a «gigas una vez», que cambia la decisión.
  //
  // Aquí decía «solo conversa», y era cierto mientras el modelo tuvo 512 tokens
  // de contexto: no le cabía el catálogo de herramientas. Ya tiene 2048, que
  // supera CTX_MINIMO_COMPLETO, así que recibe el prompt completo y el catálogo
  // con él. La afirmación caducó y se quita.
  //
  // NO se sustituye por «puede usar herramientas»: que reciba el catálogo es un
  // hecho del código, pero que un modelo a 1 bit emita un bloque ```tool bien
  // formado no lo ha comprobado nadie. Prometer eso en una etiqueta es
  // exactamente el error que esta etiqueta existe para evitar.
  if (realGPU && ENGINE_READY && MODEL27_READY && !RETIRADO_27B_CUELGA_LA_MAQUINA) local.push({ id: 'engine:qwen38-27b', label: 'Qwen3.8-27B IQ1 · motor propio (~7,6 GB, se guarda: solo se baja la primera vez) — lento', group: '⚠ Avanzado' });
  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)' });
  // Cerebros de bajo rendimiento: fuera del flujo normal, en un grupo avanzado y
  // avisados. Elffuss LM (LFM2.5-1.2B) medía flojo en el copiloto (no rastrea).
  const weak = [{ id: 'onnx:elffuss-lm', label: 'Elffuss LM (LFM2.5-1.2B) — poco rendimiento', group: '⚠ Avanzado · sin garantía de rendimiento' }];
  return [...local, ...settings.enabledExternals(), ...weak];
}

const isLocal = id => id === 'onnx' || id === 'litert' || id.startsWith('litert:') ||
  id.startsWith('onnx:') || id.startsWith('engine:');
// Por defecto: Gemma-4 grande vía LiteRT-LM (build oficial -web, VERIFICADO que
// carga y hace tool-calls) SOLO en escritorio (E4B). En MÓVIL no: E2B pesa ~1.9 GB
// y el navegador móvil (mata la pestaña por encima de ~1-2 GB de datos vivos) no lo
// sostiene, así que se re-descargaba y fallaba. Móvil → Elffuss LM (onnx, ~850 MB):
// entra y funciona. E2B/E4B siguen elegibles a mano para quien tenga músculo.
const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches;
async function gpuCapacity() {
  const mem = navigator.deviceMemory || 8;
  if (!navigator.gpu) return { gpu: false, maxBuf: 0, mem };
  try { const a = await navigator.gpu.requestAdapter();
    return a ? { gpu: true, maxBuf: a.limits?.maxBufferSize || 0, mem } : { gpu: false, maxBuf: 0, mem };
  } catch { return { gpu: false, maxBuf: 0, mem }; }
}
async function pickLocalBrain() {
  const c = await gpuCapacity();
  if (c.gpu && !isMobile()) {
    if (c.maxBuf >= 2 ** 31 && c.mem >= 8) return 'litert:gemma-e4b';
    if (c.maxBuf >= 2 ** 30 && c.mem >= 6) return 'litert:gemma-e2b';
  }
  // Móvil: aunque el navegador móvil tenga WebGPU, NO cargar Gemma grande
  // —no cabe en su presupuesto de memoria—. Cerebro ligero: Qwen3.5-0.8B.
  return 'onnx:qwen3.5-0.8b';
}
const defaultBrain = () => (!realGPU || isMobile()) ? 'onnx:qwen3.5-0.8b' : '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 (id === 'onnx:elffuss-lm') ui.toast('⚠ Cerebro de bajo rendimiento: puede no rastrear bien ni dar buenos consejos. Para ventas/precisión usa Gemma (escritorio).');
    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 Qwen3.5-0.8B (ligero).');
      const ok = await changeModel('onnx:qwen3.5-0.8b');
      _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 });
ui.mountDiskChip();   // cuánto ocupan los modelos en este navegador, y cómo vaciarlo
// 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
    // El sondeo del motor propio resuelve después de que ui.init() pintara el
    // selector, así que hay que repintarlo: si no, la opción no aparece hasta
    // que algo más fuerce un refresco, y el usuario no la ve nunca.
    // Los dos sondeos se esperan JUNTOS. Repintar solo con el del motor dejaría
    // fuera el 27B cuando su comprobación tarda un poco más, y esa opción no
    // volvería a aparecer hasta que algo forzara otro refresco.
    const [hayMotor, hay27] = await Promise.all([engineCheck, modelo27Check]);
    if (hayMotor || hay27) refreshModelOptions();
    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. Para ventas/precisión, elige Gemma arriba (escritorio).');
      if (await changeModel('onnx:qwen3.5-0.8b')) 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' : await pickLocalBrain();
    const chain = [...new Set([saved, def, realGPU ? 'onnx' : null]
      .filter(id => id && available.has(id)))];
    if (!chain.length) return;
    // MÓVIL: no descargar medio giga sin permiso. Hasta hoy, abrir esto en un
    // teléfono arrancaba SOLO la descarga del cerebro (~600 MB) al cargar la
    // página. Dos motivos para no hacerlo: son los datos de alguien que no ha
    // pedido nada, y encima el navegador de móvil mata la pestaña bastante
    // antes de sostener el modelo, así que muchas veces se gastaban para nada.
    // Se avisa, se deja el botón por si quiere insistir, y se recuerda su
    // elección. El modo básico sigue atendiendo mientras tanto.
    if (isMobile() && localStorage.getItem('elffuss.movil.ok') !== '1') {
      ui.mobileGate('~600 MB', () => {
        (async () => { for (const id of chain) if (await changeModel(id)) return; })();
      });
      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 tres líneas, nada más:
${gram.contrato(sk)}
ACCION es UNA de esas palabras, tal cual. En ${sk.tag} solo lo que se haya DICHO: un dato que nadie ha dicho se descarta.
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 = {}; copilotTranscript = []; }
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 contra la GRAMÁTICA del copiloto
// (copilot-grammar.js): consejo libre pero verificado, acción de vocabulario
// cerrado, y datos que solo entran si pasan el validador de su ranura y están
// anclados en lo que se ha dicho. Guardia: tests/copilot_gramatica.mjs.
function copilotParse(text) {
  return gram.parse(text, copilotSkill, { transcript: copilotTranscript.join('\n') });
}
// El consejo se emite DESPUÉS de aplicar los datos (los del modelo y, si toca,
// los deterministas): cuando el modelo no da uno utilizable, el que se compone
// desde la acción + el tablero tiene que mirar el tablero YA corregido.
function copilotEmit(text, { applyRules = false, isGemma = false } = {}) {
  const { advice, action, updates, rejected } = copilotParse(text);
  for (const [k, v] of Object.entries(updates)) copilotData[k] = v;
  if (applyRules) copilotHybrid(isGemma);
  // Qué ha filtrado la gramática, a mano para depurar en una llamada real.
  if (rejected.length) { window.__copilotRejected = rejected; console.debug('[copiloto] gramática rechazó:', rejected.join(', ')); }
  // Sin consejo utilizable el copiloto NO se calla: la acción es de vocabulario
  // cerrado (fiable incluso en cerebro ligero) y con el tablero da uno real.
  // Solo en un turno de copiloto (applyRules): por aquí pasan TAMBIÉN las
  // respuestas normales del chat de Claw, y esas no deben pisar el consejo con
  // un «pregunta ahora por…» que nadie ha pedido.
  const show = advice || (applyRules ? gram.fallbackAdvice(copilotSkill, copilotData, action) : '');
  copilotPostState(show);
  if (show && window.__copilotOpener && window.opener) {
    try { window.opener.postMessage({ kind: 'copilot-advice', text: show }, window.__copilotOpener); } catch { /* */ }
  }
}
window.__copilotEmit = copilotEmit;

// ── Extracción DETERMINISTA de datos del cliente ──────────────────────────────
// El cerebro ligero NO rastrea fiable: Qwen marca todos los objetivos «sí» desde
// el turno 1 (falsos positivos) y LFM2.5 no marca nada; además alucinan teléfonos.
// Los datos regex-ables —teléfono (también HABLADO: «seis uno uno…»), email,
// nombre— se sacan de la transcripción con reglas y MANDAN sobre el modelo.
const _NUM = { cero: 0, uno: 1, una: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7, ocho: 8, nueve: 9 };
function _spelledDigits(t) {
  const ws = t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').split(/[^a-z0-9]+/);
  let run = [], best = '';
  for (const w of ws) {
    if (w in _NUM) run.push(_NUM[w]);
    else if (/^\d+$/.test(w)) { for (const d of w) run.push(+d); }
    else { if (run.length > best.length) best = run.join(''); run = []; }
  }
  if (run.length > best.length) best = run.join('');
  return best;
}
function _phone(t) {
  const m = t.replace(/[\s.\-()]/g, '').match(/\+?\d{7,}/);
  if (m) return m[0];
  const sp = _spelledDigits(t);
  return sp.length >= 7 ? sp : null;
}
const _EMAIL_RE = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/i;
const _NAME_RE = /\b(?:me llamo|mi nombre es|soy|le habla|habla)\s+([A-ZÁÉÍÓÚÑ][a-záéíóúñ]+(?:\s+[A-ZÁÉÍÓÚÑ][a-záéíóúñ]+){0,2})/;
const _NAME_STOP = /^(de|del|la|el|un|una|responsable|comercial|gerente|director|jefe)\b/i;
const _INTERES_RE = /\b(me interesa|nos interesa|interesad|me viene bien|suena bien|me gustar[ií]a)\b/i;
const _COMPRA_RE = /\b(quiero contratar|queremos contratar|quiero comprar|lo contrato|me lo quedo|adelante|cerramos|firmamos|hacemos el pedido)\b/i;
// Solo lo que dice el CLIENTE (Hablante 2); el vendedor (Hablante 1) no es el fichado.
function _clientText(lines) {
  return lines.filter(l => /^\[Hablante 2|^\[interlocutor/i.test(l))
    .map(l => l.replace(/^\[[^\]]*\]\s*/, '')).join('\n');
}
function deterministicFields(lines) {
  const t = _clientText(lines), out = {};
  const ph = _phone(t); if (ph) out.telefono = ph;
  const em = (t.match(_EMAIL_RE) || [])[0]; if (em) out.email = em;
  const nm = (t.match(_NAME_RE) || [])[1]; if (nm && !_NAME_STOP.test(nm)) out.nombre = nm.trim();
  return out;
}
function deterministicGoals(lines) {
  const t = _clientText(lines), out = {};
  const f = deterministicFields(lines);
  if (f.telefono) out.telefono = true;
  if (f.nombre) out.nombre = true;
  if (_INTERES_RE.test(t)) out.interes = true;
  if (_COMPRA_RE.test(t)) out.compra = true;
  return out;
}
let copilotTranscript = [];
// Corrige copilotData con las reglas tras la respuesta del modelo:
//  · ficha  → teléfono/email/nombre MANDAN (más fiables que el modelo pequeño).
//  · ventas en cerebro LIGERO → objetivos SOLO por reglas (mata el «todo sí»).
//    en Gemma → se suma como respaldo (Gemma ya rastrea bien).
function copilotHybrid(isGemma) {
  const sk = copilotSkill;
  if (sk.id === 'ficha') {
    Object.assign(copilotData, deterministicFields(copilotTranscript));
  } else if (sk.id === 'ventas') {
    const dg = deterministicGoals(copilotTranscript);
    if (isGemma) { for (const k in dg) copilotData[k] = true; }
    else { for (const s of sk.slots) copilotData[s.key] = !!dg[s.key]; }
  }
}

// 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;
  // El contrato lo escribe la gramática (copilot-grammar.js), no este prompt: si
  // el formato cambia aquí y allí no, el validador tira todo lo que llegue.
  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 tres líneas, sin comillas y sin nada más:
${gram.contrato(sk)}
ACCION es UNA de esas palabras, tal cual. En ${sk.tag} solo lo que se haya DICHO: nada inventado.
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 tres líneas: CONSEJO: … / ACCION: … / ${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));
    // La transcripción se apunta ANTES de emitir: la gramática la usa para anclar
    // los datos (lo que nadie dijo no entra) y para cazar el consejo que solo es
    // eco del interlocutor.
    copilotTranscript.push(line);
    // applyRules → corrección determinista dentro del emit: los datos
    // regex-ables mandan sobre el modelo y, en cerebro ligero, los objetivos
    // salen SOLO de reglas. El consejo se compone ya con el tablero corregido.
    copilotEmit(out, { applyRules: true, isGemma: activeModel.startsWith('litert') });
  } 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 { /* */ }
})();