KikoCis commited on
Commit
73063cc
·
1 Parent(s): 094ecad

Fix the brain that stopped loading, plus workspace, Hard Work and i18n

Browse files

- litert.js imported the runtime from an unpinned CDN URL. On 11 Aug version
0.16.0 shipped, jsdelivr cannot build its +esm bundle (404), and the Gemma
brain stopped loading in production without a line of our code changing.
The version is now pinned; check the URL returns 200 before bumping it.
- Workspace: pick a folder on disk and keep sessions, skills and generated
apps as normal files. Inventory of what is stored, one-click wipe, and a
downloadable copy for browsers without File System Access. The encrypted
vault is never exported in the clear.
- Hard Work (RLM): work a whole folder in parts — map over chunks, merge the
findings, recurse when the merge still doesn't fit.
- Settings reordered: live usage (memory, disk, loaded brain) and cleanup come
first; external cloud models move to a collapsed section at the end.
- Data-loss fixes: a transient IndexedDB read error used to wipe every skill
and collapse the conversation store to a single entry.
- Claw UI completed in Ukrainian, French, German and Portuguese.

Files changed (17) hide show
  1. css/code.css +5 -2
  2. css/lab.css +131 -0
  3. index.html +4 -4
  4. js/challenge.js +0 -74
  5. js/conversations.js +9 -1
  6. js/i18n.js +126 -2
  7. js/lab.js +574 -0
  8. js/main.js +140 -34
  9. js/providers/litert.js +38 -9
  10. js/rlm.js +404 -0
  11. js/skills.js +21 -1
  12. js/splash-gl.js +3 -3
  13. js/swe-tasks.js +74 -0
  14. js/tools/code.js +103 -26
  15. js/workspace.js +289 -0
  16. lab.html +123 -0
  17. style.css +0 -28
css/code.css CHANGED
@@ -1,7 +1,8 @@
1
  /* Elffuss Code — VS Code oscuro con alma élfica */
2
  :root {
3
- --bg: #0b0d12; --bg2: #11141c; --bg3: #171b26; --line: #262c3d;
4
- --fg: #e8ebf4; --muted: #8b93a8; --accent: #ff4d8d; --accent2: #7c5cff;
 
5
  }
6
  * { box-sizing: border-box; }
7
  html, body { height: 100%; margin: 0; }
@@ -301,6 +302,8 @@ body.hide-tree #sidebar { display: none; }
301
  /* 🎯 Modo Objetivo: botón + tarjeta de plan (planificador/ejecutor) */
302
  .cbtn.goal { font-size: .74rem; }
303
  .cbtn.goal.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2050, #2a1840); }
 
 
304
  .msg.plan-card { background: var(--bg3); border: 1px solid var(--line); border-radius: 11px; padding: 10px 12px; font-size: .82rem; align-self: stretch; max-width: 100%; }
305
  .plan-card .plan-head { font-weight: 600; margin-bottom: 2px; }
306
  .plan-card .plan-summary { color: var(--muted); font-size: .78rem; margin-bottom: 8px; }
 
1
  /* Elffuss Code — VS Code oscuro con alma élfica */
2
  :root {
3
+ --bg: #090a11; --bg2: #101317; --bg3: #161a24; --line: #20252c;
4
+ --fg: #eae8f2; --muted: #8b929c; --accent: #c8a06a; --accent2: #8272ff;
5
+ --serif: "Iowan Old Style", Georgia, "Times New Roman", serif;
6
  }
7
  * { box-sizing: border-box; }
8
  html, body { height: 100%; margin: 0; }
 
302
  /* 🎯 Modo Objetivo: botón + tarjeta de plan (planificador/ejecutor) */
303
  .cbtn.goal { font-size: .74rem; }
304
  .cbtn.goal.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2050, #2a1840); }
305
+ .cbtn.hardwork { font-size: .74rem; }
306
+ .cbtn.hardwork.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2a18, #2a1f40); box-shadow: 0 0 0 1px var(--accent) inset; }
307
  .msg.plan-card { background: var(--bg3); border: 1px solid var(--line); border-radius: 11px; padding: 10px 12px; font-size: .82rem; align-self: stretch; max-width: 100%; }
308
  .plan-card .plan-head { font-weight: 600; margin-bottom: 2px; }
309
  .plan-card .plan-summary { color: var(--muted); font-size: .78rem; margin-bottom: 8px; }
css/lab.css ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Elffuss Lab — panel de banco de pruebas. Estética de instrumental: oscuro,
2
+ datos en monoespaciada, acentos fosforescentes y nada de adorno que no diga
3
+ un dato. Independiente de code.css para no pisar el IDE. */
4
+ :root {
5
+ --bg: #070b0e;
6
+ --panel: #0d141a;
7
+ --panel-2: #111b23;
8
+ --line: #1d2c37;
9
+ --ink: #d7e6ef;
10
+ --dim: #7d94a3;
11
+ --accent: #2af5c0;
12
+ --accent-2: #35b8ff;
13
+ --warn: #ffb648;
14
+ --bad: #ff5c72;
15
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body {
19
+ margin: 0; background: var(--bg); color: var(--ink);
20
+ font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
21
+ background-image:
22
+ radial-gradient(1200px 600px at 15% -10%, #0d2b33 0%, transparent 60%),
23
+ radial-gradient(900px 500px at 110% 10%, #101f36 0%, transparent 55%);
24
+ min-height: 100vh;
25
+ }
26
+ a { color: var(--accent-2); }
27
+
28
+ /* ── cabecera ── */
29
+ header {
30
+ display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
31
+ padding: 14px 20px; border-bottom: 1px solid var(--line);
32
+ background: linear-gradient(180deg, rgba(13,20,26,.95), rgba(13,20,26,.6));
33
+ position: sticky; top: 0; z-index: 20; backdrop-filter: blur(8px);
34
+ }
35
+ header h1 { margin: 0; font-size: 17px; letter-spacing: .06em; text-transform: uppercase; }
36
+ header h1 span { color: var(--accent); }
37
+ .sub { color: var(--dim); font-size: 12px; font-family: var(--mono); }
38
+ .spacer { flex: 1; }
39
+
40
+ /* piloto de estado */
41
+ .led { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 12px; color: var(--dim); }
42
+ .led i {
43
+ width: 9px; height: 9px; border-radius: 50%; background: #33454f; display: block;
44
+ box-shadow: 0 0 0 0 rgba(42,245,192,.5);
45
+ }
46
+ .led.on i { background: var(--accent); animation: pulse 1.8s infinite; }
47
+ .led.busy i { background: var(--warn); animation: pulse .9s infinite; }
48
+ .led.err i { background: var(--bad); }
49
+ @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(42,245,192,.45); } 70% { box-shadow: 0 0 0 7px rgba(42,245,192,0); } 100% { box-shadow: 0 0 0 0 rgba(42,245,192,0); } }
50
+
51
+ /* ── rejilla ── */
52
+ main { display: grid; grid-template-columns: minmax(300px, 380px) 1fr; gap: 16px; padding: 16px 20px 40px; align-items: start; }
53
+ @media (max-width: 940px) { main { grid-template-columns: 1fr; } }
54
+
55
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
56
+ .panel + .panel { margin-top: 14px; }
57
+ .panel > h2 {
58
+ margin: 0; padding: 11px 14px; font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
59
+ color: var(--dim); border-bottom: 1px solid var(--line); background: var(--panel-2);
60
+ display: flex; align-items: center; gap: 8px;
61
+ }
62
+ .panel > h2 .tag { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--accent); letter-spacing: .04em; }
63
+ .panel .body { padding: 12px 14px; }
64
+
65
+ /* ── controles ── */
66
+ .seg { display: grid; gap: 6px; }
67
+ .seg label {
68
+ display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px; cursor: pointer;
69
+ border: 1px solid var(--line); border-radius: 9px; background: #0a1116; transition: .15s;
70
+ }
71
+ .seg label:hover { border-color: #2b4250; }
72
+ .seg input { margin: 3px 0 0; accent-color: var(--accent); }
73
+ .seg label.sel { border-color: var(--accent); background: rgba(42,245,192,.07); }
74
+ .seg .t { font-weight: 600; }
75
+ .seg .d { color: var(--dim); font-size: 12px; }
76
+
77
+ .knobs { display: grid; gap: 4px; }
78
+ .knob { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: 7px; }
79
+ .knob:hover { background: #0a1116; }
80
+ .knob code { font-family: var(--mono); font-size: 12px; color: var(--ink); flex: 1; }
81
+ .knob .why { color: var(--dim); font-size: 11px; flex: 2; }
82
+ .knob input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; }
83
+ .knob input[type=number] { width: 68px; background: #0a1116; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; font-family: var(--mono); }
84
+ .group-title { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--dim); text-transform: uppercase; margin: 10px 0 4px; }
85
+
86
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
87
+ .btn {
88
+ border: 1px solid var(--line); background: #0e1a21; color: var(--ink);
89
+ padding: 9px 14px; border-radius: 9px; cursor: pointer; font: inherit; transition: .15s;
90
+ }
91
+ .btn:hover:not(:disabled) { border-color: var(--accent); color: #fff; }
92
+ .btn:disabled { opacity: .45; cursor: not-allowed; }
93
+ .btn.primary { background: rgba(42,245,192,.14); border-color: var(--accent); color: var(--accent); font-weight: 600; }
94
+ .btn.ghost { background: transparent; }
95
+
96
+ /* ── métricas ── */
97
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
98
+ .tile { background: #0a1116; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
99
+ .tile .k { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--dim); }
100
+ .tile .v { font-family: var(--mono); font-size: 22px; margin-top: 3px; color: var(--accent); }
101
+ .tile .v.small { font-size: 15px; }
102
+ .tile.bad .v { color: var(--bad); }
103
+ .tile.warn .v { color: var(--warn); }
104
+
105
+ /* barra de progreso */
106
+ .bar { height: 6px; background: #0a1116; border: 1px solid var(--line); border-radius: 99px; overflow: hidden; }
107
+ .bar > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .25s; }
108
+
109
+ /* ── tabla de resultados ── */
110
+ table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
111
+ th { text-align: left; color: var(--dim); font-weight: 500; font-size: 10px; letter-spacing: .1em; text-transform: uppercase; padding: 6px 8px; border-bottom: 1px solid var(--line); }
112
+ td { padding: 6px 8px; border-bottom: 1px solid #131f27; }
113
+ tr:last-child td { border-bottom: 0; }
114
+ .ok { color: var(--accent); }
115
+ .no { color: var(--bad); }
116
+ .pend { color: var(--dim); }
117
+
118
+ /* ── consola ── */
119
+ .log {
120
+ font-family: var(--mono); font-size: 11.5px; line-height: 1.6; color: #9fb6c4;
121
+ background: #060a0d; border: 1px solid var(--line); border-radius: 9px;
122
+ padding: 10px 12px; height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-word;
123
+ }
124
+ .log .t { color: #4d6572; }
125
+ .log .a { color: var(--accent); }
126
+ .log .w { color: var(--warn); }
127
+ .log .e { color: var(--bad); }
128
+
129
+ iframe.arena { width: 100%; height: 320px; border: 1px solid var(--line); border-radius: 9px; background: #000; }
130
+ .hint { color: var(--dim); font-size: 12px; margin: 8px 0 0; }
131
+ .pill { font-family: var(--mono); font-size: 10px; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line); color: var(--dim); }
index.html CHANGED
@@ -8,14 +8,14 @@
8
  <meta property="og:site_name" content="Elffuss">
9
  <meta property="og:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
10
  <meta property="og:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
11
- <meta property="og:url" content="https://elffuss-code.utopiaia.com/">
12
- <meta property="og:image" content="https://elffuss-code.utopiaia.com/og.png">
13
  <meta property="og:image:width" content="1200">
14
  <meta property="og:image:height" content="630">
15
  <meta name="twitter:card" content="summary_large_image">
16
  <meta name="twitter:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
17
  <meta name="twitter:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
18
- <meta name="twitter:image" content="https://elffuss-code.utopiaia.com/og.png">
19
  <meta name="description" content="Open a local folder and code with Elffuss: a web IDE plus an AI agent that runs on your own GPU (WebGPU). Nothing leaves your machine.">
20
  <link rel="icon" href="img/elffuss-code.svg">
21
  <link rel="stylesheet" href="css/code.css">
@@ -129,6 +129,7 @@
129
  <input id="prompt" autocomplete="off" placeholder="Pídele código a Elffuss…">
130
  <button type="button" id="btn-autoedit" class="cbtn autoedit on" title="Editar archivos automáticamente"><span class="ae-ico">&lt;/&gt;</span> <span class="ae-txt">Auto</span></button>
131
  <button type="button" id="btn-goal" class="cbtn goal" title="Modo Objetivo: descompone el mensaje en tareas y las ejecuta una a una (planificador + ejecutor)">🎯 <span class="goal-txt">Goal</span></button>
 
132
  <button type="submit" id="btn-send" title="Enviar"></button>
133
  </form>
134
  </div>
@@ -147,6 +148,5 @@
147
  </div>
148
 
149
  <script type="module" src="js/main.js"></script>
150
- <script type="module" src="js/challenge.js"></script>
151
  </body>
152
  </html>
 
8
  <meta property="og:site_name" content="Elffuss">
9
  <meta property="og:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
10
  <meta property="og:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
11
+ <meta property="og:url" content="https://code.elffuss.utopiaia.com/">
12
+ <meta property="og:image" content="https://code.elffuss.utopiaia.com/og.png">
13
  <meta property="og:image:width" content="1200">
14
  <meta property="og:image:height" content="630">
15
  <meta name="twitter:card" content="summary_large_image">
16
  <meta name="twitter:title" content="Elffuss Code — a VS Code-style IDE with an AI soul, in your browser">
17
  <meta name="twitter:description" content="A web IDE with an AI coding elf that reads, searches and edits your project. Open a folder and code with a local model on WebGPU — nothing leaves your machine.">
18
+ <meta name="twitter:image" content="https://code.elffuss.utopiaia.com/og.png">
19
  <meta name="description" content="Open a local folder and code with Elffuss: a web IDE plus an AI agent that runs on your own GPU (WebGPU). Nothing leaves your machine.">
20
  <link rel="icon" href="img/elffuss-code.svg">
21
  <link rel="stylesheet" href="css/code.css">
 
129
  <input id="prompt" autocomplete="off" placeholder="Pídele código a Elffuss…">
130
  <button type="button" id="btn-autoedit" class="cbtn autoedit on" title="Editar archivos automáticamente"><span class="ae-ico">&lt;/&gt;</span> <span class="ae-txt">Auto</span></button>
131
  <button type="button" id="btn-goal" class="cbtn goal" title="Modo Objetivo: descompone el mensaje en tareas y las ejecuta una a una (planificador + ejecutor)">🎯 <span class="goal-txt">Goal</span></button>
132
+ <button type="button" id="btn-hardwork" class="cbtn hardwork" title="Hard Work (RLM): lee tu proyecto ENTERO por partes — audita, resume o busca en código que no cabe en el modelo. Tarda, pero lo abarca todo.">⛏ <span class="hw-txt">Hard Work</span></button>
133
  <button type="submit" id="btn-send" title="Enviar"></button>
134
  </form>
135
  </div>
 
148
  </div>
149
 
150
  <script type="module" src="js/main.js"></script>
 
151
  </body>
152
  </html>
js/challenge.js DELETED
@@ -1,74 +0,0 @@
1
- // challenge.js — top bar announcing the 15-minute challenge, with the two places to post
2
- // the result. Self-contained (injects its own CSS), dismissible, and it removes itself
3
- // after the deadline so nobody has to remember to take it down.
4
- import { uiLang } from './i18n.js';
5
-
6
- const DEADLINE = Date.parse('2026-08-10T00:00:00Z'); // winner announced Sun Aug 9
7
- const KEY = 'elffuss_challenge_v1_dismissed';
8
- const X_URL = 'https://x.com/KikoCisneros/status/2085358719190130963';
9
- const LI_URL = 'https://www.linkedin.com/feed/update/urn:li:activity:7491125059768516608/';
10
-
11
- const COPY = {
12
- es: {
13
- text: '⚡ Reto de 15 min: abre una carpeta tuya y deja que Elffuss arregle un bug real. Gana el comentario con más likes — ganador el domingo 9.',
14
- x: 'Publicar en X', li: 'En LinkedIn', close: 'Cerrar aviso',
15
- },
16
- en: {
17
- text: '⚡ 15-min challenge: open one of your folders and let Elffuss fix a real bug. Most-liked comment wins — winner on Sun Aug 9.',
18
- x: 'Post on X', li: 'On LinkedIn', close: 'Dismiss',
19
- },
20
- };
21
-
22
- export function mountChallengeBar() {
23
- if (Date.now() > DEADLINE) return;
24
- try { if (localStorage.getItem(KEY)) return; } catch (e) {}
25
- const c = COPY[uiLang() === 'es' ? 'es' : 'en'];
26
-
27
- const css = document.createElement('style');
28
- css.textContent = `
29
- body.elf-ch { padding-top: 38px; }
30
- body.elf-ch #ide { height: calc(100vh - 38px); }
31
- .elf-ch-bar {
32
- position: fixed; inset: 0 0 auto 0; z-index: 400; height: 38px;
33
- display: flex; align-items: center; justify-content: center; gap: 10px;
34
- padding: 0 42px 0 12px; font-size: .82rem; color: #fff;
35
- background: linear-gradient(135deg, var(--accent, #7c5cff), var(--accent2, #ff5cd6));
36
- box-shadow: 0 2px 12px rgba(0,0,0,.35);
37
- }
38
- .elf-ch-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39
- .elf-ch-bar a {
40
- flex: none; padding: 3px 10px; border-radius: 999px; text-decoration: none;
41
- color: #fff; background: rgba(0,0,0,.28); font-weight: 600;
42
- }
43
- .elf-ch-bar a:hover { background: rgba(0,0,0,.45); }
44
- .elf-ch-bar button {
45
- position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
46
- background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; opacity: .8;
47
- }
48
- .elf-ch-bar button:hover { opacity: 1; }
49
- @media (max-width: 620px) { .elf-ch-bar span { font-size: .74rem; } }
50
- `;
51
- document.head.appendChild(css);
52
-
53
- const bar = document.createElement('div');
54
- bar.className = 'elf-ch-bar';
55
- bar.innerHTML = `<span></span>
56
- <a target="_blank" rel="noopener" href="${X_URL}"></a>
57
- <a target="_blank" rel="noopener" href="${LI_URL}"></a>
58
- <button type="button" aria-label=""></button>`;
59
- bar.querySelector('span').textContent = c.text;
60
- const [ax, ali] = bar.querySelectorAll('a');
61
- ax.textContent = c.x; ali.textContent = c.li;
62
- const btn = bar.querySelector('button');
63
- btn.textContent = '✕'; btn.setAttribute('aria-label', c.close);
64
- btn.addEventListener('click', () => {
65
- try { localStorage.setItem(KEY, '1'); } catch (e) {}
66
- bar.remove(); document.body.classList.remove('elf-ch');
67
- });
68
-
69
- document.body.classList.add('elf-ch');
70
- document.body.appendChild(bar);
71
- }
72
-
73
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountChallengeBar);
74
- else mountChallengeBar();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/conversations.js CHANGED
@@ -59,7 +59,15 @@ export function getActive() { return activeId ? convs.get(activeId) : null; }
59
  export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); }
60
 
61
  async function persistConv(conv) {
62
- const all = await db.get('kv', 'conversations').catch(() => null) || [];
 
 
 
 
 
 
 
 
63
  const i = all.findIndex(c => c.id === conv.id);
64
  const rec = {
65
  id: conv.id, title: titleFor(conv), history: conv.agent.history.slice(-HIST_CAP),
 
59
  export function getOpenTabs() { return openTabIds.map(id => convs.get(id)).filter(Boolean); }
60
 
61
  async function persistConv(conv) {
62
+ // NO tragarse el error de lectura: un fallo transitorio devolvía [] y el
63
+ // db.set de abajo reescribía el store entero con UNA sola conversación,
64
+ // borrando el resto. Distinguir «no existe aún» (undefined → []) de «no se
65
+ // pudo leer» (excepción → no escribir nada este turno).
66
+ let stored;
67
+ try { stored = await db.get('kv', 'conversations'); }
68
+ catch (e) { console.warn('[elffuss] no se pudo leer conversations; no sobrescribo', e); return; }
69
+ const all = stored == null ? [] : stored;
70
+ if (!Array.isArray(all)) { console.warn('[elffuss] conversations corrupto; no sobrescribo'); return; }
71
  const i = all.findIndex(c => c.id === conv.id);
72
  const rec = {
73
  id: conv.id, title: titleFor(conv), history: conv.agent.history.slice(-HIST_CAP),
js/i18n.js CHANGED
@@ -54,7 +54,69 @@ const L = {
54
  setCalc: 'Calculando espacio…', setPersist: '✓ almacenamiento persistente (no se borra solo)',
55
  setNoPersist: '⚠ sin persistencia: el navegador podría desalojarlo', setLimit: ' · límite ~{gb}',
56
  setModelPh: 'modelo (p. ej. gpt-4o-mini)', keyNeeded: 'clave (no necesaria)',
57
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  en: {
59
  ph: 'Ask Elffuss for code…',
60
  thinking: 'Elffuss is thinking', writing: 'Elffuss writes · {n} chars', using: 'Elffuss uses {name}',
@@ -103,7 +165,69 @@ const L = {
103
  setCalc: 'Calculating space…', setPersist: '✓ persistent storage (won’t be auto-cleared)',
104
  setNoPersist: '⚠ not persistent: the browser could evict it', setLimit: ' · limit ~{gb}',
105
  setModelPh: 'model (e.g. gpt-4o-mini)', keyNeeded: 'key (not needed)',
106
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  };
108
 
109
  // Idioma del chrome: 2 letras, ES por defecto.
 
54
  setCalc: 'Calculando espacio…', setPersist: '✓ almacenamiento persistente (no se borra solo)',
55
  setNoPersist: '⚠ sin persistencia: el navegador podría desalojarlo', setLimit: ' · límite ~{gb}',
56
  setModelPh: 'modelo (p. ej. gpt-4o-mini)', keyNeeded: 'clave (no necesaria)',
57
+ wsTitle: "Espacio de trabajo (tu disco)",
58
+ wsDesc: "Tus conversaciones y skills viven en este navegador, que puede borrarlas. Guárdalas en una carpeta y quedan como ficheros normales.",
59
+ wsNoSupport: "Este navegador no permite elegir carpeta. Usa «Descargar copia».",
60
+ wsNoFolder: "Sin carpeta: solo viven en este navegador.",
61
+ wsFolder: "Carpeta: {name}",
62
+ wsNeedsPerm: "«{name}» necesita permiso otra vez (pasa al recargar).",
63
+ wsPick: "Elegir carpeta",
64
+ wsUseProject: "Usar la del proyecto",
65
+ wsRegrant: "Conceder permiso",
66
+ wsSaveNow: "Guardar ahora",
67
+ wsSaved: "Guardado ({n} almacenes)",
68
+ wsAuto: "Guardar solo",
69
+ wsForget: "Olvidar carpeta",
70
+ wsDownload: "Descargar copia",
71
+ wsWhat: "Qué hay guardado",
72
+ setTitle: "Ajustes",
73
+ close: "cerrar",
74
+ setBrainTitle: "Cerebro (modelo)",
75
+ setModelE4bSub: "El mejor · WebGPU local · ~4 GB",
76
+ setModelE2bSub: "Ligero · WebGPU local · ~2 GB",
77
+ setModelOnnxSub: "Modelo propio · 850 MB · tool-calls + apps",
78
+ setModelRulesSub: "Órdenes directas, cero descarga",
79
+ setBridgeTitle: "🔌 Bridge local (ejecución real en tu máquina)",
80
+ setBridgeDisconnected: "desconectado",
81
+ setBridgeFolderPh: "/ruta/completa/a/tu/proyecto",
82
+ setBridgeConnect: "Conectar",
83
+ setBridgeOnStatus: "✓ conectado — ejecución real activa",
84
+ setBridgeNoToken: "⚠️ pega el token que imprimió el programa al arrancarlo",
85
+ setBridgeConnecting: "Conectando…",
86
+ setBridgeConnectedMsg: "✔ conectado — ejecución real en tu máquina",
87
+ setPermTitle: "✅ Permisos de ejecución",
88
+ setGoalTitle: "🎯 Modo Objetivo",
89
+ setTelTitle: "📨 Errores y feedback",
90
+ setTelEmpty: "escribe algo primero",
91
+ setTelThanks: "¡enviado, gracias!",
92
+ setProvTitle: "Proveedores externos (opcional · la clave se queda en tu navegador)",
93
+ setProvUse: "Usar",
94
+ setManageSkillsBtn: "Gestionar skills de Claude Code",
95
+ skRemove: "Quitar",
96
+ skBrowse: "Explorar",
97
+ skAddRepoPh: "owner/repo o URL de GitHub (p. ej. OpenClaude/…)",
98
+ skAddRepo: "Añadir repo",
99
+ hwPlaceholder: "⛏ Hard Work: pregunta algo sobre TODO el proyecto…",
100
+ hwNeedBrain: "Hard Work necesita un cerebro cargado: elige un modelo en Ajustes y reintenta.",
101
+ hwNeedProject: "Hard Work trabaja sobre tu proyecto. Abre una carpeta primero y vuelve a pulsar ⛏ Hard Work.",
102
+ hwReading: "Hard Work · leyendo el proyecto…",
103
+ hwNoFiles: "No encontré archivos de texto/código legibles en el proyecto.",
104
+ hwTrimmed: " — recortado",
105
+ hwPhaseReduce: "Hard Work · sintetizando…",
106
+ hwResultTrunc: " Proyecto recortado al máximo — acota con una subcarpeta.",
107
+ planPlanning: "planificando…",
108
+ planRunning: "ejecutando…",
109
+ planDone: "completado",
110
+ planFailed: "con fallos",
111
+ stModelLoadingAI: "Cargando el modelo IA…",
112
+ termCapsBridge: "🔌 Bridge local: node/npm/python reales",
113
+ termCapsNoBridge: "shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)",
114
+ setBridgeFolderLabel: "Carpeta de trabajo (opcional — si no, usa una temporal)",
115
+ setBridgeDesc: "Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.",
116
+ setBridgeDownload: "⬇ Descargar para {os}",
117
+ setBridgeOtherOS: "otro sistema operativo",
118
+ setModelRulesName: "Básico (sin modelo)",
119
+ },
120
  en: {
121
  ph: 'Ask Elffuss for code…',
122
  thinking: 'Elffuss is thinking', writing: 'Elffuss writes · {n} chars', using: 'Elffuss uses {name}',
 
165
  setCalc: 'Calculating space…', setPersist: '✓ persistent storage (won’t be auto-cleared)',
166
  setNoPersist: '⚠ not persistent: the browser could evict it', setLimit: ' · limit ~{gb}',
167
  setModelPh: 'model (e.g. gpt-4o-mini)', keyNeeded: 'key (not needed)',
168
+ wsTitle: "Workspace (your disk)",
169
+ wsDesc: "Your conversations and skills live in this browser, which can wipe them. Save them to a folder and they stay as normal files.",
170
+ wsNoSupport: "This browser can't pick a folder. Use «Download copy».",
171
+ wsNoFolder: "No folder: they only live in this browser.",
172
+ wsFolder: "Folder: {name}",
173
+ wsNeedsPerm: "«{name}» needs permission again (happens after a reload).",
174
+ wsPick: "Pick a folder",
175
+ wsUseProject: "Use the project's",
176
+ wsRegrant: "Grant permission",
177
+ wsSaveNow: "Save now",
178
+ wsSaved: "Saved ({n} stores)",
179
+ wsAuto: "Save automatically",
180
+ wsForget: "Forget folder",
181
+ wsDownload: "Download copy",
182
+ wsWhat: "What's stored",
183
+ setTitle: "Settings",
184
+ close: "close",
185
+ setBrainTitle: "Brain (model)",
186
+ setModelE4bSub: "The best · local WebGPU · ~4 GB",
187
+ setModelE2bSub: "Light · local WebGPU · ~2 GB",
188
+ setModelOnnxSub: "Our own model · 850 MB · tool-calls + apps",
189
+ setModelRulesSub: "Direct commands, zero download",
190
+ setBridgeTitle: "🔌 Local bridge (real execution on your machine)",
191
+ setBridgeDisconnected: "disconnected",
192
+ setBridgeFolderPh: "/full/path/to/your/project",
193
+ setBridgeConnect: "Connect",
194
+ setBridgeOnStatus: "✓ connected — real execution active",
195
+ setBridgeNoToken: "⚠️ paste the token the program printed on startup",
196
+ setBridgeConnecting: "Connecting…",
197
+ setBridgeConnectedMsg: "✔ connected — real execution on your machine",
198
+ setPermTitle: "✅ Execution permissions",
199
+ setGoalTitle: "🎯 Goal mode",
200
+ setTelTitle: "📨 Errors and feedback",
201
+ setTelEmpty: "type something first",
202
+ setTelThanks: "sent, thanks!",
203
+ setProvTitle: "External providers (optional · the key stays in your browser)",
204
+ setProvUse: "Use",
205
+ setManageSkillsBtn: "Manage Claude Code skills",
206
+ skRemove: "Remove",
207
+ skBrowse: "Browse",
208
+ skAddRepoPh: "owner/repo or GitHub URL (e.g. OpenClaude/…)",
209
+ skAddRepo: "Add repo",
210
+ hwPlaceholder: "⛏ Hard Work: ask something about the WHOLE project…",
211
+ hwNeedBrain: "Hard Work needs a loaded brain: pick a model in Settings and try again.",
212
+ hwNeedProject: "Hard Work works on your project. Open a folder first, then press ⛏ Hard Work again.",
213
+ hwReading: "Hard Work · reading the project…",
214
+ hwNoFiles: "I found no readable text/code files in the project.",
215
+ hwTrimmed: " — trimmed",
216
+ hwPhaseReduce: "Hard Work · synthesizing…",
217
+ hwResultTrunc: " Project trimmed to the max — narrow it down to a subfolder.",
218
+ planPlanning: "planning…",
219
+ planRunning: "running…",
220
+ planDone: "completed",
221
+ planFailed: "with failures",
222
+ stModelLoadingAI: "Loading the AI model…",
223
+ termCapsBridge: "🔌 Local bridge: real node/npm/python",
224
+ termCapsNoBridge: "project shell · node/npm/python → Local bridge (⚙ Settings)",
225
+ setBridgeFolderLabel: "Working folder (optional — otherwise a temporary one is used)",
226
+ setBridgeDesc: "A small program that YOU run on your computer — it gives the elf real execution (node, npm, python…) without leaving your machine. Nothing is installed in the browser.",
227
+ setBridgeDownload: "⬇ Download for {os}",
228
+ setBridgeOtherOS: "another operating system",
229
+ setModelRulesName: "Basic (no model)",
230
+ },
231
  };
232
 
233
  // Idioma del chrome: 2 letras, ES por defecto.
js/lab.js ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Elffuss Lab — banco de pruebas del propio producto.
2
+ //
3
+ // Tres cosas, en la misma mesa:
4
+ // 1) ELEGIR algoritmo de generación (directo / RLM map-reduce / RLM con
5
+ // crítica) y ENCENDER o APAGAR cada perilla del compresor de contexto.
6
+ // 2) LANZAR benchmarks reales y ver el progreso caso a caso, en vivo.
7
+ // 3) GUARDAR la corrida con su TRAZA en un historial y un ranking LOCALES:
8
+ // todo se queda en esta máquina, no se envía nada a ningún sitio.
9
+ //
10
+ // Nada de esto simula: el banco de contexto llama al `packHistoryACER` de
11
+ // verdad, el de SWE ejecuta los tests de verdad contra el módulo arreglado, y
12
+ // el de juegos ejecuta el juego generado y lo puntúa mirándolo correr.
13
+ import { DEFAULTS, packHistoryACER, estimateTokens } from './acer-core.js';
14
+ import { TASKS } from './swe-tasks.js';
15
+
16
+ const $ = s => document.querySelector(s);
17
+ const el = (t, c, x) => { const n = document.createElement(t); if (c) n.className = c; if (x != null) n.textContent = x; return n; };
18
+
19
+ // ─────────────────────────── estado ───────────────────────────
20
+ const S = {
21
+ brain: localStorage.getItem('elffusscode.model') || 'rules',
22
+ algo: 'directo',
23
+ algoOpts: { rounds: 1, chunkTokens: 1800, maxChunks: 24 },
24
+ ctx: {}, // overrides sobre DEFAULTS
25
+ bench: 'contexto',
26
+ provider: null,
27
+ running: false, abort: false,
28
+ run: null, // { bench, casos:[], métricas:{} } — la traza
29
+ };
30
+
31
+ // Perillas del compresor que tiene sentido tocar a mano (las demás quedan en su
32
+ // valor por defecto). El «porqué» sale del propio acer-core.
33
+ const CTX_KNOBS = [
34
+ ['recuperación', [
35
+ ['SEMANTIC', 'bool', 'fusiona embeddings con BM25 — INERTE en el banco de contexto (ese usa el packer sin embeddings)'],
36
+ ['MMR', 'bool', 'diversifica lo recuperado (evita repetir lo mismo)'],
37
+ ['DEDUP', 'bool', 'quita mensajes casi idénticos'],
38
+ ['PIN_QUERY_TERMS', 'bool', 'clava los términos de la pregunta'],
39
+ ['SUPERSEDE', 'bool', 'lo nuevo pisa a lo viejo cuando se contradicen'],
40
+ ['RECENCY_WEIGHT', 'num', 'peso de lo reciente al puntuar'],
41
+ ]],
42
+ ['presupuesto', [
43
+ ['ELASTIC', 'bool', 'estira/encoge según la presión real'],
44
+ ['RECENT', 'num', 'nº de turnos recientes intocables'],
45
+ ['TAIL_MIN_FRAC', 'num', 'suelo garantizado para la cola'],
46
+ ['HEAD_FRAC', 'num', 'reserva para el arranque (0 = apagada)'],
47
+ ['MAX_MSG_CHARS', 'num', 'recorte por mensaje'],
48
+ ]],
49
+ ['extras', [
50
+ ['DATES', 'bool', 'anota «ayer» con la fecha del turno'],
51
+ ['SUMMARY', 'bool', 'tarjeta de recuento (cuesta presupuesto)'],
52
+ ['AUTO', 'bool', 'deriva las perillas de lo medido en ESTE historial'],
53
+ ]],
54
+ ];
55
+
56
+ const ALGOS = [
57
+ ['directo', 'Directo', 'Una sola inferencia. El baremo contra el que se compara todo.'],
58
+ ['rlm-deep', 'RLM · crear y criticar', 'Borrador → crítica → reescritura, N rondas (deepCreate).'],
59
+ ['rlm-hard', 'RLM · Hard Work', 'Trocea el material, pregunta a cada trozo y funde (map-reduce).'],
60
+ ];
61
+
62
+ const BENCHES = [
63
+ ['contexto', 'Contexto (ACER)', 'Determinista y sin GPU: mide qué evidencia SOBREVIVE al compresor y a qué precio en tokens. Reacciona a las perillas al instante.'],
64
+ ['swe', 'SWE-bench-style', `${TASKS.length} repos con un bug real: el agente lo arregla con sus tools y se EJECUTA el test. Métrica: resolved/N.`],
65
+ ['juegos', 'Juegos (generar y jugar)', 'Genera un juego con el algoritmo elegido, lo ejecuta y lo puntúa: carga sin errores, arranca, se mueve y aguanta jugando.'],
66
+ ];
67
+
68
+ const BRAINS = [
69
+ ['rules', 'Reglas (sin GPU)', 'Determinista. Valida el arnés sin bajar pesos.'],
70
+ ['onnx', 'Elffuss LM (CPU/wasm)', 'LFM2.5-1.2B por transformers.js.'],
71
+ ['litert:gemma-e2b', 'Gemma-4 E2B (~2 GB)', 'WebGPU.'],
72
+ ['litert:gemma-e4b', 'Gemma-4 E4B (~3 GB)', 'WebGPU. El mejor.'],
73
+ ];
74
+
75
+ // ─────────────────────────── consola ───────────────────────────
76
+ function log(msg, cls = '') {
77
+ const t = new Date().toTimeString().slice(0, 8);
78
+ const line = el('div');
79
+ line.appendChild(el('span', 't', t + ' '));
80
+ line.appendChild(el('span', cls, msg));
81
+ $('#log').appendChild(line);
82
+ $('#log').scrollTop = $('#log').scrollHeight;
83
+ }
84
+ const setLed = (id, cls, txt) => { const n = $(id); n.className = 'led ' + cls; n.querySelector('span').textContent = txt; };
85
+ const prog = (done, total, txt) => {
86
+ $('#prog').style.width = total ? Math.round(done / total * 100) + '%' : '0';
87
+ $('#prog-txt').textContent = txt || (total ? `${done}/${total}` : '—');
88
+ };
89
+
90
+ function tiles(obj) {
91
+ const box = $('#tiles'); box.innerHTML = '';
92
+ for (const [k, v] of Object.entries(obj)) {
93
+ const t = el('div', 'tile' + (v && v.bad ? ' bad' : v && v.warn ? ' warn' : ''));
94
+ t.appendChild(el('div', 'k', k));
95
+ const val = (v && typeof v === 'object') ? v.v : v;
96
+ t.appendChild(el('div', 'v' + (String(val).length > 7 ? ' small' : ''), String(val)));
97
+ box.appendChild(t);
98
+ }
99
+ }
100
+ function table(cols, rows) {
101
+ $('#res-head').innerHTML = '<tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr>';
102
+ $('#res-body').innerHTML = '';
103
+ rows.forEach(addRow);
104
+ }
105
+ function addRow(cells) {
106
+ const tr = el('tr');
107
+ cells.forEach(c => {
108
+ const td = el('td');
109
+ if (c && typeof c === 'object') { td.className = c.cls || ''; td.textContent = c.t; }
110
+ else td.textContent = c;
111
+ tr.appendChild(td);
112
+ });
113
+ $('#res-body').appendChild(tr);
114
+ return tr;
115
+ }
116
+
117
+ // ─────────────────────────── interfaz ───────────────────────────
118
+ function radio(host, items, current, onPick) {
119
+ host.innerHTML = '';
120
+ items.forEach(([id, title, desc]) => {
121
+ const l = el('label', id === current ? 'sel' : '');
122
+ const i = el('input'); i.type = 'radio'; i.name = host.id; i.checked = id === current;
123
+ i.onchange = () => { onPick(id); radio(host, items, id, onPick); };
124
+ const d = el('div');
125
+ d.appendChild(el('div', 't', title));
126
+ d.appendChild(el('div', 'd', desc));
127
+ l.append(i, d); host.appendChild(l);
128
+ });
129
+ }
130
+
131
+ function ctxUI() {
132
+ const host = $('#ctx-knobs'); host.innerHTML = '';
133
+ for (const [group, knobs] of CTX_KNOBS) {
134
+ host.appendChild(el('div', 'group-title', group));
135
+ const box = el('div', 'knobs');
136
+ for (const [key, type, why] of knobs) {
137
+ const row = el('div', 'knob');
138
+ const cur = key in S.ctx ? S.ctx[key] : DEFAULTS[key];
139
+ const inp = el('input');
140
+ if (type === 'bool') { inp.type = 'checkbox'; inp.checked = !!cur; }
141
+ else { inp.type = 'number'; inp.step = 'any'; inp.value = cur ?? 0; }
142
+ inp.onchange = () => {
143
+ S.ctx[key] = type === 'bool' ? inp.checked : Number(inp.value);
144
+ ctxTag();
145
+ if (S.bench === 'contexto') log('perilla ' + key + ' → ' + S.ctx[key] + ' (relanza para medir)', 'w');
146
+ };
147
+ row.append(inp, el('code', '', key), el('div', 'why', why));
148
+ box.appendChild(row);
149
+ }
150
+ host.appendChild(box);
151
+ }
152
+ ctxTag();
153
+ }
154
+ function ctxTag() {
155
+ const on = CTX_KNOBS.flatMap(([, k]) => k).filter(([key, t]) => t === 'bool' && (key in S.ctx ? S.ctx[key] : DEFAULTS[key])).length;
156
+ $('#ctx-tag').textContent = on + ' activas';
157
+ }
158
+ function algoKnobs() {
159
+ const host = $('#algo-knobs'); host.innerHTML = '';
160
+ const defs = S.algo === 'rlm-hard'
161
+ ? [['chunkTokens', 'tamaño de cada trozo'], ['maxChunks', 'techo de sub-llamadas']]
162
+ : S.algo === 'rlm-deep' ? [['rounds', 'rondas de crítica + reescritura']] : [];
163
+ if (!defs.length) { host.appendChild(el('div', 'hint', 'El modo directo no tiene parámetros: una inferencia y ya.')); return; }
164
+ const box = el('div', 'knobs');
165
+ defs.forEach(([k, why]) => {
166
+ const row = el('div', 'knob');
167
+ const i = el('input'); i.type = 'number'; i.value = S.algoOpts[k]; i.min = 0;
168
+ i.onchange = () => { S.algoOpts[k] = Number(i.value); };
169
+ row.append(i, el('code', '', k), el('div', 'why', why));
170
+ box.appendChild(row);
171
+ });
172
+ host.appendChild(box);
173
+ }
174
+
175
+ // ─────────────────────────── cerebro ───────────────────────────
176
+ async function loadBrain() {
177
+ const id = S.brain;
178
+ $('#btn-load').disabled = true;
179
+ setLed('#led-model', 'busy', 'cargando ' + id + '…');
180
+ try {
181
+ let mod;
182
+ if (id === 'rules') mod = await import('./providers/rules.js');
183
+ else if (id === 'onnx') mod = await import('./providers/onnx.js');
184
+ else if (id.startsWith('litert:')) {
185
+ mod = await import('./providers/litert.js');
186
+ mod.configure(id.split(':')[1]);
187
+ }
188
+ if (mod.load) await mod.load(s => { $('#load-state').textContent = String(s).slice(0, 60); });
189
+ S.provider = mod;
190
+ setLed('#led-model', 'on', 'modelo: ' + id);
191
+ $('#load-state').textContent = '';
192
+ log('cerebro listo: ' + id, 'a');
193
+ } catch (e) {
194
+ setLed('#led-model', 'err', 'fallo al cargar');
195
+ log('no se pudo cargar el cerebro: ' + e.message, 'e');
196
+ }
197
+ $('#btn-load').disabled = false;
198
+ }
199
+
200
+ // ═══════════════ BANCO 1 · contexto (determinista, sin GPU) ═══════════════
201
+ // Construye un historial largo con HECHOS plantados y ruido alrededor, lo pasa
202
+ // por el compresor con las perillas elegidas y mide qué evidencia sobrevive y
203
+ // cuántos tokens cuesta. Sin modelo: mide el COMPRESOR, no al que responde.
204
+ // Cada caso trae: el dato BUENO, una versión CADUCADA del mismo dato (dicha
205
+ // antes) y distractores que comparten vocabulario con la pregunta. Así el banco
206
+ // no premia «meter mucho», sino traer lo correcto y dejar fuera lo viejo — que
207
+ // es lo que de verdad hacen SUPERSEDE, PIN_QUERY_TERMS o DEDUP.
208
+ const HECHOS = [
209
+ { bueno: 'Ahora el servidor de pagos escucha en el puerto 7421.', viejo: 'El servidor de pagos escuchaba en el puerto 3000.',
210
+ q: '¿en qué puerto escucha el servidor de pagos?', clave: '7421', claveVieja: '3000',
211
+ ruido: ['El servidor de correo va por otro puerto distinto.', 'Ese puerto del router no tiene nada que ver con pagos.'] },
212
+ { bueno: 'La clave de despliegue caduca el 3 de marzo.', viejo: 'La clave de despliegue caducaba el 9 de enero.',
213
+ q: '¿cuándo caduca la clave de despliegue?', clave: '3 de marzo', claveVieja: '9 de enero',
214
+ ruido: ['La clave del wifi la cambiaron en verano.', 'El despliegue de ayer no tocó ninguna clave.'] },
215
+ { bueno: 'Nordvik factura ahora en coronas.', viejo: 'Nordvik facturaba en euros.',
216
+ q: '¿en qué moneda factura Nordvik?', clave: 'coronas', claveVieja: 'facturaba en euros',
217
+ ruido: ['Nordvik cambió de comercial el trimestre pasado.', 'A otros clientes se les factura en euros.'] },
218
+ { bueno: 'El informe trimestral lo firma Beatriz.', viejo: 'El informe trimestral lo firmaba Andrés.',
219
+ q: '¿quién firma el informe trimestral?', clave: 'firma Beatriz', claveVieja: 'firmaba Andrés',
220
+ ruido: ['Andrés sigue en la empresa, pero en otro equipo.', 'Beatriz también revisa el informe mensual.'] },
221
+ { bueno: 'El backup nocturno corre a las 02:30.', viejo: 'El backup nocturno corría a las 23:00.',
222
+ q: '¿a qué hora corre el backup nocturno?', clave: '02:30', claveVieja: '23:00',
223
+ ruido: ['El backup semanal es otra tarea distinta.', 'A las 23:00 lo que hay es el corte de logs.'] },
224
+ { bueno: 'El almacén de Lugo pasó a Vigo.', viejo: 'El almacén principal estaba en Lugo.',
225
+ q: '¿a dónde se movió el almacén de Lugo?', clave: 'pasó a Vigo', claveVieja: 'estaba en Lugo',
226
+ ruido: ['En Lugo queda solo una oficina comercial.', 'El almacén de Vigo ya existía para otra cosa.'] },
227
+ ];
228
+ const RUIDO = [
229
+ 'Vale, seguimos mañana con eso.', 'Te paso el enlace en un momento.',
230
+ 'Creo que el diseño nuevo se ve mejor.', 'Nos vemos en la reunión de las cinco.',
231
+ 'Ese ticket lo cerró soporte la semana pasada.', 'Prefiero no tocar eso hoy.',
232
+ 'Lo dejo apuntado para el lunes.', 'Sí, ya lo hemos hablado antes.',
233
+ ];
234
+
235
+ // Historial LARGO: el dato bueno queda enterrado y fuera de la ventana reciente.
236
+ function historialCon(c, largo = 420) {
237
+ const h = [];
238
+ const posViejo = Math.floor(largo * 0.10);
239
+ const posBueno = Math.floor(largo * 0.55);
240
+ const posRuido = [Math.floor(largo * 0.30), Math.floor(largo * 0.75)];
241
+ for (let i = 0; i < largo; i++) {
242
+ let txt = RUIDO[i % RUIDO.length] + ' (' + i + ')';
243
+ if (i === posViejo) txt = c.viejo;
244
+ else if (i === posBueno) txt = c.bueno;
245
+ else if (i === posRuido[0]) txt = c.ruido[0];
246
+ else if (i === posRuido[1]) txt = c.ruido[1];
247
+ h.push({ role: i % 2 ? 'assistant' : 'user', content: txt, ts: Date.now() - (largo - i) * 60000 });
248
+ }
249
+ return h;
250
+ }
251
+
252
+ async function benchContexto(onCase) {
253
+ const opts = { ...DEFAULTS, ...S.ctx };
254
+ const budget = 320; // muy apretado: obliga a ELEGIR
255
+ const filas = [];
256
+ for (let i = 0; i < HECHOS.length; i++) {
257
+ if (S.abort) break;
258
+ const c = HECHOS[i];
259
+ const hist = historialCon(c);
260
+ hist.push({ role: 'user', content: c.q, ts: Date.now() });
261
+ const { messages } = packHistoryACER(hist, budget, opts);
262
+ const texto = messages.map(m => m.content).join('\n');
263
+ const sobrevive = texto.includes(c.clave);
264
+ const arrastraViejo = texto.includes(c.claveVieja);
265
+ const usados = estimateTokens(texto);
266
+ filas.push({ pregunta: c.q, sobrevive, arrastraViejo, usados, deN: hist.length, quedan: messages.length });
267
+ onCase(filas[filas.length - 1], i, HECHOS.length);
268
+ await new Promise(r => setTimeout(r, 25)); // deja respirar a la UI
269
+ }
270
+ const rec = filas.filter(f => f.sobrevive).length;
271
+ const conf = filas.filter(f => f.arrastraViejo).length;
272
+ return {
273
+ filas,
274
+ resumen: {
275
+ 'evidencia recuperada': `${rec}/${filas.length}`,
276
+ 'recall': (filas.length ? (rec / filas.length * 100).toFixed(0) : 0) + '%',
277
+ 'arrastra dato viejo': { v: `${conf}/${filas.length}`, bad: conf > 0 },
278
+ 'tokens medios': Math.round(filas.reduce((a, f) => a + f.usados, 0) / (filas.length || 1)),
279
+ 'presupuesto': budget,
280
+ 'mensajes que pasan': Math.round(filas.reduce((a, f) => a + f.quedan, 0) / (filas.length || 1)),
281
+ },
282
+ };
283
+ }
284
+
285
+ // ═══════════════ BANCO 2 · SWE-bench-style (ejecuta los tests) ═══════════════
286
+ async function opfsRoot() { return navigator.storage.getDirectory(); }
287
+ async function limpiarOPFS() {
288
+ const o = await opfsRoot();
289
+ for await (const e of o.values()) await o.removeEntry(e.name, { recursive: true }).catch(() => {});
290
+ }
291
+ async function sembrar(files) {
292
+ const o = await opfsRoot();
293
+ for (const [path, txt] of Object.entries(files)) {
294
+ const parts = path.split('/'); const name = parts.pop(); let d = o;
295
+ for (const x of parts) d = await d.getDirectoryHandle(x, { create: true });
296
+ const w = await (await d.getFileHandle(name, { create: true })).createWritable();
297
+ await w.write(txt); await w.close();
298
+ }
299
+ }
300
+ async function leer(path) {
301
+ const o = await opfsRoot();
302
+ const parts = path.split('/'); const name = parts.pop(); let d = o;
303
+ for (const x of parts) d = await d.getDirectoryHandle(x);
304
+ return (await (await d.getFileHandle(name)).getFile()).text();
305
+ }
306
+ // Ejecuta DE VERDAD el módulo arreglado y le pasa el test de la tarea.
307
+ async function pasaElTest(task) {
308
+ const src = await leer(task.target);
309
+ const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
310
+ try { return !!task.test(await import(/* @vite-ignore */ url)); }
311
+ catch { return false; }
312
+ finally { URL.revokeObjectURL(url); }
313
+ }
314
+
315
+ async function benchSWE(onCase) {
316
+ const code = await import('./tools/code.js');
317
+ await code.openProject(await opfsRoot());
318
+ const { Agent } = await import('./agent.js');
319
+ const filas = [];
320
+ for (let i = 0; i < TASKS.length; i++) {
321
+ if (S.abort) break;
322
+ const t = TASKS[i];
323
+ await limpiarOPFS(); await sembrar(t.files);
324
+ code.invalidateFileList();
325
+ const t0 = performance.now();
326
+ let pasos = 0, err = null;
327
+ try {
328
+ if (S.brain === 'rules') {
329
+ // Solver de guion: valida el ARNÉS (debe dar N/N) sin gastar GPU.
330
+ await sembrar({ [t.target]: t.solution });
331
+ } else {
332
+ const ag = new Agent(S.provider);
333
+ await ag.handle(`${t.task}\n\nEl fichero a arreglar es ${t.target}. Usa code.read y code.edit.`,
334
+ ev => { if (ev.type === 'tool') pasos++; });
335
+ }
336
+ } catch (e) { err = e.message; }
337
+ const ok = err ? false : await pasaElTest(t);
338
+ filas.push({ id: t.id, ok, pasos, secs: Math.round((performance.now() - t0) / 1000), err });
339
+ onCase(filas[filas.length - 1], i, TASKS.length);
340
+ }
341
+ const res = filas.filter(f => f.ok).length;
342
+ return { filas, resumen: { resolved: `${res}/${filas.length}`, '% resueltas': (filas.length ? (res / filas.length * 100).toFixed(0) : 0) + '%', 'pasos totales': filas.reduce((a, f) => a + f.pasos, 0) } };
343
+ }
344
+
345
+ // ═══════════════ BANCO 3 · juegos (genera, ejecuta y puntúa) ═══════════════
346
+ const BRIEF_JUEGO = 'Un juego tipo Flappy Bird: un pájaro que cae por gravedad, sube al pulsar espacio o click, ' +
347
+ 'tuberías que avanzan y hay que esquivar, colisiones, puntuación y pantalla de fin de partida.';
348
+
349
+ async function generarJuego() {
350
+ const rlm = await import('./rlm.js').catch(() => null);
351
+ if (!rlm) throw new Error('rlm.js no disponible en esta build');
352
+ let inferencias = 0;
353
+ const contando = { chat: (...a) => { inferencias++; return S.provider.chat(...a); } };
354
+ const rondas = S.algo === 'rlm-deep' ? Math.max(1, S.algoOpts.rounds) : 0;
355
+ const out = await rlm.deepCreate({ brief: BRIEF_JUEGO, provider: contando, rounds: rondas, onProgress: e => log(' ' + JSON.stringify(e)) });
356
+ return { html: out.html, inferencias, trace: out.trace };
357
+ }
358
+
359
+ // Puntúa el juego EJECUTÁNDOLO en la arena: ¿carga sin errores? ¿arranca?
360
+ // ¿se mueve? ¿aguanta jugando? Cada criterio suma.
361
+ async function puntuarJuego(html) {
362
+ const frame = $('#arena');
363
+ $('#arena-panel').hidden = false;
364
+ const errores = [];
365
+ const onErr = e => errores.push(String(e.message || e).slice(0, 120));
366
+ frame.srcdoc = html;
367
+ await new Promise(r => frame.onload = r);
368
+ const w = frame.contentWindow, d = frame.contentDocument;
369
+ w.addEventListener('error', onErr);
370
+ const hash = () => {
371
+ const c = d.querySelector('canvas'); if (!c) return -1;
372
+ try { const px = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
373
+ let h = 7; for (let i = 0; i < px.length; i += 997) h = (h * 31 + px[i]) % 999983; return h;
374
+ } catch { return -1; }
375
+ };
376
+ const esperar = ms => new Promise(r => setTimeout(r, ms));
377
+ const canvas = !!d.querySelector('canvas');
378
+ await esperar(600);
379
+ const h0 = hash();
380
+ // arrancar: pulsar el botón si lo hay, y quitarle el foco (si no, ESPACIO
381
+ // re-pulsa el botón en vez de saltar — nos pasó midiéndolo a mano)
382
+ const btn = d.querySelector('button');
383
+ if (btn) { btn.click(); btn.blur?.(); }
384
+ await esperar(400);
385
+ const arranca = hash() !== h0;
386
+ // jugar: aletear con click en el canvas, que es el manejador que sí registran
387
+ const c = d.querySelector('canvas');
388
+ let vivoMs = 0, puntos = 0;
389
+ const t0 = performance.now();
390
+ for (let i = 0; i < 20; i++) {
391
+ c?.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));
392
+ d.dispatchEvent(new w.KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
393
+ await esperar(300);
394
+ const run = (() => { try { return w.gameRunning; } catch { return null; } })();
395
+ puntos = Math.max(puntos, (() => { try { return w.score || 0; } catch { return 0; } })());
396
+ if (run === false) break;
397
+ vivoMs = performance.now() - t0;
398
+ }
399
+ const criterios = {
400
+ 'carga sin errores': errores.length === 0,
401
+ 'tiene canvas': canvas,
402
+ 'arranca': arranca,
403
+ 'aguanta ≥3 s jugando': vivoMs >= 3000,
404
+ 'llega a puntuar': puntos > 0,
405
+ };
406
+ return { criterios, errores, vivoSegs: +(vivoMs / 1000).toFixed(1), puntos, bytes: html.length };
407
+ }
408
+
409
+ async function benchJuegos(onCase) {
410
+ if (!S.provider || S.brain === 'rules') throw new Error('este banco necesita un modelo de verdad (Reglas no genera juegos)');
411
+ const filas = [];
412
+ log('generando el juego con «' + S.algo + '»… esto tarda', 'w');
413
+ const g = await generarJuego();
414
+ log(`generado: ${g.html.length} bytes en ${g.inferencias} inferencias`, 'a');
415
+ const p = await puntuarJuego(g.html);
416
+ Object.entries(p.criterios).forEach(([k, v], i) => {
417
+ filas.push({ criterio: k, ok: v });
418
+ onCase(filas[filas.length - 1], i, Object.keys(p.criterios).length);
419
+ });
420
+ S.lastGame = { html: g.html, trace: g.trace };
421
+ const pasa = filas.filter(f => f.ok).length;
422
+ return {
423
+ filas,
424
+ resumen: {
425
+ 'criterios superados': `${pasa}/${filas.length}`, inferencias: g.inferencias,
426
+ 'bytes': p.bytes, 'segundos vivo': p.vivoSegs, 'puntuación': p.puntos,
427
+ },
428
+ };
429
+ }
430
+
431
+ // ─────────────────────────── orquestación ───────────────────────────
432
+ async function run() {
433
+ if (S.running) return;
434
+ S.running = true; S.abort = false;
435
+ $('#btn-run').disabled = true; $('#btn-stop').disabled = false;
436
+ setLed('#led-run', 'busy', 'ejecutando ' + S.bench);
437
+ tiles({}); prog(0, 1, 'preparando…');
438
+ log('▶ benchmark «' + S.bench + '» · algoritmo «' + S.algo + '» · cerebro «' + S.brain + '»', 'a');
439
+ const t0 = Date.now();
440
+ let out = null, error = null;
441
+ try {
442
+ if (S.bench === 'contexto') {
443
+ table(['pregunta', 'dato bueno', 'dato viejo', 'tokens', 'mensajes'], []);
444
+ out = await benchContexto((f, i, n) => {
445
+ addRow([f.pregunta.slice(0, 38),
446
+ f.sobrevive ? { t: 'SOBREVIVE', cls: 'ok' } : { t: 'PERDIDO', cls: 'no' },
447
+ f.arrastraViejo ? { t: 'lo arrastra', cls: 'no' } : { t: 'descartado', cls: 'ok' },
448
+ f.usados, `${f.quedan}/${f.deN}`]);
449
+ prog(i + 1, n, `caso ${i + 1} de ${n}`);
450
+ });
451
+ } else if (S.bench === 'swe') {
452
+ table(['tarea', 'resultado', 'pasos', 'seg'], []);
453
+ out = await benchSWE((f, i, n) => {
454
+ addRow([f.id, f.ok ? { t: 'RESUELTA', cls: 'ok' } : { t: f.err ? 'ERROR' : 'FALLA', cls: 'no' }, f.pasos, f.secs]);
455
+ prog(i + 1, n, `tarea ${i + 1} de ${n}`);
456
+ });
457
+ } else {
458
+ table(['criterio', 'resultado'], []);
459
+ out = await benchJuegos((f, i, n) => {
460
+ addRow([f.criterio, f.ok ? { t: 'SÍ', cls: 'ok' } : { t: 'NO', cls: 'no' }]);
461
+ prog(i + 1, n, `criterio ${i + 1} de ${n}`);
462
+ });
463
+ }
464
+ } catch (e) { error = e.message; log('fallo: ' + e.message, 'e'); }
465
+
466
+ if (out) {
467
+ tiles(out.resumen);
468
+ S.run = {
469
+ bench: S.bench, algo: S.algo, algoOpts: { ...S.algoOpts }, brain: S.brain,
470
+ ctx: { ...DEFAULTS, ...S.ctx }, resumen: out.resumen, casos: out.filas,
471
+ duracionSegs: Math.round((Date.now() - t0) / 1000), cuando: new Date().toISOString(),
472
+ };
473
+ log('✔ terminado: ' + JSON.stringify(out.resumen), 'a');
474
+ }
475
+ setLed('#led-run', error ? 'err' : 'on', error ? 'con fallo' : 'listo');
476
+ prog(1, 1, error ? 'abortado' : 'completado');
477
+ S.running = false; $('#btn-run').disabled = false; $('#btn-stop').disabled = true;
478
+ }
479
+
480
+ // ─────────────────── historial y ranking, en local ───────────────────
481
+ // Todo se queda en esta máquina: ni se envía ni se pide permiso a nadie. La
482
+ // gracia del ranking es comparar CONFIGURACIONES entre sí — qué combinación de
483
+ // algoritmo y perillas gana en cada banco — con su traza al lado para revisarla.
484
+ const STORE = 'elffusscode.lab.runs';
485
+ const MAX_GUARDADAS = 60;
486
+
487
+ // Puntuación comparable (0-100) según el banco, para poder ordenar.
488
+ function puntuar(r) {
489
+ const n = (s) => Number(String(s).split('/')[0]) || 0;
490
+ const d = (s) => Number(String(s).split('/')[1]) || 1;
491
+ if (r.bench === 'contexto') return Math.round(n(r.resumen['evidencia recuperada']) / d(r.resumen['evidencia recuperada']) * 100);
492
+ if (r.bench === 'swe') return Math.round(n(r.resumen.resolved) / d(r.resumen.resolved) * 100);
493
+ return Math.round(n(r.resumen['criterios superados']) / d(r.resumen['criterios superados']) * 100);
494
+ }
495
+ // Huella corta de la configuración: lo que de verdad distingue una corrida.
496
+ function huella(r) {
497
+ const on = Object.entries(r.ctx).filter(([k, v]) => v === true).map(([k]) => k);
498
+ const extra = r.algo === 'rlm-deep' ? `·${r.algoOpts.rounds}r` : '';
499
+ return `${r.algo}${extra} · ${r.brain} · ${on.length} perillas`;
500
+ }
501
+ const leerRuns = () => { try { return JSON.parse(localStorage.getItem(STORE)) || []; } catch { return []; } };
502
+ function guardarCorrida() {
503
+ if (!S.run) return log('todavía no hay ninguna corrida que guardar', 'w');
504
+ const runs = leerRuns();
505
+ runs.push({ ...S.run, score: puntuar(S.run), id: (S.run.cuando || '') + '·' + Math.round(performance.now()) });
506
+ localStorage.setItem(STORE, JSON.stringify(runs.slice(-MAX_GUARDADAS)));
507
+ log('guardada en el historial local (' + runs.length + ' corridas)', 'a');
508
+ pintarRanking();
509
+ }
510
+ function pintarRanking() {
511
+ const host = $('#rank-body'); host.innerHTML = '';
512
+ const runs = leerRuns().filter(r => r.bench === S.bench).sort((a, b) => b.score - a.score);
513
+ $('#rank-tag').textContent = runs.length ? runs.length + ' corridas · ' + S.bench : 'sin corridas de ' + S.bench;
514
+ if (!runs.length) { host.innerHTML = '<tr><td colspan="5" class="pend">Aún no has guardado ninguna corrida de este banco.</td></tr>'; return; }
515
+ runs.forEach((r, i) => {
516
+ const tr = el('tr');
517
+ const medalla = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : String(i + 1);
518
+ [[medalla], [{ t: r.score + '%', cls: r.score >= 80 ? 'ok' : r.score >= 40 ? '' : 'no' }],
519
+ [huella(r)], [new Date(r.cuando).toLocaleString()]].forEach(([c]) => {
520
+ const td = el('td');
521
+ if (c && typeof c === 'object') { td.className = c.cls || ''; td.textContent = c.t; } else td.textContent = c;
522
+ tr.appendChild(td);
523
+ });
524
+ const acc = el('td');
525
+ const ver = el('button', 'btn ghost', 'traza');
526
+ ver.style.padding = '2px 8px'; ver.style.fontSize = '11px';
527
+ ver.onclick = () => descargarTraza(r);
528
+ acc.appendChild(ver); tr.appendChild(acc);
529
+ host.appendChild(tr);
530
+ });
531
+ }
532
+ // Exportar es un fichero en tu disco, no un envío: sirve para revisar la traza
533
+ // caso a caso o comparar dos corridas fuera del panel.
534
+ function descargarTraza(r) {
535
+ const a = el('a');
536
+ a.href = URL.createObjectURL(new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' }));
537
+ a.download = `elffuss-lab-${r.bench}-${r.score}.json`;
538
+ a.click();
539
+ log('traza exportada a tu disco', 'a');
540
+ }
541
+ function borrarHistorial() {
542
+ if (!confirm('¿Borrar TODAS las corridas guardadas?')) return;
543
+ localStorage.removeItem(STORE); pintarRanking(); log('historial local borrado', 'w');
544
+ }
545
+
546
+ // ─────────────────────────── arranque ───────────────────────────
547
+ radio($('#brains'), BRAINS, S.brain, id => { S.brain = id; $('#brain-tag').textContent = id; });
548
+ radio($('#algos'), ALGOS, S.algo, id => { S.algo = id; $('#algo-tag').textContent = id; algoKnobs(); });
549
+ radio($('#benches'), BENCHES, S.bench, id => { S.bench = id; $('#bench-tag').textContent = id; });
550
+ $('#brain-tag').textContent = S.brain;
551
+ $('#bench-tag').textContent = S.bench;
552
+ algoKnobs(); ctxUI();
553
+
554
+ $('#btn-load').onclick = loadBrain;
555
+ $('#btn-run').onclick = run;
556
+ $('#btn-stop').onclick = () => { S.abort = true; log('parando tras el caso en curso…', 'w'); };
557
+ $('#ctx-all').onclick = () => { CTX_KNOBS.flatMap(([, k]) => k).forEach(([k, t]) => { if (t === 'bool') S.ctx[k] = true; }); ctxUI(); };
558
+ $('#ctx-none').onclick = () => { CTX_KNOBS.flatMap(([, k]) => k).forEach(([k, t]) => { if (t === 'bool') S.ctx[k] = false; }); ctxUI(); };
559
+ $('#ctx-def').onclick = () => { S.ctx = {}; ctxUI(); };
560
+ $('#btn-launch').onclick = () => {
561
+ localStorage.setItem('elffusscode.model', S.brain);
562
+ localStorage.setItem('elffusscode.ctxopts', JSON.stringify(S.ctx));
563
+ localStorage.setItem('elffusscode.algo', JSON.stringify({ algo: S.algo, opts: S.algoOpts }));
564
+ log('configuración guardada; abriendo el IDE', 'a');
565
+ window.open('index.html', '_blank');
566
+ };
567
+
568
+ $('#btn-save').onclick = guardarCorrida;
569
+ $('#btn-clear').onclick = borrarHistorial;
570
+ pintarRanking();
571
+
572
+ log('Elffuss Lab listo. Elige algoritmo, enciende o apaga la compresión y lanza un banco.', 'a');
573
+ log('El banco de contexto no necesita GPU: mide el compresor y responde al instante.');
574
+ log('Los resultados se quedan en esta máquina: historial y ranking local, sin enviar nada.');
js/main.js CHANGED
@@ -20,6 +20,24 @@ import { t, applyI18n } from './i18n.js';
20
  import * as bridge from './bridge.js';
21
  import * as conv from './conversations.js';
22
  import { TASK_PREFIX } from './goal.js';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  import * as telemetry from './telemetry.js';
24
 
25
  const $ = id => document.getElementById(id);
@@ -100,7 +118,7 @@ const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<':
100
  // llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
101
  // lista evolucionar en vivo en vez de reconstruirse entera cada vez.
102
  const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
103
- const PLAN_STATUS_LABEL = { planning: 'planificando…', running: 'ejecutando…', done: 'completado', failed: 'con fallos' };
104
  function renderPlanCard(plan) {
105
  const domId = 'plan-' + plan.id;
106
  let div = document.getElementById(domId);
@@ -338,7 +356,7 @@ function modelOptions() {
338
  if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
339
  if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
340
  opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
341
- opts.push({ id: 'rules', label: 'Básico (sin modelo)' });
342
  return [...opts, ...settings.enabledExternals()];
343
  }
344
 
@@ -382,7 +400,7 @@ async function changeModel(id) {
382
  if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
383
  loadingId = id;
384
  $('model-dot').className = 'dot loading';
385
- showModelProgress('Cargando el modelo IA…', 4);
386
  try {
387
  const mod = await resolveProvider(id);
388
  await mod.load(p => {
@@ -451,7 +469,7 @@ function settingsShell(title) {
451
  const box = $('settings-panel');
452
  box.hidden = false;
453
  box.replaceChildren();
454
- const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = 'cerrar';
455
  close.onclick = () => { box.hidden = true; };
456
  box.append(close, el('h3', 'panel-title', title));
457
  return box;
@@ -502,15 +520,15 @@ function fireworks() {
502
  }
503
 
504
  function renderSettings() {
505
- const box = settingsShell('Ajustes');
506
 
507
  // --- Cerebro (modelo) ---
508
- box.append(el('div', 'sk-h', 'Cerebro (modelo)'));
509
  const LOCAL = [
510
- { id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub: 'El mejor · WebGPU local · ~4 GB', need: 'gpu' },
511
- { id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub: 'Ligero · WebGPU local · ~2 GB', need: 'gpu' },
512
- { id: 'onnx', name: 'Elffuss LM (healed)', sub: 'Modelo propio · 850 MB · tool-calls + apps' },
513
- { id: 'rules', name: 'Básico (sin modelo)', sub: 'Órdenes directas, cero descarga' },
514
  ];
515
  const grid = el('div', 'model-grid');
516
  for (const m of LOCAL) {
@@ -523,6 +541,53 @@ function renderSettings() {
523
  }
524
  box.appendChild(grid);
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  // --- Almacenamiento del modelo (caché persistente) ---
527
  box.append(el('div', 'sk-h', t('setStoreTitle')));
528
  const storeCard = el('div', 'prov-card');
@@ -545,7 +610,7 @@ function renderSettings() {
545
  paintStorage();
546
 
547
  // --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
548
- box.append(el('div', 'sk-h', '🔌 Bridge local (ejecución real en tu máquina)'));
549
  const brCard = el('div', 'prov-card bridge-card');
550
  const guessOS = () => {
551
  const ua = navigator.userAgent;
@@ -558,14 +623,14 @@ function renderSettings() {
558
  const primary = guessOS();
559
  brCard.innerHTML =
560
  `<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
561
- `<p class="muted" style="font-size:.72rem;margin:6px 0">Un pequeño programa que TÚ ejecutas en tu ordenador — le da a la elfa ejecución real (node, npm, python…) sin salir de tu máquina. Nada se instala en el navegador.</p>` +
562
- `<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
563
- `<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
564
  Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
565
  `</details>` +
566
  `<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
567
- `<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">Carpeta de trabajo (opcional — si no, usa una temporal)</label><input id="br-folder" placeholder="/ruta/completa/a/tu/proyecto"></div>` +
568
- `<button id="br-connect" class="prov-use" style="margin-top:8px">Conectar</button>`;
569
  box.appendChild(brCard);
570
  brCard.querySelector('#br-folder').value = bridge.getFolder();
571
  brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
@@ -575,7 +640,7 @@ function renderSettings() {
575
  if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
576
  const on = bridge.isConnected();
577
  brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
578
- brCard.querySelector('#br-status').textContent = on ? '✓ conectado — ejecución real activa' : 'desconectado';
579
  if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
580
  };
581
  paintBridge();
@@ -584,15 +649,15 @@ function renderSettings() {
584
  const btn = brCard.querySelector('#br-connect');
585
  bridge.setFolder(brCard.querySelector('#br-folder').value);
586
  const token = brCard.querySelector('#br-token').value.trim();
587
- if (!token) return showBridgeMsg(brCard, '⚠️ pega el token que imprimió el programa al arrancarlo', true);
588
- btn.disabled = true; btn.textContent = 'Conectando…';
589
- try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, '✔ conectado — ejecución real en tu máquina'); fireworks(); }
590
  catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
591
- finally { btn.disabled = false; btn.textContent = 'Conectar'; }
592
  };
593
 
594
  // --- Permisos de ejecución (mismo interruptor que </> Auto de la barra) ---
595
- box.append(el('div', 'sk-h', '✅ Permisos de ejecución'));
596
  const permCard = el('div', 'prov-card');
597
  permCard.innerHTML =
598
  `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
@@ -610,7 +675,7 @@ function renderSettings() {
610
  };
611
 
612
  // --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
613
- box.append(el('div', 'sk-h', '🎯 Modo Objetivo'));
614
  const goalCard = el('div', 'prov-card');
615
  goalCard.innerHTML =
616
  `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
@@ -628,7 +693,7 @@ function renderSettings() {
628
  };
629
 
630
  // --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
631
- box.append(el('div', 'sk-h', '📨 Errores y feedback'));
632
  const telCard = el('div', 'prov-card');
633
  telCard.innerHTML =
634
  `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
@@ -648,24 +713,24 @@ function renderSettings() {
648
  const ta = telCard.querySelector('#tel-feedback');
649
  const msg = telCard.querySelector('#tel-msg');
650
  const text = ta.value.trim();
651
- if (!text) { msg.textContent = 'escribe algo primero'; return; }
652
  const wasEnabled = telemetry.isEnabled();
653
  if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
654
  await telemetry.sendFeedback(text);
655
  if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
656
  ta.value = '';
657
- msg.textContent = '¡enviado, gracias!';
658
  setTimeout(() => { msg.textContent = ''; }, 4000);
659
  };
660
 
661
  // --- Proveedores externos (API keys) ---
662
- box.append(el('div', 'sk-h', 'Proveedores externos (opcional · la clave se queda en tu navegador)'));
663
  for (const [id, c] of Object.entries(settings.configs())) {
664
  const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
665
  const head = el('div', 'prov-head');
666
  const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
667
  const title = el('b', null, c.label);
668
- const use = el('button', 'prov-use', 'Usar');
669
  use.hidden = !c.enabled || activeModel === 'ext:' + id;
670
  use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
671
  head.append(toggle, title, use);
@@ -687,7 +752,7 @@ function renderSettings() {
687
  // --- Skills ---
688
  box.append(el('div', 'sk-h', 'Skills'));
689
  const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
690
- skBtn.textContent = 'Gestionar skills de Claude Code';
691
  skBtn.onclick = openSkillsPanel;
692
  box.appendChild(skBtn);
693
  }
@@ -1069,7 +1134,7 @@ async function toggleTerminal(force) {
1069
  $('act-term').classList.toggle('on', show);
1070
  if (show) {
1071
  const cap = shell.capabilities();
1072
- $('term-caps').textContent = cap.bridge ? '🔌 Bridge local: node/npm/python reales' : 'shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)';
1073
  await terminal.mount($('terminal-host'));
1074
  terminal.refit();
1075
  }
@@ -1240,9 +1305,50 @@ $('btn-goal').addEventListener('click', () => {
1240
  });
1241
  paintGoal();
1242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1243
  // resolver @rutas del mensaje: se leen y se adjuntan como contexto
1244
  const _send = send;
1245
  send = async (text) => {
 
1246
  const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
1247
  if (refs.length) {
1248
  let ctx = '';
@@ -1279,7 +1385,7 @@ async function openSkillsPanel() {
1279
  row.className = 'sk-row';
1280
  row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
1281
  const rm = document.createElement('button');
1282
- rm.className = 'ghost'; rm.textContent = 'Quitar';
1283
  rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
1284
  row.appendChild(rm);
1285
  instBox.appendChild(row);
@@ -1296,7 +1402,7 @@ async function openSkillsPanel() {
1296
  row.className = 'sk-row';
1297
  row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
1298
  const browse = document.createElement('button');
1299
- browse.className = 'primary'; browse.textContent = 'Explorar';
1300
  browse.onclick = () => browseRepo(s.repo, box);
1301
  row.appendChild(browse);
1302
  if (!s.official) {
@@ -1310,9 +1416,9 @@ async function openSkillsPanel() {
1310
  const addRow = document.createElement('div');
1311
  addRow.className = 'sk-row';
1312
  const inp = document.createElement('input');
1313
- inp.placeholder = 'owner/repo o URL de GitHub (p. ej. OpenClaude/…)';
1314
  const add = document.createElement('button');
1315
- add.className = 'primary'; add.textContent = 'Añadir repo';
1316
  add.onclick = async () => {
1317
  try { await skills.addSource(inp.value); openSkillsPanel(); }
1318
  catch (e) { alert(e.message); }
 
20
  import * as bridge from './bridge.js';
21
  import * as conv from './conversations.js';
22
  import { TASK_PREFIX } from './goal.js';
23
+ import { hardWork, gatherFolder } from './rlm.js';
24
+ import * as workspace from './workspace.js';
25
+
26
+ // Espacio de trabajo (core): qué de Code puede guardarse en tu disco.
27
+ workspace.init({
28
+ app: 'code', ns: 'elffusscode', db,
29
+ stores: [
30
+ { id: 'conversations', label: 'Conversaciones', icon: '💬', kind: 'idb-key', store: 'kv', key: 'conversations',
31
+ files: { ext: '.json', name: c => c.title || c.id } },
32
+ { id: 'skills', label: 'Skills', icon: '🧩', kind: 'idb-key', store: 'kv', key: 'skills',
33
+ files: { ext: '.md', name: s => s.name, body: s => `---\nname: ${s.name}\ndescription: ${s.description || ''}\n---\n\n${s.content || ''}` } },
34
+ { id: 'prefs', label: 'Preferencias', icon: '⚙️', kind: 'ls',
35
+ keys: ['elffusscode.model', 'elffusscode.autoedit', 'elffusscode.goalmode', 'elffuss.acer', 'elffuss.resumen', 'elffuss.semantic'] },
36
+ ],
37
+ });
38
+ workspace.restore().catch(() => {});
39
+ if (workspace.autosaveEnabled()) workspace.autosave({ enabled: true });
40
+
41
  import * as telemetry from './telemetry.js';
42
 
43
  const $ = id => document.getElementById(id);
 
118
  // llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
119
  // lista evolucionar en vivo en vez de reconstruirse entera cada vez.
120
  const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
121
+ const PLAN_STATUS_LABEL = { planning: t("planPlanning"), running: t("planRunning"), done: t("planDone"), failed: t("planFailed") };
122
  function renderPlanCard(plan) {
123
  const domId = 'plan-' + plan.id;
124
  let div = document.getElementById(domId);
 
356
  if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
357
  if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
358
  opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
359
+ opts.push({ id: 'rules', label: t('setModelRulesName') });
360
  return [...opts, ...settings.enabledExternals()];
361
  }
362
 
 
400
  if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
401
  loadingId = id;
402
  $('model-dot').className = 'dot loading';
403
+ showModelProgress(t("stModelLoadingAI"), 4);
404
  try {
405
  const mod = await resolveProvider(id);
406
  await mod.load(p => {
 
469
  const box = $('settings-panel');
470
  box.hidden = false;
471
  box.replaceChildren();
472
+ const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = t("close");
473
  close.onclick = () => { box.hidden = true; };
474
  box.append(close, el('h3', 'panel-title', title));
475
  return box;
 
520
  }
521
 
522
  function renderSettings() {
523
+ const box = settingsShell(t("setTitle"));
524
 
525
  // --- Cerebro (modelo) ---
526
+ box.append(el('div', 'sk-h', t("setBrainTitle")));
527
  const LOCAL = [
528
+ { id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub: t("setModelE4bSub"), need: 'gpu' },
529
+ { id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub: t("setModelE2bSub"), need: 'gpu' },
530
+ { id: 'onnx', name: 'Elffuss LM (healed)', sub: t("setModelOnnxSub") },
531
+ { id: 'rules', name: t('setModelRulesName'), sub: t("setModelRulesSub") },
532
  ];
533
  const grid = el('div', 'model-grid');
534
  for (const m of LOCAL) {
 
541
  }
542
  box.appendChild(grid);
543
 
544
+ // --- Espacio de trabajo: guardar conversaciones/skills en disco ---
545
+ box.append(el('div', 'sk-h', '💾 ' + t('wsTitle')));
546
+ const wsCard = el('div', 'prov-card');
547
+ const wsState = el('div', 'muted'); wsState.style.fontSize = '.78rem';
548
+ const wsRow = el('div', 'field'); wsRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;margin-top:8px';
549
+ const wsInv = el('div'); wsInv.style.cssText = 'margin-top:10px;font-size:.78rem';
550
+ wsCard.append(el('span', 'muted', t('wsDesc')), wsState, wsRow, wsInv);
551
+ box.appendChild(wsCard);
552
+ const wsKb = n => n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n > 1024 ? Math.round(n / 1024) + ' KB' : n + ' B';
553
+ async function paintWs() {
554
+ const st = workspace.status();
555
+ wsRow.replaceChildren();
556
+ wsState.textContent = !st.supported.pick ? t('wsNoSupport')
557
+ : !st.folder ? t('wsNoFolder')
558
+ : st.ready ? t('wsFolder', { name: st.folder }) : t('wsNeedsPerm', { name: st.folder });
559
+ const mk = (label, fn) => { const b = el('button', 'prov-use', label); b.onclick = fn; wsRow.appendChild(b); };
560
+ if (st.supported.pick) {
561
+ if (!st.folder) {
562
+ mk(t('wsPick'), async () => { try { await workspace.pick(); await paintWs(); } catch (e) { wsState.textContent = '⚠️ ' + e.message; } });
563
+ // Code ya tiene una carpeta abierta: reutilizarla es un clic
564
+ if (codeTools.handle()) mk(t('wsUseProject'), async () => { await workspace.adopt(codeTools.handle()); await paintWs(); });
565
+ } else if (!st.ready) mk(t('wsRegrant'), async () => { try { await workspace.regrant(); await paintWs(); } catch (e) { wsState.textContent = '⚠️ ' + e.message; } });
566
+ else {
567
+ mk(t('wsSaveNow'), async () => { try { const r = await workspace.save(); wsState.textContent = r.ok ? t('wsSaved', { n: r.wrote.length }) : '⚠️ ' + (r.errors[0]?.error || ''); } catch (e) { wsState.textContent = '⚠️ ' + e.message; await paintWs(); } });
568
+ const lbl = el('label', null); lbl.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:.78rem';
569
+ const chk = el('input'); chk.type = 'checkbox'; chk.checked = workspace.autosaveEnabled();
570
+ chk.onchange = () => workspace.autosave({ enabled: chk.checked });
571
+ lbl.append(chk, el('span', 'muted', t('wsAuto'))); wsRow.appendChild(lbl);
572
+ mk(t('wsForget'), async () => { await workspace.forget(); await paintWs(); });
573
+ }
574
+ }
575
+ mk(t('wsDownload'), async () => {
576
+ const blob = await workspace.bundle();
577
+ const a = document.createElement('a');
578
+ a.href = URL.createObjectURL(blob);
579
+ a.download = `elffuss-code-${new Date().toISOString().slice(0, 10)}.json`;
580
+ a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000);
581
+ });
582
+ wsInv.replaceChildren(el('b', null, t('wsWhat')));
583
+ for (const it of await workspace.inventory()) {
584
+ const line = el('div'); line.style.cssText = 'display:flex;justify-content:space-between;padding:3px 0';
585
+ line.append(el('span', null, `${it.icon} ${it.label}`), el('span', 'muted', it.error ? '⚠️' : `${it.count} · ${wsKb(it.bytes)}`));
586
+ wsInv.appendChild(line);
587
+ }
588
+ }
589
+ paintWs().catch(() => {});
590
+
591
  // --- Almacenamiento del modelo (caché persistente) ---
592
  box.append(el('div', 'sk-h', t('setStoreTitle')));
593
  const storeCard = el('div', 'prov-card');
 
610
  paintStorage();
611
 
612
  // --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
613
+ box.append(el('div', 'sk-h', t("setBridgeTitle")));
614
  const brCard = el('div', 'prov-card bridge-card');
615
  const guessOS = () => {
616
  const ua = navigator.userAgent;
 
623
  const primary = guessOS();
624
  brCard.innerHTML =
625
  `<div class="prov-head"><span id="br-dot" class="dot off"></span><b>Bridge local</b><span id="br-status" class="muted" style="margin-left:auto;font-size:.72rem">desconectado</span></div>` +
626
+ `<p class="muted" style="font-size:.72rem;margin:6px 0">${t('setBridgeDesc')}</p>` +
627
+ `<a class="prov-use" style="text-decoration:none;display:inline-block" href="bridge-dl/${primary}" download>${t('setBridgeDownload',{os:OTHER[primary]})}</a>` +
628
+ `<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">${t('setBridgeOtherOS')}</summary>` +
629
  Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
630
  `</details>` +
631
  `<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">${t('setBrToken')}</label><input id="br-token" placeholder="${t('brTokenPh')}"></div>` +
632
+ `<div class="field" style="margin-top:6px"><label class="muted" style="font-size:.68rem">${t('setBridgeFolderLabel')}</label><input id="br-folder" placeholder="${t('setBridgeFolderPh')}"></div>` +
633
+ `<button id="br-connect" class="prov-use" style="margin-top:8px">${t('setBridgeConnect')}</button>`;
634
  box.appendChild(brCard);
635
  brCard.querySelector('#br-folder').value = bridge.getFolder();
636
  brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
 
640
  if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
641
  const on = bridge.isConnected();
642
  brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
643
+ brCard.querySelector('#br-status').textContent = on ? t("setBridgeOnStatus") : t("setBridgeDisconnected");
644
  if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
645
  };
646
  paintBridge();
 
649
  const btn = brCard.querySelector('#br-connect');
650
  bridge.setFolder(brCard.querySelector('#br-folder').value);
651
  const token = brCard.querySelector('#br-token').value.trim();
652
+ if (!token) return showBridgeMsg(brCard, t("setBridgeNoToken"), true);
653
+ btn.disabled = true; btn.textContent = t("setBridgeConnecting");
654
+ try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, t("setBridgeConnectedMsg")); fireworks(); }
655
  catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
656
+ finally { btn.disabled = false; btn.textContent = t("setBridgeConnect"); }
657
  };
658
 
659
  // --- Permisos de ejecución (mismo interruptor que </> Auto de la barra) ---
660
+ box.append(el('div', 'sk-h', t("setPermTitle")));
661
  const permCard = el('div', 'prov-card');
662
  permCard.innerHTML =
663
  `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
 
675
  };
676
 
677
  // --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
678
+ box.append(el('div', 'sk-h', t("setGoalTitle")));
679
  const goalCard = el('div', 'prov-card');
680
  goalCard.innerHTML =
681
  `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
 
693
  };
694
 
695
  // --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
696
+ box.append(el('div', 'sk-h', t("setTelTitle")));
697
  const telCard = el('div', 'prov-card');
698
  telCard.innerHTML =
699
  `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
 
713
  const ta = telCard.querySelector('#tel-feedback');
714
  const msg = telCard.querySelector('#tel-msg');
715
  const text = ta.value.trim();
716
+ if (!text) { msg.textContent = t("setTelEmpty"); return; }
717
  const wasEnabled = telemetry.isEnabled();
718
  if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
719
  await telemetry.sendFeedback(text);
720
  if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
721
  ta.value = '';
722
+ msg.textContent = t("setTelThanks");
723
  setTimeout(() => { msg.textContent = ''; }, 4000);
724
  };
725
 
726
  // --- Proveedores externos (API keys) ---
727
+ box.append(el('div', 'sk-h', t("setProvTitle")));
728
  for (const [id, c] of Object.entries(settings.configs())) {
729
  const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
730
  const head = el('div', 'prov-head');
731
  const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
732
  const title = el('b', null, c.label);
733
+ const use = el('button', 'prov-use', t("setProvUse"));
734
  use.hidden = !c.enabled || activeModel === 'ext:' + id;
735
  use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
736
  head.append(toggle, title, use);
 
752
  // --- Skills ---
753
  box.append(el('div', 'sk-h', 'Skills'));
754
  const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
755
+ skBtn.textContent = t("setManageSkillsBtn");
756
  skBtn.onclick = openSkillsPanel;
757
  box.appendChild(skBtn);
758
  }
 
1134
  $('act-term').classList.toggle('on', show);
1135
  if (show) {
1136
  const cap = shell.capabilities();
1137
+ $('term-caps').textContent = cap.bridge ? t("termCapsBridge") : t("termCapsNoBridge");
1138
  await terminal.mount($('terminal-host'));
1139
  terminal.refit();
1140
  }
 
1305
  });
1306
  paintGoal();
1307
 
1308
+ // ── Hard Work (RLM): lee el proyecto ENTERO por partes ──
1309
+ let hardMode = false;
1310
+ $('btn-hardwork').addEventListener('click', () => {
1311
+ hardMode = !hardMode;
1312
+ $('btn-hardwork').classList.toggle('on', hardMode);
1313
+ $('prompt').placeholder = hardMode ? t("hwPlaceholder") : 'Pídele código a Elffuss…';
1314
+ });
1315
+
1316
+ // Trabaja el proyecto abierto por partes: trocea, pregunta a cada parte y funde.
1317
+ async function runHardWork(question) {
1318
+ const active = conv.getActive();
1319
+ if (!active) return;
1320
+ addMsg('user', question);
1321
+ const thinking = thinkingBubble();
1322
+ const fail = msg => { thinking.remove(); addMsg('assistant', msg); };
1323
+ try {
1324
+ const provider = conv.getProvider();
1325
+ if (!provider || provider === rules) return fail(t("hwNeedBrain"));
1326
+ const root = codeTools.handle();
1327
+ if (!root) return fail(t("hwNeedProject"));
1328
+ thinking.tool(t("hwReading"));
1329
+ const context = await gatherFolder(root, { maxBytes: 1.5e6 });
1330
+ if (!context.trim()) return fail(t("hwNoFiles"));
1331
+ const res = await hardWork({
1332
+ question, context, provider,
1333
+ onProgress: p => {
1334
+ if (p.phase === 'plan') thinking.tool(`Hard Work · ${p.chunks} partes (${Math.round(p.chars / 1000)}k)${p.truncated ? t("hwTrimmed") : ''}`);
1335
+ else if (p.phase === 'map') thinking.tool(`Hard Work · leyendo ${p.i}/${p.n}`);
1336
+ else if (p.phase === 'reduce-group') thinking.tool(`Hard Work · fundiendo ${p.g}/${p.n}`);
1337
+ else if (p.phase === 'reduce') thinking.tool(t("hwPhaseReduce"));
1338
+ },
1339
+ });
1340
+ thinking.remove();
1341
+ addMsg('assistant', res.answer + `\n\n_⛏ Hard Work (RLM): ${res.chunks} partes, ${res.kept} con señal.${res.truncated ? t("hwResultTrunc") : ''}_`);
1342
+ } catch (e) {
1343
+ thinking.remove();
1344
+ addMsg('assistant err', 'Hard Work falló: ' + (e?.message || String(e)));
1345
+ }
1346
+ }
1347
+
1348
  // resolver @rutas del mensaje: se leen y se adjuntan como contexto
1349
  const _send = send;
1350
  send = async (text) => {
1351
+ if (hardMode) return runHardWork(text);
1352
  const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
1353
  if (refs.length) {
1354
  let ctx = '';
 
1385
  row.className = 'sk-row';
1386
  row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
1387
  const rm = document.createElement('button');
1388
+ rm.className = 'ghost'; rm.textContent = t("skRemove");
1389
  rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
1390
  row.appendChild(rm);
1391
  instBox.appendChild(row);
 
1402
  row.className = 'sk-row';
1403
  row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
1404
  const browse = document.createElement('button');
1405
+ browse.className = 'primary'; browse.textContent = t("skBrowse");
1406
  browse.onclick = () => browseRepo(s.repo, box);
1407
  row.appendChild(browse);
1408
  if (!s.official) {
 
1416
  const addRow = document.createElement('div');
1417
  addRow.className = 'sk-row';
1418
  const inp = document.createElement('input');
1419
+ inp.placeholder = t("skAddRepoPh");
1420
  const add = document.createElement('button');
1421
+ add.className = 'primary'; add.textContent = t("skAddRepo");
1422
  add.onclick = async () => {
1423
  try { await skills.addSource(inp.value); openSkillsPanel(); }
1424
  catch (e) { alert(e.message); }
js/providers/litert.js CHANGED
@@ -39,7 +39,12 @@ export async function load(onProgress = () => {}) {
39
  let adapter = null;
40
  try { adapter = await navigator.gpu.requestAdapter(); } catch { /* sin adaptador */ }
41
  if (!adapter) throw new Error('No hay un adaptador WebGPU real disponible (la API existe pero no hay GPU accesible) — prueba con Elffuss LM, que corre en CPU/wasm.');
42
- const litertlm = await import('https://cdn.jsdelivr.net/npm/@litert-lm/core/+esm');
 
 
 
 
 
43
  // El .litertlm lo descargamos NOSOTROS (cache-first en Cache Storage) y se lo
44
  // pasamos a Engine.create como Blob (la API acepta string|Blob|ReadableStream).
45
  // Motivo: el fetch interno de LiteRT baja el peso con XHR+Range desde un WORKER
@@ -78,17 +83,41 @@ export async function cachedModelBlob(url, onProgress = () => {}) {
78
  if (!net.ok || !net.body) return url;
79
  const total = +net.headers.get('content-length') || 0;
80
  const t0 = performance.now();
81
- const [prog, toCache] = net.body.tee();
82
- (async () => {
83
- const r = prog.getReader(); let loaded = 0;
84
- for (;;) { const { done, value } = await r.read(); if (done) break; loaded += value.length; onProgress(fmtBytes(loaded, total, t0)); }
85
- })().catch(() => {});
 
 
 
 
 
 
 
 
 
86
  const headers = { 'Content-Type': 'application/octet-stream' };
87
  if (total) headers['Content-Length'] = String(total);
88
- await cache.put(url, new Response(toCache, { headers }));
 
 
 
 
 
 
 
 
 
 
 
89
  const cached = await cache.match(url);
90
- return cached ? await cached.blob() : url;
91
- } catch { return url; }
 
 
 
 
92
  }
93
  function fmtBytes(loaded, total, t0) {
94
  const mb = n => (n / 1048576).toFixed(0);
 
39
  let adapter = null;
40
  try { adapter = await navigator.gpu.requestAdapter(); } catch { /* sin adaptador */ }
41
  if (!adapter) throw new Error('No hay un adaptador WebGPU real disponible (la API existe pero no hay GPU accesible) — prueba con Elffuss LM, que corre en CPU/wasm.');
42
+ // VERSIÓN FIJADA a propósito. Sin fijarla, la URL apunta siempre a la última
43
+ // publicada: el 2026-08-11 salió 0.16.0, jsdelivr NO consigue construirle el
44
+ // bundle `+esm` (404) y el cerebro Gemma dejó de cargar en producción sin que
45
+ // nosotros tocáramos una línea. Al subir de versión hay que COMPROBAR que
46
+ // `https://cdn.jsdelivr.net/npm/@litert-lm/core@<v>/+esm` responde 200.
47
+ const litertlm = await import('https://cdn.jsdelivr.net/npm/@litert-lm/core@0.15.0/+esm');
48
  // El .litertlm lo descargamos NOSOTROS (cache-first en Cache Storage) y se lo
49
  // pasamos a Engine.create como Blob (la API acepta string|Blob|ReadableStream).
50
  // Motivo: el fetch interno de LiteRT baja el peso con XHR+Range desde un WORKER
 
83
  if (!net.ok || !net.body) return url;
84
  const total = +net.headers.get('content-length') || 0;
85
  const t0 = performance.now();
86
+ // Progreso SIN tee(): con un modelo de gigabytes, tee() crea dos ramas que
87
+ // se consumen a ritmos distintos y el navegador tiene que bufferizar la
88
+ // diferencia en memoria → el cache.put acababa reventando y el modelo NO se
89
+ // cacheaba NUNCA (medido con E4B: 2832 MB bajados y cero guardados; el
90
+ // usuario se los re-bajaba en cada sesión). Con un TransformStream hay un
91
+ // solo consumidor: contamos al vuelo y el mismo flujo va a la caché.
92
+ let loaded = 0;
93
+ const counted = net.body.pipeThrough(new TransformStream({
94
+ transform(chunk, ctrl) {
95
+ loaded += chunk.byteLength ?? chunk.length;
96
+ onProgress(fmtBytes(loaded, total, t0));
97
+ ctrl.enqueue(chunk);
98
+ },
99
+ }));
100
  const headers = { 'Content-Type': 'application/octet-stream' };
101
  if (total) headers['Content-Length'] = String(total);
102
+ // Cachear GIGABYTES puede fallar de verdad: ventana privada (Cache Storage
103
+ // en memoria), disco lleno, cuota del origen. Si falla hay que DECIRLO: el
104
+ // progreso ya ha prometido «se cachea para la próxima vez» y, callándolo,
105
+ // el usuario se re-baja el modelo entero cada sesión sin saber por qué.
106
+ try {
107
+ await cache.put(url, new Response(counted, { headers }));
108
+ } catch (e) {
109
+ onProgress(`No se pudo guardar el modelo en caché (${e.name || 'error'}): habrá que descargarlo otra vez la próxima. ` +
110
+ `Suele ser ventana privada o falta de espacio.`);
111
+ console.warn('[elffuss] modelo NO cacheado:', e);
112
+ return url;
113
+ }
114
  const cached = await cache.match(url);
115
+ if (!cached) { onProgress('No se pudo guardar el modelo en caché: habrá que descargarlo otra vez la próxima.'); return url; }
116
+ return await cached.blob();
117
+ } catch (e) {
118
+ console.warn('[elffuss] caché de modelo no disponible:', e);
119
+ return url;
120
+ }
121
  }
122
  function fmtBytes(loaded, total, t0) {
123
  const mb = n => (n / 1048576).toFixed(0);
js/rlm.js ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // RLM — Recursive Language Models (el modo «Hard Work» de Elffuss).
2
+ //
3
+ // Idea (Zhang et al., MIT, 2025): un modelo PEQUEÑO y local puede manejar
4
+ // entradas ENORMES si no las mete todas de golpe en su ventana, sino que
5
+ // TROCEA el material, pregunta a cada trozo (map = una sub-llamada al mismo
6
+ // modelo) y FUNDE las respuestas (reduce). Si lo fundido aún no cabe, se
7
+ // recurre. El modelo raíz nunca ve todo el contexto a la vez: trabaja por
8
+ // partes, como un humano leyendo un tocho capítulo a capítulo.
9
+ //
10
+ // Trade-off honesto: cambia LATENCIA por CAPACIDAD. Son muchas inferencias del
11
+ // modelo local (que es lento), así que es para tareas que PUEDEN esperar —
12
+ // auditar, resumir o revisar una carpeta/repo entero— no para tiempo real.
13
+ //
14
+ // El motor es agnóstico del proveedor: solo necesita algo con
15
+ // `chat(history, system, onToken)` (lo cumplen rules/onnx/litert/api).
16
+
17
+ const CHARS_PER_TOK = 4; // misma estimación que context.js
18
+ const DEFAULTS = {
19
+ chunkTokens: 1800, // tamaño de cada trozo (deja hueco al prompt del modelo)
20
+ overlapTokens: 120, // solape para no cortar una idea a la mitad entre trozos
21
+ maxDepth: 3, // recursión máxima del reduce jerárquico
22
+ maxChunks: 80, // techo DURO de sub-llamadas del map (coste/tiempo)
23
+ callTimeoutMs: 120000, // guarda por sub-llamada (un motor colgado no bloquea todo)
24
+ };
25
+
26
+ const clip = (s, n) => (s.length > n ? s.slice(0, n) + '…' : s);
27
+
28
+ // Trocea respetando límites de línea cuando es posible (no parte a mitad de
29
+ // palabra si hay un salto cerca del corte).
30
+ function chunk(text, chunkChars, overlapChars) {
31
+ const step = Math.max(1, chunkChars - overlapChars);
32
+ const out = [];
33
+ for (let i = 0; i < text.length; i += step) {
34
+ let end = Math.min(i + chunkChars, text.length);
35
+ if (end < text.length) {
36
+ const nl = text.lastIndexOf('\n', end);
37
+ if (nl > i + chunkChars * 0.6) end = nl; // corta en salto si cae en el último 40%
38
+ }
39
+ out.push(text.slice(i, end));
40
+ if (end >= text.length) break;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ // sub(): UNA inferencia del modelo local sobre un fragmento. Es la recursión.
46
+ async function sub(provider, system, prompt, timeoutMs) {
47
+ const run = provider.chat([{ role: 'user', content: prompt }], system, () => {});
48
+ if (!timeoutMs) return run;
49
+ let to;
50
+ const guard = new Promise((_, rej) => { to = setTimeout(() => rej(new Error('sub-llamada agotó el tiempo')), timeoutMs); });
51
+ try { return await Promise.race([run, guard]); }
52
+ finally { clearTimeout(to); }
53
+ }
54
+
55
+ const MAP_SYS = 'Eres un analista minucioso. Te doy UN fragmento de un material más grande y una pregunta. Responde SOLO con lo que ESTE fragmento aporte a la pregunta: datos concretos, en pocas frases, con cita textual breve si procede. No inventes lo que no esté en el fragmento. Si el fragmento no aporta nada a la pregunta, responde exactamente: NADA';
56
+ const REDUCE_SYS = 'Eres un sintetizador. Te doy una pregunta y varios hallazgos extraídos de fragmentos distintos del MISMO material. Fúndelos en una única respuesta, coherente y completa, a la pregunta. Resuelve solapes, no repitas, no inventes lo que no esté en los hallazgos. Responde en el idioma de la pregunta.';
57
+
58
+ // Motor RLM. Devuelve { answer, chunks, kept }.
59
+ export async function hardWork({ question, context, provider, onProgress = () => {}, opts = {} }) {
60
+ const o = { ...DEFAULTS, ...opts };
61
+ if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo local o remoto arriba).');
62
+ const q = String(question || '').trim();
63
+ const ctx = String(context || '').trim();
64
+ if (!q) throw new Error('Hard Work: dime qué quieres saber del material.');
65
+ if (!ctx) throw new Error('Hard Work: no encontré material que procesar.');
66
+
67
+ const chunkChars = o.chunkTokens * CHARS_PER_TOK;
68
+ let parts = chunk(ctx, chunkChars, o.overlapTokens * CHARS_PER_TOK);
69
+ const truncated = parts.length > o.maxChunks;
70
+ if (truncated) parts = parts.slice(0, o.maxChunks);
71
+ onProgress({ phase: 'plan', chunks: parts.length, chars: ctx.length, truncated });
72
+
73
+ // MAP — un modelo local es UN motor, así que las sub-llamadas van en serie.
74
+ const findings = [];
75
+ for (let i = 0; i < parts.length; i++) {
76
+ onProgress({ phase: 'map', i: i + 1, n: parts.length });
77
+ let ans;
78
+ try { ans = await sub(provider, MAP_SYS, `PREGUNTA:\n${q}\n\nFRAGMENTO ${i + 1}/${parts.length}:\n${parts[i]}`, o.callTimeoutMs); }
79
+ catch { ans = 'NADA'; }
80
+ ans = String(ans || '').trim();
81
+ if (ans && !/^NADA\b/i.test(ans)) findings.push(`[${i + 1}] ${ans}`);
82
+ }
83
+ onProgress({ phase: 'mapped', kept: findings.length, of: parts.length });
84
+ if (!findings.length) return { answer: 'Revisé el material por partes y no encontré nada relevante para eso.', chunks: parts.length, kept: 0, truncated };
85
+
86
+ const answer = await reduce(provider, q, findings, o, onProgress, 0);
87
+ return { answer: String(answer || '').trim(), chunks: parts.length, kept: findings.length, truncated };
88
+ }
89
+
90
+ // REDUCE jerárquico: si los hallazgos caben, funde en uno; si no, agrupa,
91
+ // funde cada grupo y recurre con las respuestas parciales.
92
+ async function reduce(provider, q, findings, o, onProgress, depth) {
93
+ const budget = o.chunkTokens * CHARS_PER_TOK * 2; // el reduce puede ver algo más que un trozo
94
+ const joined = findings.join('\n');
95
+ if (joined.length <= budget || depth >= o.maxDepth) {
96
+ onProgress({ phase: 'reduce', depth, final: true });
97
+ return sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS:\n${clip(joined, budget)}`, o.callTimeoutMs);
98
+ }
99
+ const groups = [];
100
+ let cur = [];
101
+ let len = 0;
102
+ for (const f of findings) {
103
+ if (len + f.length + 1 > budget && cur.length) { groups.push(cur); cur = []; len = 0; }
104
+ cur.push(f); len += f.length + 1;
105
+ }
106
+ if (cur.length) groups.push(cur);
107
+ onProgress({ phase: 'reduce', depth, groups: groups.length, final: false });
108
+ const partials = [];
109
+ for (let g = 0; g < groups.length; g++) {
110
+ onProgress({ phase: 'reduce-group', depth, g: g + 1, n: groups.length });
111
+ const p = await sub(provider, REDUCE_SYS, `PREGUNTA:\n${q}\n\nHALLAZGOS (grupo ${g + 1}/${groups.length}):\n${groups[g].join('\n')}`, o.callTimeoutMs);
112
+ partials.push(`[g${g + 1}] ${String(p || '').trim()}`);
113
+ }
114
+ return reduce(provider, q, partials, o, onProgress, depth + 1);
115
+ }
116
+
117
+ // ── Hard Work · CREACIÓN: borrador → autocrítica → reescritura ──────────────
118
+ // El hermano creativo de RLM. RLM gasta cómputo LEYENDO material enorme; esto
119
+ // lo gasta MEJORANDO lo que el modelo crea: en vez de una sola pasada, el
120
+ // modelo pequeño hace un borrador, se autocritica sin piedad y se reescribe
121
+ // corrigiendo sus propios fallos. Sube la calidad de un modelo modesto sin
122
+ // cambiar de modelo — solo pensando más veces.
123
+ const CREATE_SYS = 'Eres Elffuss creando una app web. Respondes SOLO con un documento HTML completo y autocontenido (CSS y JS inline, fondo oscuro). Si es un juego o algo visual, usa <canvas> y que se vea vivo (color, movimiento, efectos). Nada de texto fuera del HTML.';
124
+ const CRITIQUE_SYS = 'Eres un crítico de videojuegos y front-end, implacable pero útil. Te doy el código de una app/juego web. Enumera 3 a 6 defectos CONCRETOS y accionables: bugs de lógica, controles que faltan, colisiones mal hechas, falta de condición de fin o de puntuación, jugabilidad sosa y pobreza visual (sin color, sin efectos, estático). Sé específico y breve. NO reescribas el código: solo la lista de defectos.';
125
+ const REWRITE_SYS = 'Eres Elffuss mejorando tu propia app. Te doy el HTML actual y una crítica. Devuelve SOLO el HTML completo y autocontenido MEJORADO que corrige TODOS los puntos de la crítica, conservando lo que ya funcionaba y subiendo el nivel visual (color, brillo, partículas si encaja). Sin explicaciones fuera del HTML.';
126
+
127
+ // Saca el documento HTML de la respuesta del modelo (fence ```html o directo).
128
+ export function extractHtml(text) {
129
+ const s = String(text || '');
130
+ const fence = s.match(/```html\s*([\s\S]*?)```/i) || s.match(/```\s*([\s\S]*?)```/);
131
+ const body = fence ? fence[1] : s;
132
+ const m = body.match(/<!doctype[\s\S]*<\/html>/i) || body.match(/<html[\s\S]*<\/html>/i);
133
+ return (m ? m[0] : body).trim();
134
+ }
135
+
136
+ const CONT_SYS = 'Continúas un documento HTML/JS que se cortó a media escritura. Devuelve SOLO la continuación EXACTA desde donde termina —sin repetir nada de lo ya escrito, sin reabrir <html> ni <script>, sin explicaciones— hasta cerrar todas las etiquetas y </html>.';
137
+
138
+ // Genera y, si el modelo se quedó sin tokens a media escritura (el HTML no
139
+ // cierra </html>), pide la continuación desde la cola y la cose. Así el techo
140
+ // de 1024 tokens deja de truncar apps grandes: se completan por tramos.
141
+ async function genComplete(provider, system, prompt, { maxCont = 4 } = {}) {
142
+ let full = String(await provider.chat([{ role: 'user', content: prompt }], system, () => {}) || '');
143
+ for (let i = 0; i < maxCont && !/<\/html>/i.test(full); i++) {
144
+ const tail = full.slice(-1400);
145
+ let cont = String(await provider.chat(
146
+ [{ role: 'user', content: `FINAL de lo escrito hasta ahora (continúa desde aquí, sin repetirlo):\n${tail}` }],
147
+ CONT_SYS, () => {}) || '');
148
+ // Defensa: si el modelo reabre el documento en vez de continuar, recórtalo.
149
+ cont = cont.replace(/^[\s\S]*?<!doctype html>/i, '').replace(/^[\s\S]*?<html[^>]*>/i, '');
150
+ if (!cont.trim()) break;
151
+ full += cont;
152
+ }
153
+ return full;
154
+ }
155
+
156
+
157
+ // ── Jurado deliberante (Reasoning Jury, arXiv 2608.12585) ───────────────────
158
+ // Un juez único detecta mal los defectos: se inventa unos y se le escapan
159
+ // otros (lo vimos con el crítico único de Hard Work). El paper sustituye al
160
+ // juez por un JURADO que delibera: cada jurado propone defectos, luego ve los
161
+ // de los demás y VOTA cuáles son reales, y un moderador se queda con los que
162
+ // tienen apoyo. Aquí el jurado es un mismo modelo con lentes distintas —no
163
+ // modelos distintos, que no caben en el navegador—, así que el efecto es
164
+ // menor que en el paper; por eso se mide, no se asume.
165
+ const JURY_LENSES = [
166
+ { key: 'logica', name: 'lógica', ask: 'errores de lógica y de estado: variables sin definir, condiciones al revés, bucles que no avanzan, una condición de fin que nunca se cumple, cuentas mal hechas' },
167
+ { key: 'interaccion', name: 'interacción', ask: 'controles y respuesta: entradas que no hacen nada, colisiones que no detectan, falta de arranque o de reinicio, nada que indique al usuario qué pasa' },
168
+ { key: 'visual', name: 'presentación', ask: 'lo que se ve: elementos con tamaño cero o fuera de pantalla, todo del mismo color, nada dibujado, texto ilegible' },
169
+ ];
170
+
171
+ const asLines = t => String(t || '').split('\n')
172
+ .map(l => l.replace(/^\s*(?:[-*\u2022]|\d+[.)])\s*/, '').trim())
173
+ .filter(l => l.length > 12 && l.length < 220);
174
+
175
+ const normDefect = d => d.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
176
+ .replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter(w => w.length > 3).slice(0, 6).join(' ');
177
+
178
+ // Devuelve { defects:[{text,votes,jurors}], rounds } sobre un artefacto.
179
+ // Normaliza espacios para comparar una cita con el código real.
180
+ const flat = t => String(t).replace(/\s+/g, ' ').trim();
181
+
182
+ // Elige las lentes que TIENEN algo que mirar en este artefacto. Pasarle
183
+ // «presentación» a una función pura de cálculo no aporta nada: ese jurado o
184
+ // calla o repite lo que dijo el de lógica, y los repetidos se comían las
185
+ // plazas de candidatos.
186
+ function lensesFor(material) {
187
+ const tieneUI = /<canvas|<button|<input|<form|document\.|addEventListener|innerHTML|querySelector/i.test(material);
188
+ const tienePintado = /<canvas|getContext|fillRect|drawImage|style\.|css/i.test(material);
189
+ const out = [JURY_LENSES[0]]; // lógica: siempre
190
+ if (tieneUI) out.push(JURY_LENSES[1]); // interacción
191
+ if (tienePintado) out.push(JURY_LENSES[2]); // presentación
192
+ if (out.length === 1) out.push(JURY_LENSES_EXTRA[0], JURY_LENSES_EXTRA[1]); // código puro
193
+ else if (out.length === 2) out.push(JURY_LENSES_EXTRA[0]);
194
+ return out;
195
+ }
196
+
197
+ // Lentes para código sin interfaz, donde «interacción» y «presentación» no pintan nada.
198
+ const JURY_LENSES_EXTRA = [
199
+ { key: 'datos', name: 'datos y tipos', ask: 'tipos y datos: conversiones implícitas, valores que llegan como texto cuando se esperan números, undefined/NaN, claves que cambian de tipo' },
200
+ { key: 'bordes', name: 'casos límite', ask: 'casos límite y validación: entradas vacías, negativas o fuera de rango, ausencia de comprobaciones, resultados absurdos que nadie detiene' },
201
+ ];
202
+
203
+ export async function juryReview({ artifact, provider, onProgress = () => {}, lenses = null, requireEvidence = true }) {
204
+ const dropped = [];
205
+ if (!provider?.chat) throw new Error('el jurado necesita un cerebro cargado');
206
+ const material = String(artifact || '').slice(0, 6000);
207
+ lenses = lenses || lensesFor(material);
208
+ onProgress({ phase: 'jury-lenses', lenses: lenses.map(l => l.name) });
209
+
210
+ // Ronda 1 — cada jurado propone defectos desde SU lente, sin ver a los demás.
211
+ const proposals = [];
212
+ for (const L of lenses) {
213
+ onProgress({ phase: 'jury-propose', lens: L.name });
214
+ const sys = `Eres un revisor especializado en ${L.ask}. Te doy el código de una app web. Enumera SOLO los defectos REALES que veas desde tu especialidad. Por cada uno escribe DOS líneas seguidas:
215
+ DEFECTO: <qué está mal, en una línea>
216
+ PRUEBA: <copia LITERAL del fragmento de código que lo demuestra, tal cual aparece>
217
+ Máximo 4 defectos. Si no ves ninguno, escribe NINGUNO. La PRUEBA debe estar copiada del código palabra por palabra; si no puedes copiarla, no incluyas ese defecto.`;
218
+ const out = await provider.chat([{ role: 'user', content: 'CÓDIGO:\n' + material }], sys, () => {});
219
+ // Emparejar DEFECTO con su PRUEBA y COMPROBAR que la cita existe de verdad
220
+ // en el código. Es un filtro determinista: si el modelo no sabe citar, se
221
+ // lo ha inventado. (El modelo verifica mucho mejor de lo que propone, pero
222
+ // esto ni siquiera necesita al modelo.)
223
+ const raw = String(out).split('\n').map(x => x.trim());
224
+ const found = [];
225
+ for (let i = 0; i < raw.length; i++) {
226
+ const dm = raw[i].match(/^DEFECTO:\s*(.+)/i);
227
+ if (!dm) continue;
228
+ const pm = (raw[i + 1] || '').match(/^PRUEBA:\s*(.+)/i);
229
+ const texto = dm[1].trim();
230
+ if (texto.length < 12) continue;
231
+ if (!requireEvidence) { found.push({ texto, cita: pm ? pm[1].trim() : '' }); continue; }
232
+ const cita = pm ? pm[1].trim().replace(/^[`'"]|[`'"]$/g, '') : '';
233
+ if (cita.length >= 8 && flat(material).includes(flat(cita))) found.push({ texto, cita });
234
+ else dropped.push({ lens: L.name, texto, cita: cita.slice(0, 60) });
235
+ }
236
+ proposals.push({ lens: L, found });
237
+ }
238
+
239
+ // Candidatos únicos (los jurados repiten el mismo defecto con otras palabras).
240
+ // Deduplicar por la CITA de código, no por las palabras: dos jurados describen
241
+ // el mismo fallo con frases distintas («el bucle empieza en 1» y «ignora el
242
+ // primer elemento») y el deduplicador léxico los dejaba pasar como dos, con lo
243
+ // que la lista de candidatos se llenaba de repetidos. Si señalan el mismo
244
+ // fragmento de código, es el mismo defecto.
245
+ const seen = new Map();
246
+ for (const p of proposals) for (const d of p.found) {
247
+ const k = d.cita && d.cita.length >= 8 ? 'c:' + flat(d.cita).slice(0, 80) : 't:' + normDefect(d.texto);
248
+ if (k && !seen.has(k)) seen.set(k, d.texto);
249
+ }
250
+ const candidates = [...seen.values()].slice(0, 12);
251
+ onProgress({ phase: 'jury-candidates', n: candidates.length });
252
+ if (!candidates.length) return { defects: [], candidates: 0, dropped };
253
+
254
+ // Ronda 2 — DELIBERACIÓN: cada jurado ve TODOS los candidatos (incluidos los
255
+ // ajenos) y vota cuáles son de verdad. Aquí es donde puede retirar el suyo.
256
+ const votes = candidates.map(() => 0);
257
+ for (const L of lenses) {
258
+ onProgress({ phase: 'jury-vote', lens: L.name });
259
+ const list = candidates.map((c, i) => `${i + 1}. ${c}`).join('\n');
260
+ // OJO con el formato: poner "N: SI" como plantilla hacía que el modelo
261
+ // escribiera literalmente la letra N y no se parseara NINGÚN voto (el
262
+ // jurado parecía rechazarlo todo). Los modelos pequeños copian el ejemplo
263
+ // tal cual: hay que darles números de verdad.
264
+ const sys = `Eres un revisor riguroso (especialidad: ${L.name}). Te doy el código y una lista numerada de defectos propuestos por otros revisores. Para CADA número, mira el código y di si el defecto es REAL. Responde SOLO con una línea por defecto, con su número y SI o NO. Así:
265
+ 1: SI
266
+ 2: NO
267
+ 3: SI
268
+ Nada más: ni explicaciones ni texto extra. Ante la duda, NO.`;
269
+ const out = await provider.chat(
270
+ [{ role: 'user', content: 'CÓDIGO:\n' + material + '\n\nDEFECTOS PROPUESTOS:\n' + list }], sys, () => {});
271
+ const txt = String(out);
272
+ let parsed = 0;
273
+ for (const m of txt.matchAll(/(\d+)\s*[:.)\-]\s*(S[IÍ]|YES|NO)\b/gi)) {
274
+ const i = +m[1] - 1;
275
+ parsed++;
276
+ if (i >= 0 && i < votes.length && /^(s[ií]|yes)$/i.test(m[2])) votes[i]++;
277
+ }
278
+ // Respaldo: si el modelo ignoró el formato numerado, leer los SI/NO en orden.
279
+ if (!parsed) {
280
+ const seq = [...txt.matchAll(/\b(S[IÍ]|YES|NO)\b/gi)].map(m => m[1]);
281
+ seq.slice(0, votes.length).forEach((v, i) => { if (/^(s[ií]|yes)$/i.test(v)) votes[i]++; });
282
+ }
283
+ onProgress({ phase: 'jury-parsed', lens: L.name, parsed });
284
+ }
285
+
286
+ // Moderador — se queda con los que tienen apoyo de la mayoría del jurado.
287
+ const need = Math.ceil(lenses.length / 2);
288
+ const defects = candidates.map((text, i) => ({ text, votes: votes[i] }))
289
+ .filter(d => d.votes >= need)
290
+ .sort((a, b) => b.votes - a.votes);
291
+ onProgress({ phase: 'jury-verdict', kept: defects.length, of: candidates.length });
292
+ onProgress({ phase: 'jury-evidence', dropped: dropped.length });
293
+ return { defects, candidates: candidates.length, jurors: lenses.length, dropped };
294
+ }
295
+
296
+
297
+ // ── Edición QUIRÚRGICA en vez de reescritura completa ───────────────────────
298
+ // Reescribir el documento entero para corregir tres cosas hace que el modelo
299
+ // regenere de cero lo que ya funcionaba, y por el camino rompe la mecánica.
300
+ // Aquí solo se le piden los CAMBIOS, y los aplica este código: si el fragmento
301
+ // a buscar no está tal cual en el documento, ese cambio se descarta. El modelo
302
+ // propone, el código decide — igual que con las citas del jurado.
303
+ const PATCH_SYS = `Eres Elffuss arreglando tu propia app. Te doy el HTML actual y una lista de defectos. NO reescribas el documento. Devuelve SOLO los cambios mínimos, en bloques de exactamente tres líneas:
304
+ BUSCAR: <fragmento LITERAL del código actual, copiado tal cual, en una línea>
305
+ CAMBIAR: <con qué se sustituye, en una línea>
306
+ MOTIVO: <qué defecto arregla>
307
+ Repite el bloque por cada cambio. Máximo 6. El texto de BUSCAR debe existir palabra por palabra en el HTML y ser único; si no puedes copiarlo exacto, omite ese cambio. No toques nada que ya funcione.`;
308
+
309
+ export function applyPatches(html, respuesta) {
310
+ const lineas = String(respuesta || '').split('\n').map(x => x.trim());
311
+ let out = html;
312
+ const aplicados = [], fallidos = [];
313
+ for (let i = 0; i < lineas.length; i++) {
314
+ const b = lineas[i].match(/^BUSCAR:\s*(.+)/i);
315
+ if (!b) continue;
316
+ const c = (lineas[i + 1] || '').match(/^CAMBIAR:\s*(.+)/i);
317
+ if (!c) continue;
318
+ const buscar = b[1].trim(), cambiar = c[1].trim();
319
+ if (buscar.length < 6) continue;
320
+ const veces = out.split(buscar).length - 1;
321
+ if (veces === 1) { out = out.replace(buscar, cambiar); aplicados.push(buscar.slice(0, 60)); }
322
+ else fallidos.push({ buscar: buscar.slice(0, 60), veces }); // 0 = inventado, >1 = ambiguo
323
+ }
324
+ return { html: out, aplicados, fallidos };
325
+ }
326
+
327
+ export async function deepCreate({ brief, provider, rounds = 1, onProgress = () => {}, useJury = false, editMode = 'rewrite' }) {
328
+ if (!provider || typeof provider.chat !== 'function') throw new Error('Hard Work necesita un cerebro cargado (elige un modelo arriba).');
329
+ if (!String(brief || '').trim()) throw new Error('Hard Work: dime qué quieres que cree.');
330
+ onProgress({ phase: 'draft' });
331
+ let html = extractHtml(await genComplete(provider, CREATE_SYS, String(brief)));
332
+ const trace = [{ round: 0, html }];
333
+ for (let r = 1; r <= rounds; r++) {
334
+ onProgress({ phase: 'critique', round: r });
335
+ let critique;
336
+ if (useJury) {
337
+ const v = await juryReview({ artifact: html, provider, onProgress });
338
+ critique = v.defects.length
339
+ ? v.defects.map(d => `- ${d.text} (${d.votes} de ${v.jurors} revisores)`).join('\n')
340
+ : 'Sin defectos con apoyo suficiente del jurado.';
341
+ } else {
342
+ critique = String(await provider.chat([{ role: 'user', content: `APP/JUEGO:\n${html}` }], CRITIQUE_SYS, () => {})).trim();
343
+ }
344
+ onProgress({ phase: 'rewrite', round: r, modo: editMode });
345
+ if (editMode === 'patch') {
346
+ const resp = await provider.chat(
347
+ [{ role: 'user', content: `HTML ACTUAL:\n${html}\n\nDEFECTOS A CORREGIR:\n${critique}` }], PATCH_SYS, () => {});
348
+ const { html: nuevo, aplicados, fallidos } = applyPatches(html, resp);
349
+ onProgress({ phase: 'patched', round: r, aplicados: aplicados.length, fallidos: fallidos.length });
350
+ if (aplicados.length) { html = nuevo; trace.push({ round: r, critique, html, aplicados, fallidos }); }
351
+ else trace.push({ round: r, critique, html, aplicados: [], fallidos });
352
+ } else {
353
+ const improved = extractHtml(await genComplete(provider, REWRITE_SYS, `HTML ACTUAL:\n${html}\n\nCRÍTICA A CORREGIR:\n${critique}`));
354
+ if (improved && /<\w+[\s>]/.test(improved)) { html = improved; trace.push({ round: r, critique, html }); }
355
+ }
356
+ }
357
+ onProgress({ phase: 'done', rounds: trace.length - 1 });
358
+ return { html, trace };
359
+ }
360
+
361
+ // ── Recolección de material desde una carpeta autorizada (File System Access) ──
362
+ // Camina el árbol, concatena ficheros de texto con cabecera de ruta, y corta a
363
+ // maxBytes para no reventar la memoria. Mismo código sirve a Claw (carpeta
364
+ // autorizada) y a Code (proyecto abierto): ambos entregan un directory handle.
365
+ const TEXT_EXT = new Set(('txt md markdown js mjs cjs ts tsx jsx json html htm css scss sass ' +
366
+ 'py rb php go rs java kt swift c h cpp hpp cc cs sh bash zsh yml yaml toml ini cfg conf ' +
367
+ 'xml csv tsv sql vue svelte astro lua r pl pm ex exs erl clj tex org rst gradle properties env').split(' '));
368
+ const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'target', 'vendor', '.cache', 'coverage', '__pycache__', '.venv', 'venv']);
369
+
370
+ export async function gatherFolder(rootHandle, { maxBytes = 1.5e6, maxFiles = 400 } = {}) {
371
+ if (!rootHandle || typeof rootHandle.entries !== 'function') throw new Error('no hay carpeta que leer');
372
+ const out = [];
373
+ let bytes = 0;
374
+ let files = 0;
375
+ async function walk(dir, prefix) {
376
+ if (bytes >= maxBytes || files >= maxFiles) return;
377
+ const entries = [];
378
+ for await (const e of dir.entries()) entries.push(e);
379
+ entries.sort((a, b) => a[0].localeCompare(b[0]));
380
+ for (const [name, handle] of entries) {
381
+ if (bytes >= maxBytes || files >= maxFiles) return;
382
+ if (name.startsWith('.') && name !== '.env') { if (handle.kind === 'directory') continue; }
383
+ const path = prefix ? prefix + '/' + name : name;
384
+ if (handle.kind === 'directory') {
385
+ if (SKIP_DIR.has(name)) continue;
386
+ await walk(handle, path);
387
+ } else {
388
+ const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
389
+ if (!TEXT_EXT.has(ext)) continue;
390
+ try {
391
+ const file = await handle.getFile();
392
+ if (file.size > 400000) continue; // un fichero enorme suelto no monopoliza
393
+ const text = await file.text();
394
+ const block = `\n\n===== ${path} =====\n${text}`;
395
+ out.push(block);
396
+ bytes += block.length;
397
+ files++;
398
+ } catch { /* ilegible: se salta */ }
399
+ }
400
+ }
401
+ }
402
+ await walk(rootHandle, '');
403
+ return out.join('').slice(0, maxBytes);
404
+ }
js/skills.js CHANGED
@@ -19,16 +19,36 @@ export const DEFAULT_SOURCES = [
19
 
20
  let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)
21
 
 
 
 
 
 
22
  export async function initSkills() {
23
- cache = (await db.get('kv', KEY).catch(() => null)) || [];
 
 
 
 
 
 
 
 
24
  return cache;
25
  }
 
26
 
27
  export async function all() { return cache; }
28
  export function installed() { return cache; }
29
  export function isInstalled(repo, path) { return cache.some(s => s.repo === repo && s.path === path); }
30
 
31
  export async function install(skill) {
 
 
 
 
 
 
32
  cache = cache.filter(s => !(s.repo === skill.repo && s.path === skill.path) && s.name !== skill.name);
33
  const entry = { ...skill, content: (skill.content || '').slice(0, MAX_SKILL) };
34
  cache.push(entry);
 
19
 
20
  let cache = []; // instaladas, en memoria (para que el systemPrompt sea síncrono)
21
 
22
+ // `loaded` distingue «no hay skills» de «no se pudo leer»: si la lectura
23
+ // falla, cache queda vacío pero loaded=false, y install() se niega a persistir
24
+ // (antes, un fallo transitorio de IndexedDB borraba TODAS las skills al
25
+ // guardar la siguiente).
26
+ let loaded = false;
27
  export async function initSkills() {
28
+ try {
29
+ const stored = await db.get('kv', KEY);
30
+ cache = Array.isArray(stored) ? stored : [];
31
+ loaded = true;
32
+ } catch (e) {
33
+ cache = [];
34
+ loaded = false;
35
+ console.warn('[elffuss] no se pudieron leer las skills; no las sobrescribiré', e);
36
+ }
37
  return cache;
38
  }
39
+ export function skillsLoaded() { return loaded; }
40
 
41
  export async function all() { return cache; }
42
  export function installed() { return cache; }
43
  export function isInstalled(repo, path) { return cache.some(s => s.repo === repo && s.path === path); }
44
 
45
  export async function install(skill) {
46
+ // Si la carga inicial falló, cache no representa lo instalado: reintentar
47
+ // leer antes de escribir. Sin esto, guardar aquí borraría las skills reales.
48
+ if (!loaded) {
49
+ await initSkills();
50
+ if (!loaded) throw new Error('No pude leer tus skills guardadas; no instalo para no perderlas. Recarga la página e inténtalo otra vez.');
51
+ }
52
  cache = cache.filter(s => !(s.repo === skill.repo && s.path === skill.path) && s.name !== skill.name);
53
  const entry = { ...skill, content: (skill.content || '').slice(0, MAX_SKILL) };
54
  cache.push(entry);
js/splash-gl.js CHANGED
@@ -35,9 +35,9 @@ void main(){
35
  float tw = 0.55 + 0.45 * sin(t * (1.5 + seed.x * 3.0) + seed.y * 40.0);
36
  vGlow = tw * (1.0 - burst * 0.55);
37
  float gold = step(0.93, hash(seed.x * 31.3 + seed.y * 7.7));
38
- vec3 pink = vec3(1.0, 0.30, 0.55);
39
- vec3 violet = vec3(0.49, 0.36, 1.0);
40
- vec3 goldc = vec3(1.0, 0.84, 0.42);
41
  vColor = mix(mix(pink, violet, smoothstep(0.1, 1.3, r)), goldc, gold);
42
  gl_PointSize = (1.3 + 2.8 * tw * max(1.2 - r * 0.5, 0.2)) * dpr;
43
  }`;
 
35
  float tw = 0.55 + 0.45 * sin(t * (1.5 + seed.x * 3.0) + seed.y * 40.0);
36
  vGlow = tw * (1.0 - burst * 0.55);
37
  float gold = step(0.93, hash(seed.x * 31.3 + seed.y * 7.7));
38
+ vec3 pink = vec3(0.33, 0.86, 0.77);
39
+ vec3 violet = vec3(0.51, 0.45, 1.0);
40
+ vec3 goldc = vec3(0.82, 0.63, 0.42);
41
  vColor = mix(mix(pink, violet, smoothstep(0.1, 1.3, r)), goldc, gold);
42
  gl_PointSize = (1.3 + 2.8 * tw * max(1.2 - r * 0.5, 0.2)) * dpr;
43
  }`;
js/swe-tasks.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Batería de tareas estilo SWE-bench, autocontenidas y REPRODUCIBLES en el
2
+ // navegador: cada una siembra un repo con un BUG + una especificación (lo que el
3
+ // test espera), y un `test(mod)` que se EJECUTA de verdad contra el módulo
4
+ // arreglado (métrica `resolved`, como SWE-bench). `solution` es el contenido
5
+ // correcto del fichero objetivo (lo usa el solver determinista del arnés; el
6
+ // modelo real no lo ve, tiene que deducirlo leyendo `target` y la spec).
7
+ export const TASKS = [
8
+ {
9
+ id: 'add-sub',
10
+ target: 'src/math.js',
11
+ files: {
12
+ 'src/math.js': 'export function add(a, b) {\n return a - b; // BUG\n}\n',
13
+ 'spec/math.md': '# add(a, b)\nDebe SUMAR: add(2,3) → 5, add(10,-4) → 6.',
14
+ },
15
+ task: 'add() en src/math.js resta en vez de sumar; arréglalo para que sume.',
16
+ solution: 'export function add(a, b) {\n return a + b;\n}\n',
17
+ test: m => m.add(2, 3) === 5 && m.add(10, -4) === 6 && m.add(0, 0) === 0,
18
+ },
19
+ {
20
+ id: 'max-empty',
21
+ target: 'src/max.js',
22
+ files: {
23
+ 'src/max.js': 'export function max(arr) {\n return arr.reduce((a, b) => a > b ? a : b); // BUG: peta con []\n}\n',
24
+ 'spec/max.md': '# max(arr)\nmax([3,1,2]) → 3. Con array vacío NO debe petar: max([]) → undefined.',
25
+ },
26
+ task: 'max() peta con un array vacío; haz que devuelva undefined en ese caso.',
27
+ solution: 'export function max(arr) {\n if (!arr.length) return undefined;\n return arr.reduce((a, b) => a > b ? a : b);\n}\n',
28
+ test: m => m.max([3, 1, 2]) === 3 && m.max([]) === undefined && m.max([-1, -5]) === -1,
29
+ },
30
+ {
31
+ id: 'unique',
32
+ target: 'src/unique.js',
33
+ files: {
34
+ 'src/unique.js': 'export function unique(arr) {\n return arr; // BUG: no deduplica\n}\n',
35
+ 'spec/unique.md': '# unique(arr)\nDevuelve el array SIN duplicados: unique([1,1,2,3,3]) → [1,2,3].',
36
+ },
37
+ task: 'unique() no elimina duplicados; arréglalo.',
38
+ solution: 'export function unique(arr) {\n return [...new Set(arr)];\n}\n',
39
+ test: m => { const r = m.unique([1, 1, 2, 3, 3]); return r.length === 3 && r.join(',') === '1,2,3'; },
40
+ },
41
+ {
42
+ id: 'slugify',
43
+ target: 'src/slug.js',
44
+ files: {
45
+ 'src/slug.js': 'export function slugify(s) {\n return s.replace(/ /g, "-"); // BUG: no pasa a minúsculas\n}\n',
46
+ 'spec/slug.md': '# slugify(s)\nMinúsculas y guiones: slugify("Hola Mundo") → "hola-mundo".',
47
+ },
48
+ task: 'slugify() no pasa a minúsculas; debe devolver minúsculas con guiones.',
49
+ solution: 'export function slugify(s) {\n return s.toLowerCase().replace(/ /g, "-");\n}\n',
50
+ test: m => m.slugify('Hola Mundo') === 'hola-mundo' && m.slugify('A B C') === 'a-b-c',
51
+ },
52
+ {
53
+ id: 'clamp',
54
+ target: 'src/clamp.js',
55
+ files: {
56
+ 'src/clamp.js': 'export function clamp(v, lo, hi) {\n return v; // BUG: no acota\n}\n',
57
+ 'spec/clamp.md': '# clamp(v, lo, hi)\nAcota v al rango [lo,hi]: clamp(15,0,10) → 10, clamp(-5,0,10) → 0.',
58
+ },
59
+ task: 'clamp() no acota el valor al rango [lo,hi]; arréglalo.',
60
+ solution: 'export function clamp(v, lo, hi) {\n return Math.min(hi, Math.max(lo, v));\n}\n',
61
+ test: m => m.clamp(15, 0, 10) === 10 && m.clamp(-5, 0, 10) === 0 && m.clamp(5, 0, 10) === 5,
62
+ },
63
+ {
64
+ id: 'fizzbuzz',
65
+ target: 'src/fizzbuzz.js',
66
+ files: {
67
+ 'src/fizzbuzz.js': 'export function fizzbuzz(n) {\n if (n % 3 === 0) return "Fizz";\n if (n % 5 === 0) return "Buzz";\n if (n % 15 === 0) return "FizzBuzz"; // BUG: inalcanzable\n return String(n);\n}\n',
68
+ 'spec/fizzbuzz.md': '# fizzbuzz(n)\nMúltiplo de 15 → "FizzBuzz", de 3 → "Fizz", de 5 → "Buzz", si no el número.',
69
+ },
70
+ task: 'fizzbuzz(15) debería dar "FizzBuzz" pero da "Fizz"; el caso de 15 es inalcanzable, arréglalo.',
71
+ solution: 'export function fizzbuzz(n) {\n if (n % 15 === 0) return "FizzBuzz";\n if (n % 3 === 0) return "Fizz";\n if (n % 5 === 0) return "Buzz";\n return String(n);\n}\n',
72
+ test: m => m.fizzbuzz(15) === 'FizzBuzz' && m.fizzbuzz(9) === 'Fizz' && m.fizzbuzz(10) === 'Buzz' && m.fizzbuzz(7) === '7',
73
+ },
74
+ ];
js/tools/code.js CHANGED
@@ -186,6 +186,10 @@ export async function read({ path, offset, limit, around } = {}) {
186
  ? Math.max(1, Math.round(around) - Math.floor(size / 2))
187
  : Math.max(1, Math.round(offset) || 1);
188
  const startIdx = start - 1;
 
 
 
 
189
  const slice = lines.slice(startIdx, startIdx + size);
190
  const endLine = startIdx + slice.length;
191
  const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
@@ -249,30 +253,84 @@ export async function edit({ path, search, replace } = {}) {
249
  // los espacios; localizamos el bloque normalizando espacios y, si hace falta,
250
  // por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
251
  // en lo hondo de un fichero grande).
252
- const norm = s => s.replace(/[ \t]+/g, ' ').trim();
253
- const curLines = current.split('\n');
254
- const seaLines = search.replace(/\n+$/, '').split('\n');
255
- const nSea = seaLines.map(norm);
256
- const k = seaLines.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
- // 1) bloque idéntico salvo espacios → tiene que ser ÚNICO
259
- let exactStart = -1, exactHits = 0;
260
- for (let i = 0; i + k <= curLines.length; i++) {
261
- let same = true;
262
- for (let j = 0; j < k; j++) if (norm(curLines[i + j]) !== nSea[j]) { same = false; break; }
263
- if (same) { exactHits++; if (exactStart === -1) exactStart = i; if (exactHits > 1) break; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  }
265
- if (exactHits > 1)
266
- throw new Error(`«search» coincide (ignorando espacios) en más de un sitio de ${path} — añade líneas de contexto para que sea inequívoco.`);
267
 
268
- let start = exactStart;
 
269
  if (start === -1) {
270
- // 2) por parecido: la ventana con más líneas coincidentes (normalizadas),
271
- // exigiendo ≥70% y que gane con claridad a la segunda mejor (sin ambigüedad)
272
  let best = -1, bestScore = 0, second = 0;
273
- for (let i = 0; i + k <= curLines.length; i++) {
274
  let hit = 0;
275
- for (let j = 0; j < k; j++) if (norm(curLines[i + j]) === nSea[j]) hit++;
276
  const score = hit / k;
277
  if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
278
  else if (score > second) second = score;
@@ -283,8 +341,13 @@ export async function edit({ path, search, replace } = {}) {
283
  throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
284
  `Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
285
 
286
- const nextLines = [...curLines.slice(0, start), ...replace.split('\n'), ...curLines.slice(start + k)];
287
- const nextContent = nextLines.join('\n');
 
 
 
 
 
288
  if (nextContent === current)
289
  throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
290
  return write({ path, content: nextContent });
@@ -294,11 +357,16 @@ export async function edit({ path, search, replace } = {}) {
294
  // existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
295
  // (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
296
  // como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
297
- const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80;
 
 
 
 
298
  export async function search({ query, ext = '' } = {}) {
299
  if (!query) throw new Error('Falta query');
300
  if (!projectHandle) throw new Error('No hay proyecto abierto');
301
  const results = [];
 
302
  let checked = 0, capped = false;
303
  const q = query.toLowerCase();
304
  async function walk(dir, prefix) {
@@ -308,8 +376,13 @@ export async function search({ query, ext = '' } = {}) {
308
  const p = prefix ? prefix + '/' + e.name : e.name;
309
  if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
310
  if (ext && !e.name.endsWith(ext)) continue;
 
311
  const f = await e.getFile();
312
- if (f.size > 200_000) continue;
 
 
 
 
313
  checked++;
314
  const lines = (await f.text()).split('\n');
315
  for (let i = 0; i < lines.length; i++) {
@@ -322,9 +395,13 @@ export async function search({ query, ext = '' } = {}) {
322
  }
323
  }
324
  await walk(projectHandle, '');
 
 
 
 
 
 
325
  if (!results.length)
326
- return `Sin resultados para «${query}»` + (capped ? ` (búsqueda cortada tras ${checked} ficheros; afina con ext:".js" o un término más concreto).` : '');
327
- return results.join('\n') + (capped
328
- ? `\n… búsqueda cortada (${results.length} resultados, ${checked} ficheros revisados) — puede haber más: afina con ext o un término más concreto.`
329
- : '');
330
  }
 
186
  ? Math.max(1, Math.round(around) - Math.floor(size / 2))
187
  : Math.max(1, Math.round(offset) || 1);
188
  const startIdx = start - 1;
189
+ // Pedir más allá del final devolvía un rango imposible («líneas 999-998 de 6»)
190
+ // y CERO contenido: el modelo se quedaba sin nada que hacer. Mejor decírselo.
191
+ if (startIdx >= total)
192
+ return `${path}: solo tiene ${total} líneas y pediste desde la ${start} — usa offset entre 1 y ${total}.`;
193
  const slice = lines.slice(startIdx, startIdx + size);
194
  const endLine = startIdx + slice.length;
195
  const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
 
253
  // los espacios; localizamos el bloque normalizando espacios y, si hace falta,
254
  // por parecido de líneas — y sustituimos ESE bloque, esté donde esté (también
255
  // en lo hondo de un fichero grande).
256
+ const norm = s => s.replace(/[ \t]+/g, ' ').trim(); // .trim() se lleva también el \r final
257
+ // Partimos guardando el offset de carácter de cada línea: así el bloque se
258
+ // sustituye por CORTE EXACTO y todo lo de fuera queda byte a byte igual
259
+ // (incluidos sus finales de línea). Con split/join se reescribía el fichero
260
+ // entero y un fichero CRLF acababa con finales de línea MEZCLADOS.
261
+ const curLines = [], lineStart = [];
262
+ for (let i = 0; i <= current.length;) {
263
+ const nl = current.indexOf('\n', i);
264
+ lineStart.push(i);
265
+ if (nl === -1) { curLines.push(current.slice(i)); break; }
266
+ curLines.push(current.slice(i, nl)); // puede acabar en \r
267
+ i = nl + 1;
268
+ }
269
+ const crlf = (current.match(/\r\n/g) || []).length;
270
+ const eol = crlf && crlf * 2 >= (current.match(/\n/g) || []).length ? '\r\n' : '\n';
271
+ const nCur = curLines.map(norm); // normalizamos UNA vez
272
+ let seaLines = search.replace(/[\r\n]+$/, '').split(/\r?\n/);
273
+ let repLines = replace.split(/\r?\n/);
274
+
275
+ // Posiciones donde un bloque de líneas encaja (ignorando espacios).
276
+ const locate = (nBlock) => {
277
+ const at = [];
278
+ for (let i = 0; i + nBlock.length <= nCur.length; i++) {
279
+ let same = true;
280
+ for (let j = 0; j < nBlock.length; j++) if (nCur[i + j] !== nBlock[j]) { same = false; break; }
281
+ if (same) { at.push(i); if (at.length > 8) break; }
282
+ }
283
+ return at;
284
+ };
285
+ const ambiguous = () => new Error(`«search» coincide (ignorando espacios) en más de un sitio de ${path} — añade líneas de contexto para que sea inequívoco.`);
286
 
287
+ let start = -1, k = seaLines.length;
288
+
289
+ // 1) el bloque ENTERO, idéntico salvo espacios tiene que ser ÚNICO
290
+ {
291
+ const at = locate(seaLines.map(norm));
292
+ if (at.length > 1) throw ambiguous();
293
+ if (at.length === 1) start = at[0];
294
+ }
295
+
296
+ // 2) si no aparece entero, puede ser que el fichero tenga líneas que el modelo
297
+ // NO vio (un comentario añadido después, p. ej.) metidas dentro del bloque.
298
+ // Como search y replace comparten el contexto sin tocar al principio y al
299
+ // final, recortamos esa parte común y buscamos solo el NÚCLEO que cambia: así
300
+ // lo que el modelo no vio se queda donde estaba en vez de desaparecer.
301
+ if (start === -1) {
302
+ let pre = 0, suf = 0;
303
+ while (pre < seaLines.length && pre < repLines.length && norm(seaLines[pre]) === norm(repLines[pre])) pre++;
304
+ while (suf < seaLines.length - pre && suf < repLines.length - pre &&
305
+ norm(seaLines[seaLines.length - 1 - suf]) === norm(repLines[repLines.length - 1 - suf])) suf++;
306
+ const core = seaLines.slice(pre, seaLines.length - suf);
307
+ if (core.length) {
308
+ let at = locate(core.map(norm));
309
+ if (at.length > 1) {
310
+ // desempate por CONTEXTO: gana el candidato con más líneas del contexto
311
+ // recortado alrededor (y solo si gana en solitario).
312
+ const ctx = [...seaLines.slice(0, pre), ...seaLines.slice(seaLines.length - suf)].map(norm).filter(Boolean);
313
+ const near = i => {
314
+ const from = Math.max(0, i - pre - 3), to = Math.min(nCur.length, i + core.length + suf + 3);
315
+ const around = nCur.slice(from, to);
316
+ return ctx.filter(c => around.includes(c)).length;
317
+ };
318
+ const scored = at.map(i => ({ i, s: near(i) })).sort((a, b) => b.s - a.s);
319
+ if (ctx.length && scored[0].s > scored[1].s) at = [scored[0].i];
320
+ }
321
+ if (at.length > 1) throw ambiguous();
322
+ if (at.length === 1) { start = at[0]; k = core.length; repLines = repLines.slice(pre, repLines.length - suf); }
323
+ }
324
  }
 
 
325
 
326
+ // 3) por parecido: la ventana con más líneas coincidentes, exigiendo ≥70% y
327
+ // que gane con claridad a la segunda mejor (sin ambigüedad)
328
  if (start === -1) {
329
+ const nSea = seaLines.map(norm);
 
330
  let best = -1, bestScore = 0, second = 0;
331
+ for (let i = 0; i + k <= nCur.length; i++) {
332
  let hit = 0;
333
+ for (let j = 0; j < k; j++) if (nCur[i + j] === nSea[j]) hit++;
334
  const score = hit / k;
335
  if (score > bestScore) { second = bestScore; bestScore = score; best = i; }
336
  else if (score > second) second = score;
 
341
  throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
342
  `Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
343
 
344
+ // Corte exacto del bloque [start, start+k): desde el inicio de su primera
345
+ // línea hasta el inicio de la siguiente (es decir, salto de línea incluido).
346
+ const cutFrom = lineStart[start];
347
+ const hasTrailingNL = start + k < lineStart.length;
348
+ const cutTo = hasTrailingNL ? lineStart[start + k] : current.length;
349
+ const block = repLines.join(eol) + (hasTrailingNL ? eol : '');
350
+ const nextContent = current.slice(0, cutFrom) + block + current.slice(cutTo);
351
  if (nextContent === current)
352
  throw new Error(`La edición en ${path} no cambió nada — revisa «replace».`);
353
  return write({ path, content: nextContent });
 
357
  // existen por rendimiento, pero si se alcanzan hay que DECÍRSELO al modelo
358
  // (igual que read() avisa «quedan líneas…»); si no, un corte silencioso se lee
359
  // como «no hay más» y el modelo da por cerrada una búsqueda incompleta.
360
+ const SEARCH_MAX_FILES = 1500, SEARCH_MAX_HITS = 80, SEARCH_MAX_BYTES = 2_000_000;
361
+ // Binarios: NO pueden contener el texto que se busca y leerlos como texto es
362
+ // caro. Fuera de esta lista quedan .svg/.json/.md/.csv, que son texto y sí se
363
+ // buscan. (Antes el tope de tamaño hacía de filtro accidental de binarios.)
364
+ const BINARY_EXT = /\.(png|jpe?g|gif|webp|avif|ico|bmp|tiff?|mp[34]|m4a|wav|ogg|mov|avi|webm|pdf|zip|gz|bz2|xz|tar|7z|rar|woff2?|ttf|eot|otf|wasm|bin|exe|dll|so|dylib|class|jar|pyc|sqlite3?)$/i;
365
  export async function search({ query, ext = '' } = {}) {
366
  if (!query) throw new Error('Falta query');
367
  if (!projectHandle) throw new Error('No hay proyecto abierto');
368
  const results = [];
369
+ const skipped = []; // ficheros de texto NO buscados por tamaño
370
  let checked = 0, capped = false;
371
  const q = query.toLowerCase();
372
  async function walk(dir, prefix) {
 
376
  const p = prefix ? prefix + '/' + e.name : e.name;
377
  if (e.kind === 'directory') { await walk(e, p); if (capped) return; continue; }
378
  if (ext && !e.name.endsWith(ext)) continue;
379
+ if (BINARY_EXT.test(e.name)) continue;
380
  const f = await e.getFile();
381
+ // Un fichero grande es justo donde el modelo NO puede leerlo entero, así
382
+ // que es el que más falta hace buscar. Se busca hasta SEARCH_MAX_BYTES; y
383
+ // lo que quede fuera se DICE (antes: >200KB se saltaba en silencio, y
384
+ // «Sin resultados» sonaba a «ese código no existe» siendo mentira).
385
+ if (f.size > SEARCH_MAX_BYTES) { skipped.push(p); continue; }
386
  checked++;
387
  const lines = (await f.text()).split('\n');
388
  for (let i = 0; i < lines.length; i++) {
 
395
  }
396
  }
397
  await walk(projectHandle, '');
398
+ // Todo lo que la búsqueda NO ha mirado se cuenta; un resultado incompleto que
399
+ // se presenta como completo es peor que no buscar.
400
+ const notes = [];
401
+ if (capped) notes.push(`búsqueda cortada (${results.length} resultados, ${checked} ficheros revisados) — puede haber más: afina con ext o un término más concreto`);
402
+ if (skipped.length) notes.push(`${skipped.length} fichero(s) NO buscados por tamaño (>${SEARCH_MAX_BYTES / 1e6} MB): ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '…' : ''} — mira dentro con code.read`);
403
+ const tail = notes.length ? '\n… ' + notes.join('. ') + '.' : '';
404
  if (!results.length)
405
+ return `Sin resultados para «${query}»` + (tail || '');
406
+ return results.join('\n') + tail;
 
 
407
  }
js/workspace.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // workspace.js — carpeta de trabajo, copia legible en disco e inventario.
2
+ //
3
+ // CORE compartido por Claw y Code. El módulo no sabe qué es una «app generada»
4
+ // ni una «conversación»: cada app le pasa un MANIFIESTO de almacenes y él sabe
5
+ // leerlos, medirlos, escribirlos a disco, reimportarlos y borrarlos.
6
+ //
7
+ // Por qué existe: todo vive en IndexedDB/localStorage, que el navegador puede
8
+ // desalojar (o el usuario borrar con «limpiar datos del sitio») sin avisar. Con
9
+ // una carpeta de trabajo, tu trabajo queda en ficheros normales que puedes ver,
10
+ // copiar y respaldar tú.
11
+ //
12
+ // Lo que NUNCA sale a disco en claro: el vault (secretos cifrados). Se marca
13
+ // `sensitive` en el manifiesto y se excluye salvo petición explícita.
14
+
15
+ const HANDLE_KEY = 'workspace-handle';
16
+ const ROOT = '.elffuss';
17
+
18
+ let cfg = null; // { app, ns, db, stores }
19
+ let dirHandle = null; // FileSystemDirectoryHandle de la carpeta de trabajo
20
+ let ready = false; // permiso concedido AHORA
21
+ let dirty = new Set();
22
+ let listeners = [];
23
+ let lastSaveAt = null, lastError = null;
24
+ let autosaveCfg = { enabled: false, debounceMs: 3000 };
25
+ let autosaveTimer = null;
26
+
27
+ export function init({ app, ns, db, stores }) {
28
+ cfg = { app, ns, db, stores: stores || [] };
29
+ }
30
+
31
+ export function supported() {
32
+ return {
33
+ pick: typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function',
34
+ persist: !!(navigator.storage && navigator.storage.persist),
35
+ };
36
+ }
37
+
38
+ const emit = () => { const s = statusSync(); listeners.forEach(f => { try { f(s); } catch { /* */ } }); };
39
+ export function onChange(fn) { listeners.push(fn); return () => { listeners = listeners.filter(f => f !== fn); }; }
40
+
41
+ function statusSync() {
42
+ return {
43
+ supported: supported(), folder: dirHandle ? dirHandle.name : null, ready,
44
+ dirty: [...dirty], lastSaveAt, lastError, autosave: autosaveCfg.enabled,
45
+ };
46
+ }
47
+ export function status() { return statusSync(); }
48
+
49
+ // ── carpeta de trabajo ───────────────────────────────────────────────────────
50
+ async function perm(handle, mode = 'readwrite') {
51
+ if (!handle?.queryPermission) return true; // OPFS u otros: dados
52
+ if ((await handle.queryPermission({ mode })) === 'granted') return true;
53
+ return false;
54
+ }
55
+
56
+ // En el arranque: recupera el handle SIN pedir permiso ni abrir diálogos.
57
+ export async function restore() {
58
+ if (!cfg) throw new Error('workspace.init() primero');
59
+ let h = null;
60
+ try { h = await cfg.db.get('kv', HANDLE_KEY); } catch { return null; }
61
+ if (!h) return null;
62
+ dirHandle = h;
63
+ ready = await perm(h);
64
+ emit();
65
+ return { name: h.name, ready, reason: ready ? null : 'prompt' };
66
+ }
67
+
68
+ // Elegir carpeta (requiere gesto de usuario).
69
+ export async function pick() {
70
+ if (!supported().pick) throw new Error('Este navegador no permite elegir una carpeta. Usa «Descargar copia» para guardar tu trabajo.');
71
+ const h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'elffuss-workspace', startIn: 'documents' });
72
+ dirHandle = h;
73
+ ready = true;
74
+ await cfg.db.set('kv', HANDLE_KEY, h);
75
+ emit();
76
+ return { name: h.name, ready: true };
77
+ }
78
+
79
+ // Reutilizar un handle que la app ya tiene (Code: la carpeta del proyecto).
80
+ export async function adopt(handle) {
81
+ if (!handle) throw new Error('sin handle');
82
+ dirHandle = handle;
83
+ ready = await perm(handle);
84
+ await cfg.db.set('kv', HANDLE_KEY, handle);
85
+ emit();
86
+ return { name: handle.name, ready };
87
+ }
88
+
89
+ // Re-conceder permiso tras recargar (requiere gesto de usuario).
90
+ export async function regrant() {
91
+ if (!dirHandle) throw new Error('no hay carpeta elegida');
92
+ const ok = dirHandle.requestPermission
93
+ ? (await dirHandle.requestPermission({ mode: 'readwrite' })) === 'granted'
94
+ : true;
95
+ ready = ok;
96
+ emit();
97
+ if (!ok) throw new Error('permiso denegado');
98
+ return { name: dirHandle.name, ready: true };
99
+ }
100
+
101
+ export async function forget() {
102
+ const name = dirHandle?.name || null;
103
+ dirHandle = null; ready = false;
104
+ try { await cfg.db.del('kv', HANDLE_KEY); } catch { /* */ }
105
+ emit();
106
+ return { forgot: name, deleted: false }; // nunca borra los ficheros del disco
107
+ }
108
+
109
+ export async function ensurePersistence() {
110
+ if (!navigator.storage?.persist) return { persisted: false, asked: false };
111
+ const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
112
+ if (already) return { persisted: true, asked: false };
113
+ const got = await navigator.storage.persist().catch(() => false);
114
+ return { persisted: got, asked: true };
115
+ }
116
+
117
+ // ── inventario: «ver qué hay» ────────────────────────────────────────────────
118
+ const sizeOf = v => { try { return new Blob([typeof v === 'string' ? v : JSON.stringify(v)]).size; } catch { return 0; } };
119
+
120
+ async function readStore(st) {
121
+ if (st.kind === 'ls') {
122
+ const items = [];
123
+ for (const k of st.keys || []) {
124
+ const v = localStorage.getItem(k);
125
+ if (v != null) items.push({ key: k, value: v });
126
+ }
127
+ return items;
128
+ }
129
+ if (st.kind === 'idb-store') { // store entero (apps, tasks…)
130
+ const [ks, vs] = await Promise.all([cfg.db.keys(st.store), cfg.db.all(st.store)]);
131
+ return ks.map((k, i) => ({ key: String(k), value: vs[i] }));
132
+ }
133
+ // 'idb-key': una clave concreta dentro de kv (history, conversations, skills…)
134
+ const v = await cfg.db.get(st.store || 'kv', st.key);
135
+ if (v == null) return [];
136
+ return Array.isArray(v) ? v.map((x, i) => ({ key: String(x.id ?? x.name ?? i), value: x })) : [{ key: st.key, value: v }];
137
+ }
138
+
139
+ export async function inventory() {
140
+ const out = [];
141
+ for (const st of cfg.stores) {
142
+ let items = [];
143
+ let err = null;
144
+ try { items = await readStore(st); } catch (e) { err = e.message; }
145
+ out.push({
146
+ id: st.id, label: st.label, icon: st.icon || '•', sensitive: !!st.sensitive,
147
+ count: items.length, bytes: items.reduce((a, b) => a + sizeOf(b.value), 0),
148
+ note: st.note || '', error: err,
149
+ });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export async function peek(id, { limit = 50 } = {}) {
155
+ const st = cfg.stores.find(s => s.id === id);
156
+ if (!st) throw new Error('almacén desconocido: ' + id);
157
+ const items = await readStore(st);
158
+ return {
159
+ id, truncated: items.length > limit,
160
+ items: items.slice(0, limit).map(it => ({
161
+ key: it.key, bytes: sizeOf(it.value),
162
+ preview: st.sensitive ? '(cifrado — no se muestra)'
163
+ : (typeof it.value === 'string' ? it.value : JSON.stringify(it.value)).slice(0, 240),
164
+ })),
165
+ };
166
+ }
167
+
168
+ // Espacio real del origen, incluidas TODAS las caches (los modelos son GB).
169
+ export async function storageReport() {
170
+ const est = navigator.storage?.estimate ? await navigator.storage.estimate().catch(() => ({})) : {};
171
+ const persisted = navigator.storage?.persisted ? await navigator.storage.persisted().catch(() => false) : false;
172
+ const out = { usage: est.usage || 0, quota: est.quota || 0, persisted, caches: [] };
173
+ if (typeof caches !== 'undefined') {
174
+ for (const name of await caches.keys().catch(() => [])) {
175
+ const c = await caches.open(name);
176
+ const reqs = await c.keys();
177
+ let bytes = 0;
178
+ for (const r of reqs) {
179
+ const res = await c.match(r);
180
+ const len = +(res?.headers.get('content-length') || 0);
181
+ bytes += len;
182
+ }
183
+ out.caches.push({ name, entries: reqs.length, bytes });
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ // ── guardar en disco ─────────────────────────────────────────────────────────
190
+ export function touch(id) {
191
+ dirty.add(id);
192
+ emit();
193
+ if (autosaveCfg.enabled) {
194
+ clearTimeout(autosaveTimer);
195
+ autosaveTimer = setTimeout(() => { save({ reason: 'auto' }).catch(() => {}); }, autosaveCfg.debounceMs);
196
+ }
197
+ }
198
+
199
+ export function autosave({ enabled, debounceMs }) {
200
+ autosaveCfg = { enabled: !!enabled, debounceMs: debounceMs || autosaveCfg.debounceMs };
201
+ try { localStorage.setItem(cfg.ns + '.workspace.autosave', enabled ? '1' : '0'); } catch { /* */ }
202
+ emit();
203
+ return autosaveCfg;
204
+ }
205
+ export function autosaveEnabled() {
206
+ try { return localStorage.getItem(cfg.ns + '.workspace.autosave') === '1'; } catch { return false; }
207
+ }
208
+
209
+ async function dirFor(path) { // crea .elffuss/<app>/<sub> y devuelve el handle
210
+ let d = dirHandle;
211
+ for (const part of path.split('/').filter(Boolean)) d = await d.getDirectoryHandle(part, { create: true });
212
+ return d;
213
+ }
214
+ async function writeFile(dir, name, text) {
215
+ const fh = await dir.getFileHandle(name, { create: true });
216
+ const w = await fh.createWritable();
217
+ await w.write(text);
218
+ await w.close();
219
+ }
220
+ const safeName = s => String(s).replace(/[^\w.\-]+/g, '_').slice(0, 60) || 'item';
221
+
222
+ export async function save({ only = null, reason = 'manual' } = {}) {
223
+ if (!dirHandle) throw new Error('no-folder');
224
+ if (!(await perm(dirHandle))) { ready = false; emit(); throw new Error('needs-regrant'); }
225
+ const targets = cfg.stores.filter(st => !st.sensitive && (only ? only.includes(st.id) : true));
226
+ const wrote = [], errors = [];
227
+ for (const st of targets) {
228
+ try {
229
+ const items = await readStore(st);
230
+ const base = `${ROOT}/${cfg.app}/${st.id}`;
231
+ if (st.files) { // uno por fichero (apps .html, skills .md)
232
+ const d = await dirFor(base);
233
+ for (const it of items) {
234
+ const body = st.files.body ? st.files.body(it.value) : JSON.stringify(it.value, null, 1);
235
+ await writeFile(d, safeName(st.files.name ? st.files.name(it.value, it.key) : it.key) + (st.files.ext || '.json'), body);
236
+ }
237
+ } else { // uno solo (historial, ajustes)
238
+ const d = await dirFor(`${ROOT}/${cfg.app}`);
239
+ await writeFile(d, st.id + '.json', JSON.stringify(items.map(i => i.value), null, 1));
240
+ }
241
+ wrote.push({ id: st.id, files: items.length });
242
+ dirty.delete(st.id);
243
+ } catch (e) { errors.push({ id: st.id, error: e.message }); }
244
+ }
245
+ lastSaveAt = Date.now();
246
+ lastError = errors.length ? errors[0].error : null;
247
+ // índice legible con lo que hay
248
+ try {
249
+ const d = await dirFor(`${ROOT}/${cfg.app}`);
250
+ await writeFile(d, 'index.json', JSON.stringify({ app: cfg.app, at: lastSaveAt, reason, wrote }, null, 1));
251
+ } catch { /* */ }
252
+ emit();
253
+ return { ok: !errors.length, at: lastSaveAt, wrote, errors };
254
+ }
255
+
256
+ // ── copia descargable (Safari/Firefox o backup manual) ───────────────────────
257
+ export async function bundle({ only = null, includeSensitive = false } = {}) {
258
+ const data = { app: cfg.app, at: Date.now(), stores: {} };
259
+ for (const st of cfg.stores) {
260
+ if (st.sensitive && !includeSensitive) continue;
261
+ if (only && !only.includes(st.id)) continue;
262
+ try { data.stores[st.id] = (await readStore(st)).map(i => i.value); } catch { /* */ }
263
+ }
264
+ return new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' });
265
+ }
266
+
267
+ // ── borrar ───────────────────────────────────────────────────────────────────
268
+ export async function wipe({ only = null, confirm } = {}) {
269
+ if (confirm !== 'BORRAR') throw new Error('confirmación requerida');
270
+ const removed = [];
271
+ for (const st of cfg.stores) {
272
+ if (only && !only.includes(st.id)) continue;
273
+ try {
274
+ if (st.kind === 'ls') { for (const k of st.keys || []) localStorage.removeItem(k); removed.push({ id: st.id }); }
275
+ else if (st.kind === 'idb-store') { for (const k of await cfg.db.keys(st.store)) await cfg.db.del(st.store, k); removed.push({ id: st.id }); }
276
+ else { await cfg.db.del(st.store || 'kv', st.key); removed.push({ id: st.id }); }
277
+ } catch (e) { removed.push({ id: st.id, error: e.message }); }
278
+ }
279
+ emit();
280
+ return { removed };
281
+ }
282
+
283
+ export async function clearCaches({ names } = {}) {
284
+ if (typeof caches === 'undefined') return { deleted: [] };
285
+ const target = names || await caches.keys();
286
+ const deleted = [];
287
+ for (const n of target) if (await caches.delete(n)) deleted.push(n);
288
+ return { deleted };
289
+ }
lab.html ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Elffuss Lab — banco de pruebas</title>
7
+ <link rel="icon" href="data:,">
8
+ <link rel="stylesheet" href="css/lab.css">
9
+ </head>
10
+ <body>
11
+
12
+ <header>
13
+ <h1>Elffuss <span>Lab</span></h1>
14
+ <div class="sub">banco de pruebas · algoritmos y compresión de contexto</div>
15
+ <div class="spacer"></div>
16
+ <div class="led" id="led-model"><i></i><span>modelo: sin cargar</span></div>
17
+ <div class="led" id="led-run"><i></i><span>en reposo</span></div>
18
+ <a class="sub" href="index.html">← volver al IDE</a>
19
+ </header>
20
+
21
+ <main>
22
+ <!-- ══════════ IZQUIERDA: configuración del experimento ══════════ -->
23
+ <section>
24
+ <div class="panel">
25
+ <h2>Cerebro <span class="tag" id="brain-tag">—</span></h2>
26
+ <div class="body">
27
+ <div class="seg" id="brains"></div>
28
+ <p class="hint">El banco usa el mismo proveedor que el IDE. «Reglas» no
29
+ gasta GPU y sirve para validar el arnés; los benchmarks que exigen
30
+ generación real necesitan un modelo de verdad.</p>
31
+ <div class="row" style="margin-top:10px">
32
+ <button class="btn" id="btn-load">Cargar cerebro</button>
33
+ <span class="sub" id="load-state"></span>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="panel">
39
+ <h2>Algoritmo de generación <span class="tag" id="algo-tag">directo</span></h2>
40
+ <div class="body">
41
+ <div class="seg" id="algos"></div>
42
+ <div class="group-title">parámetros</div>
43
+ <div class="knobs" id="algo-knobs"></div>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="panel">
48
+ <h2>Compresión de contexto (ACER) <span class="tag" id="ctx-tag">—</span></h2>
49
+ <div class="body">
50
+ <div class="row" style="margin-bottom:8px">
51
+ <button class="btn ghost" id="ctx-all">Todo ON</button>
52
+ <button class="btn ghost" id="ctx-none">Todo OFF</button>
53
+ <button class="btn ghost" id="ctx-def">Por defecto</button>
54
+ </div>
55
+ <div id="ctx-knobs"></div>
56
+ <p class="hint">Cada perilla es la del motor real (<code>acer-core.js</code>).
57
+ Lo que marques aquí es lo que se le pasa a <code>packHistoryACER</code>
58
+ en el banco de contexto y lo que se guarda al lanzar el IDE.</p>
59
+ </div>
60
+ </div>
61
+ </section>
62
+
63
+ <!-- ══════════ DERECHA: ejecución y resultados ══════════ -->
64
+ <section>
65
+ <div class="panel">
66
+ <h2>Banco de pruebas <span class="tag" id="bench-tag">—</span></h2>
67
+ <div class="body">
68
+ <div class="seg" id="benches"></div>
69
+ <div class="row" style="margin-top:12px">
70
+ <button class="btn primary" id="btn-run">▶ Lanzar benchmark</button>
71
+ <button class="btn" id="btn-stop" disabled>■ Parar</button>
72
+ <button class="btn" id="btn-save">★ Guardar en el ranking</button>
73
+ <button class="btn ghost" id="btn-launch">Lanzar el IDE con esta configuración</button>
74
+ </div>
75
+ <div class="bar" style="margin-top:12px"><i id="prog"></i></div>
76
+ <div class="sub" id="prog-txt" style="margin-top:6px">—</div>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="panel">
81
+ <h2>Métricas en vivo</h2>
82
+ <div class="body"><div class="tiles" id="tiles"></div></div>
83
+ </div>
84
+
85
+ <div class="panel">
86
+ <h2>Resultados por caso</h2>
87
+ <div class="body" style="padding:0">
88
+ <table><thead id="res-head"></thead><tbody id="res-body"></tbody></table>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="panel" id="arena-panel" hidden>
93
+ <h2>Arena <span class="tag">lo generado, ejecutándose</span></h2>
94
+ <div class="body"><iframe class="arena" id="arena" sandbox="allow-scripts allow-same-origin"></iframe></div>
95
+ </div>
96
+
97
+ <div class="panel">
98
+ <h2>Ranking local <span class="tag" id="rank-tag">—</span></h2>
99
+ <div class="body" style="padding:0">
100
+ <table>
101
+ <thead><tr><th>#</th><th>score</th><th>configuración</th><th>cuándo</th><th></th></tr></thead>
102
+ <tbody id="rank-body"></tbody>
103
+ </table>
104
+ </div>
105
+ <div class="body" style="border-top:1px solid var(--line)">
106
+ <div class="row">
107
+ <span class="pill">todo en esta máquina · no se envía nada</span>
108
+ <div class="spacer" style="flex:1"></div>
109
+ <button class="btn ghost" id="btn-clear">Borrar historial</button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <div class="panel">
115
+ <h2>Consola</h2>
116
+ <div class="body"><div class="log" id="log"></div></div>
117
+ </div>
118
+ </section>
119
+ </main>
120
+
121
+ <script type="module" src="js/lab.js"></script>
122
+ </body>
123
+ </html>
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }