KikoCis commited on
Commit
b30b7c5
·
verified ·
1 Parent(s): f16878d

Elffuss Code — browser IDE with a local AI agent (WebGPU)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ og.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,48 @@
1
  ---
2
  title: Elffuss Code
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Elffuss Code
3
+ emoji: 🧑‍💻
4
+ colorFrom: purple
5
+ colorTo: pink
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ license: apache-2.0
10
+ short_description: A VS Code-style IDE with a local AI agent, in-browser
11
+ tags:
12
+ - webgpu
13
+ - transformers.js
14
+ - local-llm
15
+ - agent
16
+ - code
17
+ - gemma
18
  ---
19
 
20
+ # 🧑‍💻 Elffuss Code a VS Code-style IDE with an AI soul, 100% in your browser
21
+
22
+ Open a local folder and a full web IDE unfolds around it — real Monaco (the VS Code editor),
23
+ a file tree, tabs — with an AI agent that **reads, searches and edits your project for real**.
24
+ The model runs on **your** GPU via WebGPU. Your code never leaves your machine.
25
+
26
+ > ⚠️ **Open it full-screen** ([direct link](https://kikocis-elffuss-code.static.hf.space)) — the
27
+ > File System Access API (opening a local folder) is blocked inside the embedded iframe.
28
+
29
+ ![demo](https://utopiaia.com/demos/elffuss/elffuss-code-demo.gif)
30
+
31
+ In the demo a **small model running entirely in the browser** reads `calc.py`, finds a real
32
+ bug and fixes it with a minimal edit — genuine tool-calling, not autocomplete.
33
+
34
+ ## Why it's different
35
+
36
+ - **Your code stays local.** The folder is opened via the File System Access API — no upload, no account.
37
+ - **The model runs on your GPU** (WebGPU, Gemma-4 via LiteRT-LM; ONNX/transformers.js + Qwen2.5-0.5B as fallback).
38
+ - **Real tool-calling**: `code.tree`, `code.read`, `code.search`, `code.edit`, `code.write` — you watch every call.
39
+ - **Real editor**: Monaco, file tree, tabs, `Ctrl+S`.
40
+ - **Zero install, zero build.** Vanilla ES modules.
41
+ - External providers (OpenAI, Anthropic, Ollama) are **opt-in**; keys stay in your browser.
42
+
43
+ ## Links
44
+
45
+ - 🌐 Full app: <https://elffuss-code.utopiaia.com>
46
+ - 💻 Source (Apache-2.0): <https://github.com/KikoCis/elffuss-code>
47
+ - ✳️ Sibling project — Elffuss Claw (agentic OS): <https://github.com/KikoCis/elffuss-claw>
48
+ - 🧬 Shared core: <https://github.com/KikoCis/elffuss>
css/code.css ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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; }
8
+ body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); overflow: hidden; }
9
+
10
+ /* ---------- landing ---------- */
11
+ #landing {
12
+ position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
13
+ background: radial-gradient(1100px 700px at 50% 30%, #221740, #0b0d12 72%); text-align: center;
14
+ }
15
+ #landing[hidden] { display: none; }
16
+ #landing canvas.gl { position: absolute; inset: 0; width: 100%; height: 100%; }
17
+ #landing .l-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px; }
18
+ #landing .hero { width: 170px; filter: drop-shadow(0 12px 40px rgba(124,92,255,.35)); animation: float 5s ease-in-out infinite; }
19
+ @keyframes float { 50% { transform: translateY(-9px); } }
20
+ #landing h1 { margin: 4px 0 0; font-size: 2rem; }
21
+ #landing h1 b { background: linear-gradient(90deg, var(--accent), var(--accent2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
22
+ #landing .tag { color: var(--muted); margin: 0; line-height: 1.5; }
23
+ #landing .tag b { color: var(--accent); }
24
+ #open-project {
25
+ margin-top: 12px; padding: 14px 34px; font-size: 1.08rem; cursor: pointer; color: #fff;
26
+ border: none; border-radius: 999px; background: linear-gradient(135deg, var(--accent), var(--accent2));
27
+ box-shadow: 0 8px 30px rgba(255,77,141,.35);
28
+ }
29
+ #open-project:hover { filter: brightness(1.12); }
30
+ #landing .ghost, .ghost {
31
+ background: none; color: var(--muted); border: 1px solid var(--line);
32
+ border-radius: 8px; padding: 8px 14px; cursor: pointer;
33
+ }
34
+ #landing .mini { font-size: .75rem; color: #5a6480; }
35
+ .resume-clone { display: flex; align-items: center; gap: 8px; margin-top: 8px; background: rgba(124,92,255,.12); border: 1px solid var(--accent2); border-radius: 999px; padding: 7px 8px 7px 16px; font-size: .78rem; color: var(--fg); }
36
+ #resume-clone-btn { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 999px; padding: 6px 14px; font-size: .76rem; cursor: pointer; }
37
+ .clone-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
38
+ .clone-or { font-size: .72rem; color: #5a6480; }
39
+ #clone-url { width: 300px; max-width: 60vw; background: var(--bg3); border: 1px solid var(--line); color: var(--fg); border-radius: 999px; padding: 9px 16px; font-size: .82rem; }
40
+ #clone-url:focus { outline: none; border-color: var(--accent2); }
41
+ #clone-btn { background: none; color: var(--fg); border: 1px solid var(--accent2); border-radius: 999px; padding: 9px 16px; font-size: .82rem; cursor: pointer; white-space: nowrap; }
42
+ #clone-btn:hover { background: rgba(124,92,255,.15); }
43
+ #clone-btn:disabled { opacity: .5; cursor: default; }
44
+ .clone-status { min-height: 1.1em; }
45
+ .clone-status.err { color: #ff6b8b; }
46
+ @media (max-width: 560px) { .clone-row { flex-direction: column; } #clone-url { width: 78vw; } }
47
+
48
+ /* ---------- IDE ---------- */
49
+ #ide { display: flex; flex-direction: column; height: 100vh; }
50
+ #ide[hidden] { display: none; } /* el display:flex le ganaba a [hidden] → el IDE asomaba tras la landing */
51
+ header {
52
+ display: flex; justify-content: space-between; align-items: center;
53
+ padding: 6px 12px; background: var(--bg2); border-bottom: 1px solid var(--line);
54
+ }
55
+ .brand { display: flex; align-items: center; gap: 8px; }
56
+ .brand img { width: 26px; height: 26px; }
57
+ .proj { color: var(--muted); font-size: .85rem; }
58
+ .controls { display: flex; align-items: center; gap: 8px; font-size: .9rem; }
59
+ select { background: var(--bg3); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; }
60
+ .dot { width: 9px; height: 9px; border-radius: 50%; }
61
+ .dot.off { background: #4a5062; } .dot.on { background: #3fb970; }
62
+ .dot.loading { background: #e5a53a; animation: pulse 1.2s infinite; }
63
+ @keyframes pulse { 50% { opacity: .35; } }
64
+
65
+ #settings-panel { display: flex; gap: 10px; padding: 10px 12px; background: var(--bg2); border-bottom: 1px solid var(--line); overflow-x: auto; position: relative; }
66
+ #settings-panel[hidden] { display: none; } /* ¡el display:flex le ganaba a hidden! */
67
+ #settings-close { position: absolute; top: 6px; right: 10px; background: none; border: none; color: var(--muted); font-size: 1.1rem; cursor: pointer; }
68
+ #settings-close:hover { color: var(--accent); }
69
+ #settings-panel .card { background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 6px; font-size: .8rem; min-width: 200px; }
70
+ #settings-panel input[type=text], #settings-panel input:not([type]) , #settings-panel input[type=password] { background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; font-size: .78rem; }
71
+
72
+ main { flex: 1; display: grid; grid-template-columns: 46px 235px minmax(0, 1fr) minmax(440px, 34%); grid-template-rows: minmax(0, 1fr); min-height: 0; overflow: hidden; }
73
+ body.hide-tree main { grid-template-columns: 46px 0 minmax(0, 1fr) minmax(440px, 34%); }
74
+ body.hide-tree #sidebar { display: none; }
75
+
76
+ /* barra de actividad estilo VS Code */
77
+ #activity {
78
+ display: flex; flex-direction: column; align-items: center; gap: 4px;
79
+ background: #0d0f15; border-right: 1px solid var(--line); padding: 8px 0;
80
+ }
81
+ #activity button {
82
+ width: 44px; height: 42px; background: none; border: none; cursor: pointer;
83
+ color: #6b7391; display: flex; align-items: center; justify-content: center;
84
+ border-left: 2px solid transparent;
85
+ }
86
+ #activity button:hover { color: var(--fg); }
87
+ #activity button.on { color: var(--fg); border-left-color: var(--accent); }
88
+ #activity img { width: 26px; height: 26px; margin-top: auto; margin-bottom: 6px; opacity: .85; }
89
+
90
+ /* explorador */
91
+ #sidebar { display: flex; flex-direction: column; min-height: 0; background: var(--bg2); border-right: 1px solid var(--line); }
92
+ #explorer-head {
93
+ padding: 8px 12px 6px; font-size: .68rem; letter-spacing: .08em; color: var(--muted);
94
+ display: flex; gap: 8px; align-items: baseline;
95
+ }
96
+ #explorer-head span { color: var(--fg); font-weight: 700; }
97
+ #tree { flex: 1; overflow: auto; padding: 0 4px 8px; font-size: .82rem; }
98
+ #tree ul { list-style: none; margin: 0; padding-left: 10px; border-left: 1px solid #1d2230; }
99
+ #tree > ul { border-left: none; padding-left: 4px; }
100
+ #tree summary { cursor: pointer; color: #c8cede; padding: 2px 0; display: flex; align-items: center; gap: 4px; user-select: none; }
101
+ #tree summary::-webkit-details-marker, #tree summary::marker { display: none; content: ''; }
102
+ #tree details[open] > summary .chevron { transform: rotate(90deg); transform-origin: 8px 8px; }
103
+ #tree .chevron { transition: transform .12s; }
104
+ #tree summary:hover, #tree .file:hover { color: #fff; background: #ffffff0a; border-radius: 4px; }
105
+ #tree .file { cursor: pointer; display: flex; align-items: center; gap: 5px; padding: 2px 0 2px 16px; color: #b8c0d4; }
106
+ .fico { flex: 0 0 auto; vertical-align: -3px; }
107
+
108
+ #editor-wrap { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
109
+ #tabs-bar { display: flex; background: #0d0f15; border-bottom: 1px solid var(--line); overflow-x: auto; min-height: 34px; }
110
+ .tab { display: flex; align-items: center; gap: 6px; padding: 6px 10px; font-size: .8rem; color: var(--muted); border-right: 1px solid var(--line); cursor: pointer; white-space: nowrap; }
111
+ .tab.active { background: var(--bg); color: var(--fg); box-shadow: inset 0 2px 0 var(--accent); }
112
+ .tab.tab-preview .tab-name { font-style: italic; }
113
+ .tab .tab-ico { display: flex; }
114
+ .tab b { cursor: pointer; opacity: .55; width: 14px; text-align: center; }
115
+ .tab b:hover { opacity: 1; color: var(--accent); }
116
+ .tab b.dirty { opacity: 1; color: var(--fg); }
117
+ .tab b.dirty:hover::before { content: ''; }
118
+ #editor { flex: 1; min-height: 0; }
119
+ #editor-empty { position: absolute; inset: 34px 0 22px 0; display: none; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--bg); color: var(--muted); z-index: 2; text-align: center; }
120
+ #editor-empty img { width: 84px; opacity: .5; }
121
+ #editor-empty p { font-size: .9rem; line-height: 1.5; }
122
+
123
+ /* la elfa de la barra: animada cuando el cerebro trabaja (invita a hacer clic) */
124
+ #activity img { transition: transform .2s; cursor: pointer; }
125
+ #activity img:hover { transform: scale(1.12); }
126
+ #activity img.working { animation: elfWorking 1.5s ease-in-out infinite; }
127
+ @keyframes elfWorking {
128
+ 0%, 100% { transform: scale(1); filter: drop-shadow(0 0 3px rgba(124,92,255,.5)); }
129
+ 50% { transform: scale(1.14); filter: drop-shadow(0 0 12px rgba(255,77,141,.95)); }
130
+ }
131
+
132
+ /* ── Mente de Elffuss (cerebro CEO · mundo trance) ─────────────────────── */
133
+ #mind-overlay { position: fixed; inset: 0; z-index: 300; background: radial-gradient(1200px 800px at 50% 45%, #1a0a2e, #05030c 78%); overflow: hidden; }
134
+ #mind-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
135
+ #mind-title { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); text-align: center; font-size: 1.1rem; font-weight: 800; letter-spacing: .18em; color: #fff; text-shadow: 0 0 22px #ff4d8d; pointer-events: none; }
136
+ #mind-title span { display: block; font-size: .66rem; font-weight: 500; letter-spacing: .06em; color: #b9a7ff; margin-top: 4px; text-shadow: none; }
137
+ #mind-close { position: absolute; top: 14px; right: 16px; z-index: 6; background: rgba(255,77,141,.14); border: 1px solid #ff4d8d; color: #ffd7e6; border-radius: 999px; padding: 7px 14px; font-size: .8rem; cursor: pointer; backdrop-filter: blur(6px); }
138
+ #mind-close:hover { background: rgba(255,77,141,.28); }
139
+ #mind-history { position: absolute; top: 14px; right: 182px; z-index: 6; background: rgba(73,232,221,.14); border: 1px solid #49e8ff; color: #bfeef6; border-radius: 999px; padding: 7px 12px; font-size: .78rem; cursor: pointer; backdrop-filter: blur(6px); }
140
+ #mind-history:hover { background: rgba(73,232,221,.28); }
141
+ #mind-playstop { position: absolute; top: 14px; right: 278px; z-index: 6; background: rgba(63,185,112,.16); border: 1px solid #3fb970; color: #b7f2cf; border-radius: 999px; padding: 7px 12px; font-size: .78rem; cursor: pointer; backdrop-filter: blur(6px); }
142
+ #mind-playstop:hover { background: rgba(63,185,112,.3); }
143
+ #mind-playstop.paused { background: rgba(255,180,70,.16); border-color: #ffb446; color: #ffe0b0; }
144
+ #mind-playstop.paused:hover { background: rgba(255,180,70,.3); }
145
+ #mind-config { position: absolute; top: 14px; right: 96px; z-index: 6; background: rgba(124,92,255,.16); border: 1px solid #7c5cff; color: #d8ccff; border-radius: 999px; padding: 7px 12px; font-size: .78rem; cursor: pointer; backdrop-filter: blur(6px); }
146
+ #mind-config:hover { background: rgba(124,92,255,.3); }
147
+ /* leyenda de perfiles: fija, no flota (siempre legible) */
148
+ #mind-legend { position: absolute; top: 60px; left: 14px; z-index: 5; display: flex; flex-direction: column; gap: 5px; background: rgba(8,5,18,.5); border: 1px solid rgba(124,92,255,.3); border-radius: 10px; padding: 8px 12px; backdrop-filter: blur(6px); pointer-events: auto; }
149
+ .ml-item { font-size: .68rem; color: #cfe8f0; display: flex; align-items: center; gap: 7px; cursor: pointer; padding: 2px 4px; border-radius: 5px; }
150
+ .ml-item:hover { background: rgba(255,255,255,.08); }
151
+ .ml-sw { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: none; }
152
+ /* solo etiquetas de propuestas forjadas flotan ancladas al mundo */
153
+ #mind-anchors { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
154
+ .mind-node-label { position: absolute; font-size: .6rem; color: #ffd7e6; background: rgba(255,77,141,.18); border: 1px solid rgba(255,77,141,.5); border-radius: 6px; padding: 1px 6px; pointer-events: none; white-space: nowrap; text-shadow: 0 0 8px #ff4d8d; }
155
+ /* historial completo: TODO lo que ha llegado, sin recortar */
156
+ #mind-log { position: absolute; top: 0; right: -380px; width: 360px; height: 100%; z-index: 8; display: flex; flex-direction: column; background: rgba(6,4,14,.92); border-left: 1px solid rgba(73,232,221,.35); backdrop-filter: blur(10px); transition: right .25s ease; }
157
+ #mind-log.show { right: 0; }
158
+ .ml-head { display: flex; align-items: center; padding: 12px 14px; font-size: .82rem; font-weight: 700; color: #bfeef6; border-bottom: 1px solid rgba(73,232,221,.25); }
159
+ .ml-head button { margin-left: auto; background: none; border: none; color: #7fd9d0; cursor: pointer; font-size: .9rem; }
160
+ #mind-log-body { flex: 1; overflow-y: auto; padding: 8px 10px; }
161
+ .ml-row { font-size: .68rem; line-height: 1.6; padding: 3px 4px; border-radius: 5px; display: flex; gap: 6px; align-items: baseline; }
162
+ .ml-row:hover { background: rgba(255,255,255,.04); }
163
+ .ml-row .ml-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
164
+ .ml-row .ml-t { color: #dfeef2; word-break: break-word; }
165
+ /* panel flotante con el .md del pensamiento (clic en un nodo) */
166
+ #mind-panel { position: absolute; top: 70px; right: 16px; width: min(420px, 40vw); max-height: 70vh; z-index: 6; display: none; flex-direction: column; background: rgba(8,5,18,.9); border: 1px solid #7c5cff; border-radius: 12px; overflow: hidden; box-shadow: 0 12px 50px rgba(0,0,0,.6); }
167
+ #mind-panel.show { display: flex; }
168
+ #mind-panel .mp-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: .74rem; color: #d8ccff; background: rgba(124,92,255,.14); border-bottom: 1px solid rgba(124,92,255,.3); }
169
+ #mind-panel .mp-head button { margin-left: auto; background: none; border: none; color: #b9a7ff; cursor: pointer; font-size: .9rem; }
170
+ #mind-panel .mp-body { flex: 1; overflow: auto; margin: 0; padding: 12px; font-size: .78rem; line-height: 1.55; color: #cfe8f0; }
171
+ #mind-panel .mp-body h1, #mind-panel .mp-body h2, #mind-panel .mp-body h3 { color: #fff; margin: 12px 0 6px; }
172
+ #mind-panel .mp-body code { background: rgba(124,92,255,.18); padding: 1px 5px; border-radius: 4px; font-size: .82em; }
173
+ #mind-panel .mp-body pre { background: #0b0716; border: 1px solid rgba(124,92,255,.3); border-radius: 8px; padding: 8px; overflow-x: auto; }
174
+ #mind-panel .mp-body ul, #mind-panel .mp-body ol { padding-left: 1.2em; }
175
+ .mp-actions { display: flex; gap: 8px; padding: 0 12px 12px; }
176
+ .mp-exec { flex: 1; background: linear-gradient(135deg, #ff4d8d, #7c5cff); border: none; color: #fff; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .78rem; font-weight: 600; }
177
+ .mp-open { flex: 1; background: rgba(124,92,255,.15); border: 1px solid #7c5cff; color: #d8ccff; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .78rem; }
178
+ /* panel de configuración del cerebro */
179
+ #mind-cfg { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); width: min(520px, 92vw); z-index: 7; display: none; flex-direction: column; gap: 6px; background: rgba(8,5,18,.94); border: 1px solid #7c5cff; border-radius: 14px; padding: 14px 16px; box-shadow: 0 16px 60px rgba(0,0,0,.6); }
180
+ #mind-cfg.show { display: flex; }
181
+ .cfg-head { display: flex; align-items: center; font-size: .9rem; font-weight: 700; color: #fff; margin-bottom: 4px; }
182
+ .cfg-head button { margin-left: auto; background: none; border: none; color: #b9a7ff; cursor: pointer; }
183
+ .cfg-notif { font-size: .68rem; color: #9fb; background: rgba(63,185,112,.1); border: 1px solid rgba(63,185,112,.3); border-radius: 8px; padding: 6px 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
184
+ .bridge-card #br-token, .bridge-card #br-folder { background: var(--bg); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; padding: 6px 9px; font-size: .78rem; width: 100%; }
185
+ .bridge-card details { color: var(--muted); }
186
+ .bridge-card details summary::marker { color: var(--accent2); }
187
+ #fireworks-fx { position: fixed; inset: 0; z-index: 999; pointer-events: none; }
188
+ .cfg-notif-btn { background: #3fb970; border: none; color: #06180d; border-radius: 6px; padding: 3px 10px; font-size: .68rem; font-weight: 700; cursor: pointer; }
189
+ #mind-cfg label { font-size: .68rem; color: #b9a7ff; margin-top: 4px; }
190
+ #mind-cfg textarea, #mind-cfg input:not([type=color]) { background: #0b0716; border: 1px solid rgba(124,92,255,.4); color: #e8ebf4; border-radius: 8px; padding: 8px 10px; font-size: .78rem; font-family: inherit; resize: vertical; }
191
+ #cfg-profiles { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; }
192
+ .cfg-prof { display: grid; grid-template-columns: 30px 1fr 1.4fr 26px; gap: 6px; align-items: center; }
193
+ .cfg-prof input[type=color] { width: 30px; height: 30px; padding: 0; border: 1px solid rgba(124,92,255,.4); border-radius: 6px; background: none; cursor: pointer; }
194
+ .cfg-prof input[type=text] { background: #0b0716; border: 1px solid rgba(124,92,255,.4); color: #e8ebf4; border-radius: 6px; padding: 6px 8px; font-size: .72rem; }
195
+ .cp-del { background: none; border: none; color: #ff8fb0; cursor: pointer; font-size: .8rem; }
196
+ .cfg-add { align-self: flex-start; background: rgba(124,92,255,.15); border: 1px dashed rgba(124,92,255,.5); color: #d8ccff; border-radius: 8px; padding: 6px 12px; font-size: .74rem; cursor: pointer; margin-top: 4px; }
197
+ .cfg-actions { display: flex; gap: 8px; margin-top: 8px; }
198
+ .cfg-ghost { flex: 1; background: rgba(73,232,221,.14); border: 1px solid #49e8ff; color: #bfeef6; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .78rem; }
199
+ .cfg-save { flex: 1; margin-top: 0; background: linear-gradient(135deg, #ff4d8d, #7c5cff); border: none; color: #fff; border-radius: 8px; padding: 9px; cursor: pointer; font-size: .8rem; font-weight: 600; }
200
+ .cfg-note { font-size: .64rem; color: #8a7bb8; margin-top: 4px; }
201
+ .mind-music { position: absolute; bottom: 14px; right: 14px; z-index: 5; width: 300px; border-radius: 12px; overflow: hidden; border: 1px solid rgba(255,77,141,.4); box-shadow: 0 8px 30px rgba(0,0,0,.5); }
202
+ .mind-music-cap { font-size: .62rem; color: #ffd7e6; background: rgba(10,6,24,.8); padding: 4px 8px; text-align: center; }
203
+ @media (max-width: 820px) { #mind-log { width: 88vw; right: -90vw; } .mind-music { width: 180px; } }
204
+
205
+ /* menú contextual del árbol y de las pestañas (botón derecho) */
206
+ #tree-menu, #tab-menu { background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 4px; min-width: 168px; box-shadow: 0 10px 34px rgba(0,0,0,.5); display: flex; flex-direction: column; }
207
+ .tm-item { text-align: left; background: none; border: none; color: var(--fg); font-size: .82rem; padding: 6px 10px; border-radius: 6px; cursor: pointer; }
208
+ .tm-item:hover { background: var(--bg3); }
209
+ .tm-item.danger { color: #ff6b8b; }
210
+ .tm-item.danger:hover { background: #3a1420; }
211
+ .tm-sep { height: 1px; background: var(--line); margin: 4px 2px; }
212
+
213
+ /* vista previa de markdown (pestaña en modo Vista previa, en vez del código fuente) */
214
+ #md-preview-pane { position: absolute; inset: 34px 0 22px 0; overflow-y: auto; background: var(--bg); padding: 18px 26px; z-index: 2; }
215
+ #md-preview-pane h1, #md-preview-pane h2, #md-preview-pane h3 { color: #fff; margin: 18px 0 8px; }
216
+ #md-preview-pane code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: .88em; }
217
+ #md-preview-pane pre { background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; padding: 10px; overflow-x: auto; }
218
+ #md-preview-pane a { color: var(--accent2); }
219
+ #md-preview-pane.html-preview { padding: 0; overflow: hidden; }
220
+ #md-preview-pane.html-preview iframe { display: block; width: 100%; height: 100%; border: none; background: #fff; }
221
+
222
+ /* panel del terminal (abajo del editor, estilo VS Code) */
223
+ #terminal-panel { display: flex; flex-direction: column; height: 260px; flex: 0 0 auto; background: #0b0d12; border-top: 1px solid var(--line); position: relative; }
224
+ #terminal-panel[hidden] { display: none; }
225
+ #term-resize { position: absolute; top: -3px; left: 0; right: 0; height: 6px; cursor: ns-resize; z-index: 3; }
226
+ #term-resize:hover { background: var(--accent2); opacity: .4; }
227
+ #term-head { display: flex; align-items: center; gap: 10px; padding: 3px 10px; background: #0d0f15; border-bottom: 1px solid var(--line); }
228
+ #term-head .term-title { font-size: .68rem; letter-spacing: .08em; color: var(--fg); font-weight: 700; }
229
+ #term-caps { font-size: .68rem; color: var(--muted); flex: 1; }
230
+ .term-btn { background: none; border: none; color: var(--muted); font-size: .72rem; cursor: pointer; padding: 2px 6px; border-radius: 5px; }
231
+ .term-btn:hover { color: var(--fg); background: var(--bg3); }
232
+ #terminal-host { flex: 1; min-height: 0; padding: 4px 6px; overflow: hidden; }
233
+ #terminal-host .xterm { height: 100%; }
234
+
235
+ #statusbar {
236
+ padding: 3px 12px; font-size: .74rem; color: #fff; min-height: 22px;
237
+ background: linear-gradient(90deg, #3d2a68, #4c2050);
238
+ border-top: 1px solid var(--line); display: flex; align-items: center; gap: 14px;
239
+ }
240
+
241
+ /* carga del modelo: visible desde el primer segundo, sobre landing e IDE */
242
+ #model-progress {
243
+ position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
244
+ z-index: 120; display: flex; align-items: center; gap: 10px;
245
+ width: min(440px, 92vw); padding: 10px 14px;
246
+ background: var(--bg3); border: 1px solid var(--accent2); border-radius: 14px;
247
+ box-shadow: 0 10px 40px rgba(124, 92, 255, .35);
248
+ }
249
+ #model-progress[hidden] { display: none; }
250
+ #model-progress img { width: 34px; height: 34px; }
251
+ #model-progress .mp-body { flex: 1; display: flex; flex-direction: column; gap: 5px; }
252
+ #model-progress span { font-size: .82rem; color: var(--fg); }
253
+ #model-progress .track { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; }
254
+ #model-bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width .25s; }
255
+
256
+ /* ---------- chat ---------- */
257
+ #chat { display: flex; flex-direction: column; border-left: 1px solid var(--line); background: var(--bg2); min-height: 0; }
258
+ #chat-log { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
259
+
260
+ /* pestañas de conversación (varias en paralelo — cerrar NO borra, solo quita de la vista) */
261
+ #conv-tabs { display: flex; flex: 0 0 auto; background: #0d0f15; border-bottom: 1px solid var(--line); overflow-x: auto; min-height: 34px; align-items: center; }
262
+ #conv-tabs .tab-add { flex: 0 0 auto; background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; padding: 4px 12px; align-self: stretch; }
263
+ #conv-tabs .tab-add:hover { color: var(--fg); background: var(--bg3); }
264
+ .tab.tab-busy .tab-name::after { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-left: 6px; vertical-align: middle; animation: tab-busy-pulse 1s ease-in-out infinite; }
265
+ @keyframes tab-busy-pulse { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
266
+
267
+ /* panel de historial de conversaciones (todas las guardadas, no solo abiertas) */
268
+ #history-panel { position: fixed; top: 46px; right: 12px; width: 320px; max-height: 60vh; overflow-y: auto; background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 34px rgba(0,0,0,.5); z-index: 90; display: flex; flex-direction: column; }
269
+ #history-panel[hidden] { display: none; }
270
+ .hp-head { display: flex; align-items: center; padding: 10px 12px; font-size: .85rem; font-weight: 700; color: var(--fg); border-bottom: 1px solid var(--line); }
271
+ .hp-head button { margin-left: auto; background: none; border: none; color: var(--muted); cursor: pointer; font-size: .9rem; }
272
+ #hp-list { overflow-y: auto; padding: 4px; }
273
+ .hp-empty { padding: 16px; color: var(--muted); font-size: .8rem; text-align: center; }
274
+ .hp-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
275
+ .hp-row:hover { background: var(--bg3); }
276
+ .hp-title { font-size: .82rem; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
277
+ .hp-date { font-size: .68rem; color: var(--muted); white-space: nowrap; }
278
+ .hp-del { background: none; border: none; cursor: pointer; font-size: .8rem; opacity: .6; }
279
+ .hp-del:hover { opacity: 1; }
280
+ .msg { padding: 8px 11px; border-radius: 11px; font-size: .85rem; line-height: 1.45; white-space: pre-wrap; word-break: break-word; max-width: 95%; }
281
+ .msg.sys { background: linear-gradient(135deg, #1c1430, #14202e); border: 1px solid var(--line); }
282
+ .msg.user { background: var(--accent2); color: #fff; align-self: flex-end; }
283
+ .msg.assistant { background: var(--bg3); border: 1px solid var(--line); align-self: flex-start; }
284
+ .msg.err { border-color: #a03050; }
285
+ /* herramientas estilo plugin de Claude Code: ● nombre + args + resultado plegable */
286
+ .msg.tool { display: flex; align-items: center; gap: 7px; background: none; padding: 2px 6px; font-size: .8rem; }
287
+ .msg.tool .tdot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: 0 0 auto; }
288
+ .msg.tool b { color: var(--fg); font-weight: 600; font-family: ui-monospace, monospace; font-size: .76rem; }
289
+ .msg.tool .targ { color: var(--muted); font-family: ui-monospace, monospace; font-size: .74rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
290
+ .msg.tool-result { color: var(--muted); font-size: .74rem; background: none; padding: 0 6px 0 19px; }
291
+ .msg.tool-result summary { cursor: pointer; list-style: none; }
292
+ .msg.tool-result summary::before { content: '⎿ '; color: #4a5266; }
293
+ .msg.tool-result pre { margin: 4px 0 0; background: #0d1117; border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto; font-size: .72rem; }
294
+ .msg.tool-result.err summary { color: #e5a53a; }
295
+ .msg.user.queued { opacity: .55; }
296
+ .msg.user.queued::after { content: ' ⏳'; }
297
+ .msg.thinking { background: var(--bg3); border: 1px dashed var(--line); color: var(--muted); font-size: .8rem; }
298
+ .msg.thinking .gen { font-family: ui-monospace, monospace; font-size: .7rem; color: #5f6880; max-height: 60px; overflow: hidden; white-space: pre-wrap; }
299
+ .msg.thinking .gen.tool-preview { font-family: inherit; font-size: .78rem; color: var(--accent2); font-style: italic; }
300
+
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; }
307
+ .plan-card .plan-status { float: right; font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
308
+ .plan-card ul.plan-tasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
309
+ .plan-card ul.plan-tasks li { display: flex; align-items: baseline; gap: 7px; padding: 5px 7px; border-radius: 7px; background: var(--bg2); border: 1px solid var(--line); }
310
+ .plan-card .task-ico { flex: 0 0 auto; }
311
+ .plan-card .task-title { font-weight: 600; }
312
+ .plan-card .task-desc { color: var(--muted); font-size: .76rem; }
313
+ .plan-card li.task-in-progress { border-color: var(--accent2); }
314
+ .plan-card li.task-done .task-title { color: #7ee787; }
315
+ .plan-card li.task-failed { border-color: #a03050; }
316
+ .plan-card li.task-failed .task-title { color: #e5a53a; }
317
+ .plan-card li.task-skipped { opacity: .5; }
318
+ .plan-card li.task-skipped .task-title { text-decoration: line-through; }
319
+ #composer { display: flex; gap: 6px; padding: 10px; }
320
+ #prompt { flex: 1; background: var(--bg3); border: 1px solid var(--line); border-radius: 9px; color: var(--fg); padding: 9px 11px; outline: none; }
321
+ #prompt:focus { border-color: var(--accent2); }
322
+ #composer button { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; border-radius: 9px; color: #fff; padding: 0 14px; cursor: pointer; }
323
+
324
+ /* ---------- markdown en el chat (estilo plugin de Claude Code) ---------- */
325
+ .msg.md { white-space: normal; }
326
+ .proposal-exec-btn { display: block; margin-top: 10px; background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 8px; padding: 8px 14px; font-size: .78rem; font-weight: 600; cursor: pointer; }
327
+ .proposal-exec-btn:hover { filter: brightness(1.1); }
328
+ .proposal-exec-btn:disabled { opacity: .6; cursor: default; filter: none; }
329
+ .msg.md code {
330
+ background: #0d1117; border: 1px solid var(--line); border-radius: 5px;
331
+ padding: 1px 6px; font-family: ui-monospace, monospace; font-size: .8em; color: #ffb3c9;
332
+ }
333
+ .msg.md code.file-link { cursor: pointer; color: var(--accent2); border-color: var(--accent2); text-decoration: underline dotted; }
334
+ .msg.md code.file-link:hover { background: rgba(124,92,255,.18); }
335
+ .msg.md pre.codeblock {
336
+ position: relative; background: #0d1117; border: 1px solid var(--line);
337
+ border-radius: 8px; padding: 10px 12px; margin: 8px 0; overflow-x: auto;
338
+ }
339
+ .msg.md pre.codeblock code {
340
+ background: none; border: none; padding: 0; color: #c9d1d9;
341
+ font-size: .78rem; line-height: 1.5; white-space: pre;
342
+ }
343
+ .msg.md pre.codeblock .lang {
344
+ position: absolute; top: 4px; right: 8px; font-size: .64rem;
345
+ color: var(--muted); text-transform: uppercase; letter-spacing: .06em;
346
+ }
347
+ .msg.md h4 { margin: 10px 0 4px; font-size: .92rem; color: var(--fg); }
348
+ .msg.md ul { margin: 4px 0; padding-left: 18px; }
349
+ .msg.md li { margin: 2px 0; }
350
+ .msg.md b { color: #fff; }
351
+
352
+ /* botón flotante chat↔editor: solo en móvil */
353
+ #code-flip {
354
+ display: none; position: fixed; bottom: 78px; right: 14px; z-index: 60;
355
+ padding: 10px 16px; font-size: .9rem; color: #fff; border: none;
356
+ border-radius: 999px; background: linear-gradient(135deg, var(--accent), var(--accent2));
357
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .5); cursor: pointer;
358
+ }
359
+
360
+ /* ---------- móvil: cabecera fija + chat/editor conmutables ---------- */
361
+ @media (max-width: 760px) {
362
+ main { grid-template-columns: 1fr; }
363
+ #activity, #sidebar { display: none; }
364
+ #code-flip { display: block; }
365
+ #editor-wrap { display: none; }
366
+ body.show-editor #editor-wrap { display: flex; }
367
+ body.show-editor #chat { display: none; }
368
+ #chat { border-left: none; min-width: 0; }
369
+ header { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
370
+ .proj { max-width: 30vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
371
+ select { max-width: 38vw; font-size: .78rem; }
372
+ #prompt { font-size: 16px; } /* sin auto-zoom iOS */
373
+ #settings-panel { flex-direction: column; }
374
+ /* nada puede tapar el composer: píldora y botón flotante, por encima de él */
375
+ #model-progress { bottom: 132px; }
376
+ #code-flip { bottom: 76px; }
377
+ }
378
+
379
+ /* ---------- composer estilo plugin de Claude Code ---------- */
380
+ #composer-wrap { border-top: 1px solid var(--line); background: var(--bg2); }
381
+ #ctx-meter { display: flex; align-items: center; gap: 8px; padding: 5px 12px 0; font-size: .68rem; color: var(--muted); }
382
+ #ctx-meter .ctx-track { flex: 1; height: 3px; background: var(--bg); border-radius: 2px; overflow: hidden; }
383
+ #ctx-bar { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent2), var(--accent)); transition: width .3s; }
384
+ #ctx-bar.hot { background: linear-gradient(90deg, #e5a53a, #e5533a); }
385
+ #composer { display: flex; align-items: center; gap: 6px; padding: 8px 10px 10px; }
386
+ .cbtn {
387
+ flex: 0 0 auto; background: var(--bg3); border: 1px solid var(--line); color: var(--muted);
388
+ border-radius: 8px; min-width: 30px; height: 32px; padding: 0 8px; cursor: pointer;
389
+ font-size: .95rem; display: inline-flex; align-items: center; gap: 4px;
390
+ }
391
+ .cbtn:hover { color: var(--fg); border-color: var(--accent2); }
392
+ .cbtn.autoedit { font-size: .74rem; }
393
+ .cbtn.autoedit .ae-ico { font-family: ui-monospace, monospace; }
394
+ .cbtn.autoedit.on { color: #fff; border-color: var(--accent); background: linear-gradient(135deg, #3a2050, #2a1840); }
395
+ #btn-send { position: relative; flex: 0 0 auto; background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; border-radius: 8px; color: #fff; height: 32px; padding: 0 14px; cursor: pointer; }
396
+ /* mientras se procesa: la elfa está "pensando" — feedback claro en el propio botón */
397
+ #btn-send.sending { cursor: not-allowed; }
398
+ #btn-send.sending svg { opacity: .25; }
399
+ #btn-send.sending::after {
400
+ content: ''; position: absolute; inset: 9px; border: 2px solid rgba(255,255,255,.35);
401
+ border-top-color: #fff; border-radius: 50%; animation: btn-send-spin .7s linear infinite;
402
+ }
403
+ @keyframes btn-send-spin { to { transform: rotate(360deg); } }
404
+ #composer #prompt { flex: 1; min-width: 0; }
405
+
406
+ /* menú flotante de +/ */
407
+ #menu {
408
+ position: absolute; bottom: 96px; left: 10px; right: 10px; z-index: 40;
409
+ background: var(--bg3); border: 1px solid var(--accent2); border-radius: 10px;
410
+ box-shadow: 0 -8px 30px rgba(0,0,0,.5); max-height: 320px; overflow-y: auto; padding: 4px;
411
+ }
412
+ #menu[hidden] { display: none; }
413
+ #menu .sep { font-size: .66rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; padding: 8px 10px 3px; }
414
+ #menu .menu-item { display: flex; justify-content: space-between; gap: 10px; width: 100%; text-align: left; background: none; border: none; color: var(--fg); padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: .82rem; }
415
+ #menu .menu-item:hover { background: #ffffff0f; }
416
+ #menu .menu-item b { font-family: ui-monospace, monospace; font-weight: 600; }
417
+ #menu .menu-item span { color: var(--muted); font-size: .74rem; }
418
+ #chat { position: relative; }
419
+
420
+ /* panel de skills */
421
+ .skills-panel h3 { margin: 0 0 4px; font-size: 1rem; }
422
+ .sk-h { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 12px 0 6px; }
423
+ .sk-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 6px; font-size: .82rem; }
424
+ .sk-row b { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
425
+ .sk-row .muted { color: var(--muted); font-size: .74rem; max-width: 40%; overflow: hidden; text-overflow: ellipsis; }
426
+ .sk-row a { color: var(--accent2); font-size: .74rem; text-decoration: none; }
427
+ .sk-row input { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; padding: 6px 9px; font-size: .78rem; }
428
+ .sk-row button.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 6px; padding: 5px 11px; cursor: pointer; font-size: .76rem; }
429
+ .sk-row button.ghost { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 6px; padding: 5px 9px; cursor: pointer; font-size: .76rem; }
430
+ #settings-panel { max-height: 55vh; overflow-y: auto; flex-direction: column; }
431
+
432
+ /* el panel de ajustes/skills se SUPERPONE (no empuja el IDE) */
433
+ #settings-panel { position: fixed; top: 46px; left: 46px; right: 0; z-index: 90; max-height: 78vh; overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,.6); }
434
+ @media (max-width: 760px) { #settings-panel { left: 0; } }
435
+
436
+ /* barra indeterminada (descargas sin loaded/total, p. ej. Gemma vía LiteRT-LM) */
437
+ #model-progress .track { overflow: hidden; }
438
+ #model-bar.indet { width: 35%; animation: indet 1.1s ease-in-out infinite; }
439
+ @keyframes indet { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
440
+
441
+ /* ===== chrome estilo VS Code: menubar + palette + settings + git ===== */
442
+ header { position: relative; }
443
+ #model-select[hidden] { display: none; }
444
+ .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 28px; padding: 0; }
445
+ .icon-btn svg { width: 16px; height: 16px; }
446
+ .icon-inline svg { vertical-align: -3px; }
447
+
448
+ /* barra de menú File/Edit/View/Git en la MISMA línea */
449
+ #menubar { display: flex; gap: 1px; margin-left: 6px; }
450
+ #menubar button { background: none; border: none; color: var(--muted); padding: 4px 9px; font-size: .8rem; cursor: pointer; border-radius: 5px; }
451
+ #menubar button:hover, #menubar button.open { color: var(--fg); background: #ffffff10; }
452
+ @media (max-width: 900px) { #menubar { display: none; } }
453
+
454
+ /* dropdown del menú (fixed, anclado al botón) */
455
+ #topmenu { position: fixed; z-index: 110; min-width: 220px; background: var(--bg3); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 10px 34px rgba(0,0,0,.55); padding: 4px; }
456
+ #topmenu[hidden] { display: none; }
457
+ #topmenu .menu-item { display: flex; justify-content: space-between; gap: 12px; width: 100%; text-align: left; background: none; border: none; color: var(--fg); padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: .82rem; }
458
+ #topmenu .menu-item:hover { background: #ffffff10; }
459
+ #topmenu .menu-item span { color: var(--muted); font-size: .74rem; }
460
+
461
+ /* chip de rama git */
462
+ .chip { display: inline-flex; align-items: center; gap: 4px; background: var(--bg3); border: 1px solid var(--line); color: var(--muted); border-radius: 999px; padding: 3px 10px; font-size: .74rem; cursor: pointer; }
463
+ .chip[hidden] { display: none; }
464
+ .chip:hover { color: var(--fg); border-color: var(--accent2); }
465
+ .chip svg { width: 12px; height: 12px; }
466
+
467
+ /* command palette */
468
+ #palette { position: fixed; top: 52px; left: 50%; transform: translateX(-50%); z-index: 130; width: min(640px, 92vw); background: var(--bg3); border: 1px solid var(--accent2); border-radius: 10px; box-shadow: 0 20px 60px rgba(0,0,0,.6); overflow: hidden; }
469
+ #palette[hidden] { display: none; }
470
+ #pal-input { width: 100%; background: var(--bg); border: none; border-bottom: 1px solid var(--line); color: var(--fg); padding: 12px 14px; font-size: .92rem; outline: none; }
471
+ #pal-list { max-height: 50vh; overflow-y: auto; padding: 4px; }
472
+ .pal-item { display: flex; align-items: baseline; gap: 10px; width: 100%; text-align: left; background: none; border: none; color: var(--fg); padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: .84rem; }
473
+ .pal-item.sel, .pal-item:hover { background: #7c5cff33; }
474
+ .pal-item .pi-name { font-weight: 500; }
475
+ .pal-item .pi-hint { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
476
+ .pal-empty { padding: 12px 14px; color: var(--muted); font-size: .82rem; }
477
+
478
+ /* settings: título, cierre y tarjetas de modelo/proveedor */
479
+ .panel-title { margin: 2px 0 8px; font-size: 1rem; }
480
+ .panel-close { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px; }
481
+ .panel-close:hover { color: var(--accent); }
482
+ .panel-close svg { width: 16px; height: 16px; }
483
+ .model-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 8px; margin-bottom: 6px; }
484
+ .model-card { text-align: left; background: var(--bg3); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; cursor: pointer; display: flex; flex-direction: column; gap: 2px; color: var(--fg); }
485
+ .model-card:hover { border-color: var(--accent2); }
486
+ .model-card.active { border-color: var(--accent); background: linear-gradient(135deg, #2a1840, #1a1428); }
487
+ .model-card b { font-size: .84rem; color: var(--fg); }
488
+ .model-card.active b { color: #fff; }
489
+ .model-card span { color: #aab2c8; font-size: .72rem; }
490
+ .prov-card { background: var(--bg3); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; margin-bottom: 7px; }
491
+ .prov-card.on { border-color: var(--accent2); }
492
+ .prov-head { display: flex; align-items: center; gap: 8px; }
493
+ .prov-head b { flex: 1; font-size: .84rem; }
494
+ .prov-use { background: linear-gradient(135deg, var(--accent), var(--accent2)); border: none; color: #fff; border-radius: 6px; padding: 4px 10px; font-size: .74rem; cursor: pointer; }
495
+ .prov-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
496
+ .prov-fields.hide { display: none; }
497
+ .prov-fields input { background: var(--bg); border: 1px solid var(--line); color: var(--fg); border-radius: 6px; padding: 6px 9px; font-size: .78rem; width: 100%; }
498
+ .field { display: flex; flex-direction: column; gap: 3px; }
499
+ .field .muted { font-size: .7rem; }
500
+ button.wide { width: 100%; padding: 9px; border-radius: 8px; }
501
+
502
+ .model-card.disabled { opacity: .5; cursor: not-allowed; }
503
+ .model-card.disabled:hover { border-color: var(--line); }
504
+
505
+ /* ---------- vistas Arquitectura / Ciudad 3D (overlay sobre el editor) ---------- */
506
+ #view-overlay { position: absolute; inset: 34px 0 22px 0; z-index: 30; background: #0b0d12; overflow: hidden; }
507
+ #view-overlay[hidden] { display: none; }
508
+ #editor-wrap { position: relative; }
509
+ #view-close { position: absolute; top: 10px; right: 12px; z-index: 32; background: var(--bg3); border: 1px solid var(--line); color: var(--muted); width: 30px; height: 28px; border-radius: 7px; cursor: pointer; }
510
+ #view-close:hover { color: var(--accent); }
511
+ #view-body { width: 100%; height: 100%; position: relative; }
512
+ #view-body svg#arch-svg { width: 100%; height: 100%; display: block; cursor: grab; }
513
+ .arch-node { cursor: pointer; }
514
+ .arch-node:hover circle { stroke-width: 3; }
515
+ .view-head { position: absolute; top: 10px; left: 14px; z-index: 31; font-size: .8rem; color: #c8cede; background: #0d1117cc; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--line); }
516
+ .view-hint { color: var(--muted); font-size: .72rem; margin-left: 8px; }
517
+ .view-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: .9rem; }
518
+ #city-canvas { width: 100%; height: 100%; display: block; }
img/elffuss-code.svg ADDED
index.html CHANGED
@@ -1,19 +1,151 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
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 Code — tu IDE con alma, en el navegador</title>
7
+ <meta property="og:type" content="website">
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="Abre una carpeta de código y programa con Elffuss: IDE web + agente local. Nada sale de tu máquina.">
20
+ <link rel="icon" href="img/elffuss-code.svg">
21
+ <link rel="stylesheet" href="css/code.css">
22
+ <!-- import map: la ciudad 3D (VibeCodeViewer vendorizado en js/vcc) usa import 'three' -->
23
+ <script type="importmap">
24
+ { "imports": {
25
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
26
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
27
+ }}
28
+ </script>
29
+ </head>
30
+ <body>
31
+
32
+ <!-- landing: primero se abre la carpeta -->
33
+ <div id="landing">
34
+ <div class="l-inner">
35
+ <img src="img/elffuss-code.svg" alt="Elffuss Code" class="hero">
36
+ <h1><b>Elffuss</b> Code</h1>
37
+ <p class="tag">Abre tu carpeta de código y programa con una elfa al lado.<br>
38
+ Editor tipo VS Code + agente <b>en tu navegador</b>. Nada sale de tu máquina. 😉</p>
39
+ <button id="open-project">📁 Abrir carpeta de código</button>
40
+ <button id="reopen-project" class="ghost" hidden></button>
41
+ <div id="resume-clone" class="resume-clone" hidden>
42
+ <span class="rc-text"></span>
43
+ <button id="resume-clone-btn">Reanudar</button>
44
+ <button id="discard-clone-btn" class="ghost">Descartar</button>
45
+ </div>
46
+ <div class="clone-row">
47
+ <span class="clone-or">o</span>
48
+ <input id="clone-url" type="text" placeholder="pega la URL de un repo público (GitHub/Bitbucket)…" autocomplete="off" spellcheck="false">
49
+ <button id="clone-btn">Descargar y abrir</button>
50
+ </div>
51
+ <p id="clone-status" class="mini clone-status" hidden></p>
52
+ <p class="mini">Chrome/Edge · File System Access · modelo local WebGPU</p>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- IDE -->
57
+ <div id="ide" hidden>
58
+ <header>
59
+ <div class="brand"><img src="img/elffuss-code.svg" alt=""><b>Elffuss Code</b>
60
+ <span id="project-name" class="proj"></span><span id="build" class="proj">__BUILD__</span></div>
61
+ <nav id="menubar">
62
+ <button data-menu="file">File</button>
63
+ <button data-menu="edit">Edit</button>
64
+ <button data-menu="view">View</button>
65
+ <button data-menu="git">Git</button>
66
+ </nav>
67
+ <div class="controls">
68
+ <button id="git-branch" class="chip" title="rama actual" hidden></button>
69
+ <span id="model-dot" class="dot off" title="modelo"></span>
70
+ <select id="model-select" hidden></select>
71
+ <button id="btn-history" class="ghost icon-btn" title="Historial de conversaciones"></button>
72
+ <button id="btn-settings" class="ghost icon-btn" title="Ajustes, modelo y API keys"></button>
73
+ </div>
74
+ </header>
75
+ <div id="settings-panel" hidden></div>
76
+ <div id="topmenu" hidden></div>
77
+ <div id="palette" hidden>
78
+ <input id="pal-input" autocomplete="off" spellcheck="false"
79
+ placeholder="Buscar archivos por nombre (: para ir a línea, @ para símbolo, &gt; para comandos)">
80
+ <div id="pal-list"></div>
81
+ </div>
82
+ <main>
83
+ <nav id="activity">
84
+ <button id="act-files" class="on" title="Explorador"></button>
85
+ <button id="act-search" title="Buscar (Cmd/Ctrl+P)"></button>
86
+ <button id="act-arch" title="Arquitectura (grafo de dependencias)"></button>
87
+ <button id="act-city" title="Ciudad 3D"></button>
88
+ <button id="act-term" title="Terminal (Ctrl+`)"></button>
89
+ <button id="act-skills" title="Skills"></button>
90
+ <button id="act-settings" title="Ajustes y modelo"></button>
91
+ <img src="img/elffuss-code.svg" alt="Elffuss" title="Elffuss Code">
92
+ </nav>
93
+ <aside id="sidebar">
94
+ <div id="explorer-head">EXPLORER<span id="explorer-proj"></span></div>
95
+ <div id="tree"></div>
96
+ </aside>
97
+ <section id="editor-wrap">
98
+ <div id="tabs-bar"></div>
99
+ <div id="editor"></div>
100
+ <div id="view-overlay" hidden>
101
+ <button id="view-close" title="cerrar">✕</button>
102
+ <div id="view-body"></div>
103
+ </div>
104
+ <div id="terminal-panel" hidden>
105
+ <div id="term-resize" title="arrastra para redimensionar"></div>
106
+ <div id="term-head">
107
+ <span class="term-title">TERMINAL</span>
108
+ <span id="term-caps"></span>
109
+ <button id="term-clear" class="term-btn" title="limpiar">limpiar</button>
110
+ <button id="term-close" class="term-btn" title="cerrar (Ctrl+`)">✕</button>
111
+ </div>
112
+ <div id="terminal-host"></div>
113
+ </div>
114
+ <div id="statusbar"></div>
115
+ </section>
116
+ <section id="chat">
117
+ <div id="conv-tabs"></div>
118
+ <div id="history-panel" hidden></div>
119
+ <div id="chat-log"></div>
120
+ <div id="menu" hidden></div>
121
+ <div id="composer-wrap">
122
+ <div id="ctx-meter" title="contexto acumulado en la conversación">
123
+ <span id="ctx-text">ctx 0 / 6k</span>
124
+ <div class="ctx-track"><div id="ctx-bar"></div></div>
125
+ </div>
126
+ <form id="composer">
127
+ <button type="button" id="btn-plus" class="cbtn" title="Adjuntar archivo del proyecto (@)"></button>
128
+ <button type="button" id="btn-slash" class="cbtn" title="Comandos"></button>
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>
135
+ </section>
136
+ </main>
137
+ </div>
138
+
139
+ <button id="code-flip" title="cambiar entre chat y editor"></button>
140
+
141
+ <div id="model-progress" hidden>
142
+ <img src="img/elffuss-code.svg" alt="">
143
+ <div class="mp-body">
144
+ <span id="model-progress-text">Preparando el cerebro local…</span>
145
+ <div class="track"><div id="model-bar"></div></div>
146
+ </div>
147
+ </div>
148
+
149
+ <script type="module" src="js/main.js"></script>
150
+ </body>
151
  </html>
js/agent.js ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Bucle agéntico de Elffuss Code (mismo protocolo que Elffuss SO).
2
+ import { runTool, toolHelp, snapshot } from './tools/index.js';
3
+ import { skillsPromptBlock } from './skills.js';
4
+ import * as telemetry from './telemetry.js';
5
+
6
+ const MAX_STEPS = 12; // margen para tareas largas (leer→editar→verificar→arreglar…)
7
+
8
+ const LANGS = {
9
+ es: 'español', en: 'English', uk: 'українська', ru: 'русский', fr: 'français',
10
+ de: 'Deutsch', it: 'italiano', pt: 'português', pl: 'polski', ca: 'català',
11
+ };
12
+
13
+ export function userLang() {
14
+ const codeL = (navigator.language || 'es').toLowerCase();
15
+ return { code: codeL, name: LANGS[codeL.split('-')[0]] || codeL };
16
+ }
17
+
18
+ export function systemPrompt(context = '') {
19
+ const lang = userLang();
20
+ return `Eres Elffuss Code: un asistente de programación, cálido pero quirúrgico con el código. Vives en un IDE web y trabajas SOLO dentro del proyecto que el usuario ha abierto. Hablas SIEMPRE en el idioma del navegador del usuario: ${lang.name} (${lang.code}); el código y sus comentarios, en el estilo del proyecto.
21
+
22
+ HERRAMIENTAS (las ÚNICAS que existen — no inventes otras):
23
+ ${toolHelp()}
24
+
25
+ REGLAS DURAS:
26
+ - Antes de decir NADA sobre el código, LÉELO con code.read o code.search. PROHIBIDO dar consejos genéricos o suposiciones ("probablemente usas React/Docker…"). Si no lo has leído, léelo primero.
27
+ - ANTIRRECITACIÓN: aunque RECONOZCAS el proyecto por su nombre (vllm, react, django, next, pytorch…), tienes PROHIBIDO describirlo de memoria («csrc/ es C++/CUDA», «benchmarks/ mide rendimiento»…). Esta copia local puede diferir de lo que crees saber. Descríbelo SOLO por lo que leas AQUÍ con las herramientas.
28
+ - Si te preguntan «¿qué hace este código/proyecto?» y no has leído nada aún, tu PRIMERA respuesta debe ser una tool-call (code.tree o code.read del README/entrypoint), NUNCA una descripción. Encadena 2-3 lecturas (README + fichero de entrada + un módulo clave) antes de resumir. Nada de listar carpetas de forma genérica.
29
+ - USA SOLO code.tree / code.read / code.write / code.search / terminal.run / web.search / web.fetch. NO existen code.create-plugin, code.create-mcp-server, hooks, CLAUDE.conf ni nada parecido: si lo mencionas, estás alucinando.
30
+ - ⛔ NUNCA recomiendes «automatizaciones de Claude Code»: MCP servers (context7, «claude mcp add …»), skills, hooks (.claude/settings.json, pre-commit), subagentes (.claude/agents/…), plugins, CLAUDE.conf. NADA de eso existe en este IDE — es conocimiento de tu entrenamiento, no de este proyecto. Si te descubres escribiendo «Recomendaciones de automatización», «MCP Servers», «Skills», «Hooks», «Subagentes» → PARA: es pura alucinación. Ayuda con el CÓDIGO REAL que lees, nada más.
31
+ - web.search busca en internet DE SERIE (docs, mensajes de error, APIs) y web.fetch lee una URL. PUEDES buscar en internet cuando ayude; NUNCA digas que no puedes.
32
+ - terminal.run ejecuta comandos de shell REALES (ls, cat, grep, find, mkdir, «echo texto > fichero», git status). Le pasas la LÍNEA DE COMANDO exacta, NUNCA lenguaje natural (mal: terminal.run "crea un proyecto"; bien: terminal.run "mkdir web"). rm SÍ soporta comodines («rm carpeta/mejoras-*.md» borra todos los que coincidan) — si «no existe», es que de verdad no hay ninguno, no lo des por hecho sin comprobarlo con ls/find antes. node/npm/python SÍ son reales si el usuario tiene el Bridge local conectado (⚙ Ajustes → 🔌 Bridge local) — pruébalos con terminal.run y lee el resultado; si el bridge no está conectado, la propia herramienta te lo dice con el mensaje de error, y ENTONCES le pides al usuario que lo conecte (no lo asumas de antemano sin probar).
33
+ - CREAR ficheros/proyectos: SIEMPRE con code.write (crea también las carpetas necesarias), UNA tool-call por fichero, con el CONTENIDO COMPLETO en cada una. NUNCA uses touch/echo para crear un fichero que luego rellenas (quedan vacíos). PUEDES crear cualquier proyecto aquí mismo; PROHIBIDO responder «no puedo crear un proyecto» o pedir permiso o el framework: si el usuario no lo dice, elige HTML/CSS/JS simple y escríbelo ya. Puedes emitir VARIAS code.write en un mismo mensaje y se ejecutan todas.
34
+ - Habla SOLO de archivos y contenido que aparezcan en el CONTEXTO o en resultados de herramientas. Cita rutas y líneas reales. Si no lo sabes, léelo, no lo inventes.
35
+
36
+ Cómo actuar:
37
+ 1) Para usar una herramienta responde SOLO con:
38
+ \`\`\`tool
39
+ {"tool": "code.read", "args": {"path": "README.md"}}
40
+ \`\`\`
41
+ 2) NUNCA inventes rutas: usa SOLO las del árbol del CONTEXTO o de code.tree/code.search. Relativas a la raíz, sin ./ inicial.
42
+ 3) Antes de tocar código: lee el archivo. Al escribir con code.write pasa el contenido COMPLETO. El editor se actualiza al instante.
43
+ 4) Tras un [resultado], responde breve y CONCRETO citando lo que has leído.
44
+
45
+ Ejemplos:
46
+ Usuario: ¿qué hace este proyecto/código?
47
+ Tú:
48
+ \`\`\`tool
49
+ {"tool": "code.read", "args": {"path": "README.md"}}
50
+ \`\`\`
51
+ Usuario: ¿qué hay en este proyecto?
52
+ Tú:
53
+ \`\`\`tool
54
+ {"tool": "code.tree", "args": {}}
55
+ \`\`\`
56
+ Usuario: busca dónde se define handleClick
57
+ Tú:
58
+ \`\`\`tool
59
+ {"tool": "code.search", "args": {"query": "handleClick"}}
60
+ \`\`\`
61
+ Usuario: crea una carpeta tests y un fichero vacío dentro
62
+ Tú:
63
+ \`\`\`tool
64
+ {"tool": "terminal.run", "args": {"command": "mkdir tests && touch tests/test_main.py"}}
65
+ \`\`\`
66
+ Usuario: crea un mini proyecto web que explique esto
67
+ Tú: (SIN pedir permiso ni framework — escribe los ficheros con contenido COMPLETO, varias code.write a la vez)
68
+ \`\`\`tool
69
+ {"tool": "code.write", "args": {"path": "web/index.html", "content": "<!DOCTYPE html>\\n<html>…página completa…</html>"}}
70
+ \`\`\`
71
+ \`\`\`tool
72
+ {"tool": "code.write", "args": {"path": "web/style.css", "content": "body{font-family:system-ui;margin:0}…"}}
73
+ \`\`\`
74
+ Usuario: ¿qué mejorarías del código?
75
+ Tú:
76
+ \`\`\`tool
77
+ {"tool": "code.read", "args": {"path": "src/main.py"}}
78
+ \`\`\`
79
+ (y tras leerlo de verdad, propones mejoras CONCRETAS citando líneas — nunca genéricas)${skillsPromptBlock()}${context ? `
80
+
81
+ CONTEXTO AHORA (estado real del IDE, úsalo):
82
+ ${context}` : ''}`;
83
+ }
84
+
85
+ function tryJson(raw) {
86
+ try {
87
+ const obj = JSON.parse(raw);
88
+ if (obj && typeof obj.tool === 'string') return { tool: obj.tool, args: obj.args || {} };
89
+ } catch { /* no era JSON */ }
90
+ return null;
91
+ }
92
+
93
+ // Formato NATIVO de LFM2.5: <|tool_call_start|>[ code.read(path="x") ]<|tool_call_end|>
94
+ export function parseNativeCall(text) {
95
+ const m = text.match(/<\|tool_call_start\|>\s*\[?\s*([\w.]+)\s*\(([\s\S]*?)\)\s*\]?\s*<\|tool_call_end\|>/)
96
+ || text.match(/^\s*\[\s*([\w.]+)\s*\(([\s\S]*?)\)\s*\]\s*$/m);
97
+ if (!m) return null;
98
+ const args = {};
99
+ const re = /([\w]+)\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^,)]+)/g;
100
+ let a;
101
+ while ((a = re.exec(m[2]))) {
102
+ let v = a[2].trim();
103
+ if (/^["']/.test(v)) v = v.slice(1, -1).replace(/\\(.)/g, '$1');
104
+ else if (/^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
105
+ else if (v === 'true' || v === 'false') v = v === 'true';
106
+ args[a[1]] = v;
107
+ }
108
+ return { tool: m[1], args };
109
+ }
110
+
111
+ // Extrae UNA tool-call (compat). Prefiere la primera de parseToolCalls.
112
+ export function parseToolCall(text) {
113
+ return parseToolCalls(text)[0] || null;
114
+ }
115
+
116
+ // Extrae TODAS las tool-calls de un mensaje, en orden. El modelo pequeño a
117
+ // menudo emite varias (p. ej. tres code.write para crear tres ficheros) o las
118
+ // mete en prosa sin un fence ```tool perfecto → antes solo se ejecutaba la
119
+ // primera (o ninguna) y los ficheros quedaban VACÍOS. Aquí buscamos cada objeto
120
+ // {"tool":...} por llaves balanceadas, esté donde esté, además del formato nativo.
121
+ export function parseToolCalls(text) {
122
+ const calls = [];
123
+ const seen = new Set();
124
+ const push = c => {
125
+ if (!c || typeof c.tool !== 'string') return;
126
+ const k = c.tool + '|' + JSON.stringify(c.args || {});
127
+ if (!seen.has(k)) { seen.add(k); calls.push(c); }
128
+ };
129
+ // 1) formato nativo LFM (puede haber varios)
130
+ const nat = /<\|tool_call_start\|>[\s\S]*?<\|tool_call_end\|>/g;
131
+ let mm;
132
+ while ((mm = nat.exec(text))) push(parseNativeCall(mm[0]));
133
+ // 2) cada objeto JSON que empiece por {"tool": … (con o sin fence)
134
+ const re = /\{\s*"tool"\s*:/g;
135
+ let m;
136
+ while ((m = re.exec(text))) {
137
+ const obj = extractBalanced(text, m.index);
138
+ if (obj) { push(tryJson(obj)); re.lastIndex = m.index + obj.length; }
139
+ }
140
+ // 3) último recurso: nativo suelto de una línea
141
+ if (!calls.length) push(parseNativeCall(text));
142
+ return calls;
143
+ }
144
+
145
+ // Desde el '{' en `start`, devuelve la subcadena hasta la '}' que balancea,
146
+ // respetando cadenas y escapes (para no cortar un content con llaves dentro).
147
+ function extractBalanced(text, start) {
148
+ let depth = 0, inStr = false, esc = false;
149
+ for (let i = start; i < text.length; i++) {
150
+ const ch = text[i];
151
+ if (esc) { esc = false; continue; }
152
+ if (ch === '\\') { esc = true; continue; }
153
+ if (ch === '"') inStr = !inStr;
154
+ else if (!inStr) {
155
+ if (ch === '{') depth++;
156
+ else if (ch === '}' && --depth === 0) return text.slice(start, i + 1);
157
+ }
158
+ }
159
+ // Recuperación: los modelos pequeños a veces se dejan SIN escribir una
160
+ // llave de cierre (cierran "args":{…} pero no el objeto exterior) aunque
161
+ // el resto del JSON esté bien formado — antes esto tiraba el tool-call
162
+ // entero (ninguna escritura llegaba a ejecutarse, sin aviso). Si al llegar
163
+ // al final falta cerrar N llaves (fuera de cualquier cadena, así que no es
164
+ // un truncado a media cadena) probamos a añadirlas — solo se acepta si el
165
+ // JSON resultante es válido de verdad.
166
+ if (!inStr && depth > 0 && depth <= 4) {
167
+ const candidate = text.slice(start).replace(/```[\s\S]*$/, '').trimEnd() + '}'.repeat(depth);
168
+ try { JSON.parse(candidate); return candidate; } catch { /* no se pudo recuperar */ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ export class Agent {
174
+ constructor(provider) {
175
+ this.provider = provider;
176
+ this.history = [];
177
+ }
178
+
179
+ setProvider(p) { this.provider = p; }
180
+
181
+ async handle(userText, onEvent) {
182
+ this.history.push({ role: 'user', content: userText });
183
+ for (let step = 0; step < MAX_STEPS; step++) {
184
+ let out;
185
+ try {
186
+ const context = await snapshot().catch(() => '');
187
+ out = await this.provider.chat(this.history, systemPrompt(context),
188
+ t => onEvent({ type: 'token', text: t }));
189
+ } catch (e) {
190
+ telemetry.reportError('agent.handle: ' + e.message, { stack: e.stack || '' });
191
+ onEvent({ type: 'error', text: 'El modelo falló: ' + e.message });
192
+ return;
193
+ }
194
+
195
+ const calls = parseToolCalls(out);
196
+ if (!calls.length) {
197
+ this.history.push({ role: 'assistant', content: out });
198
+ onEvent({ type: 'text', text: out });
199
+ return;
200
+ }
201
+
202
+ // Ejecutar TODAS las tool-calls del mensaje en orden (varios code.write =
203
+ // varios ficheros con contenido, no vacíos).
204
+ this.history.push({ role: 'assistant', content: out });
205
+ const results = [];
206
+ for (const call of calls) {
207
+ onEvent({ type: 'tool', call });
208
+ let result;
209
+ try { result = await runTool(call.tool, call.args); }
210
+ catch (e) { result = 'ERROR: ' + e.message; }
211
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
212
+ onEvent({ type: 'tool_result', tool: call.tool, result: resultStr });
213
+ results.push(`[resultado ${call.tool}]\n${resultStr}`);
214
+ }
215
+ this.history.push({ role: 'user', content: results.join('\n\n') });
216
+ }
217
+ onEvent({ type: 'text', text: '(Me quedé sin pasos: demasiadas herramientas seguidas.)' });
218
+ }
219
+ }
js/arch.js ADDED
Binary file (5.83 kB). View file
 
js/bridge.js ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cliente del Elffuss Bridge: puente local (proceso que el usuario descarga y
2
+ // ejecuta) que da ejecución REAL en su máquina (node, npm, python…) sin salir
3
+ // de ella. Protocolo por WebSocket a 127.0.0.1, con token de un solo arranque.
4
+ const PORT = 8765;
5
+ const HOST = '127.0.0.1:' + PORT;
6
+
7
+ let ws = null, connected = false, authed = false;
8
+ let pending = new Map(); // id → { onOut, onErr, resolve }
9
+ let nextId = 1;
10
+ let onStatus = () => {};
11
+ let savedFolder = '';
12
+ try { savedFolder = localStorage.getItem('elffusscode.bridgeFolder') || ''; } catch { /* */ }
13
+ let savedToken = '';
14
+ try { savedToken = localStorage.getItem('elffusscode.bridgeToken') || ''; } catch { /* */ }
15
+
16
+ export function isConnected() { return connected && authed; }
17
+ export function getFolder() { return savedFolder; }
18
+ export function setFolder(f) {
19
+ savedFolder = (f || '').trim();
20
+ try { localStorage.setItem('elffusscode.bridgeFolder', savedFolder); } catch { /* */ }
21
+ }
22
+ export function onStatusChange(fn) { onStatus = fn; }
23
+
24
+ // sondeo ligero: ¿hay un bridge escuchando en 127.0.0.1, aunque no nos hayamos
25
+ // autenticado todavía? (para saber si mostrar «detectado» antes de conectar)
26
+ export async function probe() {
27
+ try {
28
+ const r = await fetch('http://' + HOST + '/ping', { mode: 'cors' });
29
+ if (!r.ok) return false;
30
+ const j = await r.json();
31
+ return !!j.ok;
32
+ } catch { return false; }
33
+ }
34
+
35
+ export function connect(token) {
36
+ return new Promise((resolve, reject) => {
37
+ try { ws = new WebSocket('ws://' + HOST + '/ws'); }
38
+ catch (e) { reject(e); return; }
39
+ const timeout = setTimeout(() => { try { ws.close(); } catch { /* */ } reject(new Error('tiempo agotado — ¿está el bridge arrancado?')); }, 6000);
40
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token }));
41
+ ws.onerror = () => { clearTimeout(timeout); connected = false; authed = false; onStatus('error'); reject(new Error('no pude conectar — ¿está el bridge arrancado en este puerto?')); };
42
+ ws.onclose = () => { connected = false; authed = false; onStatus('disconnected'); };
43
+ ws.onmessage = e => {
44
+ let m; try { m = JSON.parse(e.data); } catch { return; }
45
+ if (m.type === 'auth-ok') {
46
+ clearTimeout(timeout);
47
+ connected = true; authed = true;
48
+ savedToken = token;
49
+ try { localStorage.setItem('elffusscode.bridgeToken', token); } catch { /* */ }
50
+ onStatus('connected');
51
+ resolve(true);
52
+ return;
53
+ }
54
+ if (m.type === 'auth-fail') { clearTimeout(timeout); connected = true; authed = false; onStatus('auth-fail'); reject(new Error('token incorrecto')); return; }
55
+ const job = pending.get(m.id);
56
+ if (!job) return;
57
+ if (m.type === 'stdout') job.onOut(m.data);
58
+ else if (m.type === 'stderr') job.onErr(m.data);
59
+ else if (m.type === 'error') { job.onErr(m.data); job.resolve(-1); pending.delete(m.id); }
60
+ else if (m.type === 'exit') { job.resolve(m.code || 0); pending.delete(m.id); }
61
+ };
62
+ });
63
+ }
64
+
65
+ export function disconnect() { try { ws?.close(); } catch { /* */ } connected = false; authed = false; }
66
+
67
+ // tratar de reconectar en silencio con el token guardado (al abrir la app)
68
+ export async function tryAutoConnect() {
69
+ if (!savedToken) return false;
70
+ try { await connect(savedToken); return true; } catch { return false; }
71
+ }
72
+
73
+ // Detección continua en segundo plano: si el usuario arranca el bridge
74
+ // DESPUÉS de cargar la página (o lo reinicia tras cerrarlo), esto lo detecta
75
+ // solo y reconecta con el token guardado — sin que haga falta reabrir Ajustes
76
+ // y pulsar «Conectar» de nuevo. Se apoya en probe() (sondeo ligero sin auth).
77
+ let autoDetectTimer = null, connecting = false;
78
+ export function startAutoDetect(intervalMs = 4000) {
79
+ if (autoDetectTimer) return () => stopAutoDetect();
80
+ autoDetectTimer = setInterval(async () => {
81
+ if (isConnected() || connecting || !savedToken) return;
82
+ if (!(await probe())) return;
83
+ connecting = true;
84
+ try { await connect(savedToken); } catch { /* el usuario aún no ha pegado token válido, o cayó de nuevo */ }
85
+ finally { connecting = false; }
86
+ }, intervalMs);
87
+ return () => stopAutoDetect();
88
+ }
89
+ export function stopAutoDetect() { clearInterval(autoDetectTimer); autoDetectTimer = null; }
90
+
91
+ // ejecuta un comando REAL en la máquina del usuario, vía el bridge.
92
+ // onOut/onErr reciben trozos de salida en vivo; devuelve el código de salida.
93
+ export function exec(cmd, { cwd = savedFolder, onOut = () => {}, onErr = () => {} } = {}) {
94
+ if (!isConnected()) return Promise.reject(new Error('bridge no conectado'));
95
+ const id = String(nextId++);
96
+ return new Promise((resolve, reject) => {
97
+ pending.set(id, { onOut, onErr, resolve });
98
+ try { ws.send(JSON.stringify({ type: 'exec', id, cmd, cwd })); }
99
+ catch (e) { pending.delete(id); reject(e); }
100
+ });
101
+ }
js/ceo.js ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cerebro CEO autónomo (compartido por Elffuss Code y Elffuss Claw): cuando el
2
+ // usuario NO está pidiendo nada (ocioso) y hay un modelo local cargado, la elfa
3
+ // «trabaja por su cuenta» — revisa el espacio de trabajo, reparte el trabajo en
4
+ // varios perfiles que piensan EN PARALELO (cada uno una línea de pensamiento en
5
+ // la vista Mente) y sintetiza propuestas de mejora.
6
+ //
7
+ // Agnóstico de herramientas a propósito: cada app inyecta su propio adaptador
8
+ // de workspace (`init({ workspace, ... })`) — Code usa code.js (proyecto de
9
+ // código), Claw usa fs.js (carpetas con permiso). El core no sabe ni le importa
10
+ // cuál es.
11
+ //
12
+ // Seguridad: NO modifica tus ficheros. Deja las propuestas en una carpeta
13
+ // aditiva (nunca toca lo existente) y las hace «flotar» en la Mente. Se PARA en
14
+ // cuanto detecta actividad del usuario y reanuda al volver a estar ocioso.
15
+ import { Agent } from './agent.js';
16
+ import { humanizeTool } from './humanize.js';
17
+
18
+ const IDLE_MS = 18000; // 18 s sin actividad → el CEO se pone a trabajar
19
+ const TICK_MS = 3000; // frecuencia de comprobación
20
+ // 5 min entre ciclos automáticos: con 45s, una tarde ociosa generaba MILES de
21
+ // ficheros. «Pensar ahora» (forceCycle) sigue disponible sin esperar esto.
22
+ const COOLDOWN_MS = 300000;
23
+ const SOUL_CAP = 25; // ficheros sueltos antes de consolidar en archivo.md
24
+
25
+ // perfil por defecto si la app anfitriona no da los suyos
26
+ const GENERIC_PROFILES = [
27
+ { id: 'p1', name: 'Revisión', focus: 'qué mejorar de forma concreta y accionable', color: '#7c5cff' },
28
+ { id: 'p2', name: 'Calidad', focus: 'errores, casos borde, cosas que podrían fallar', color: '#49e8ff' },
29
+ ];
30
+
31
+ const slug = s => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
32
+ .replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 24) || 'perfil';
33
+
34
+ // ── namespace (localStorage) y adaptador de workspace: los da la app anfitriona ──
35
+ let NS = 'elffuss'; // prefijo de las claves de localStorage
36
+ let ws = null; // { isReady, tree, write, read, list, remove }
37
+ let getProvider = () => null;
38
+ let isBusy = () => false; // ¿el usuario tiene trabajo en cola/procesándose? → prioridad
39
+ let defaultProfiles = GENERIC_PROFILES;
40
+ const K = suffix => NS + '.' + suffix;
41
+
42
+ function loadProfiles() {
43
+ try { const s = JSON.parse(localStorage.getItem(K('ceoProfiles'))); if (Array.isArray(s) && s.length) return s; } catch { /* */ }
44
+ return defaultProfiles.map(p => ({ ...p }));
45
+ }
46
+ let profiles = null;
47
+ export function getProfiles() { if (!profiles) profiles = loadProfiles(); return profiles; }
48
+ export function setProfiles(list) {
49
+ const used = new Set();
50
+ profiles = (list || []).filter(p => p && p.name).map(p => {
51
+ let id = p.id || slug(p.name); let n = id, i = 2;
52
+ while (used.has(n)) n = id + '-' + i++;
53
+ used.add(n);
54
+ return { id: n, name: String(p.name).slice(0, 40), focus: String(p.focus || '').slice(0, 200), color: /^#[0-9a-f]{6}$/i.test(p.color || '') ? p.color : '#7c5cff' };
55
+ });
56
+ if (!profiles.length) profiles = defaultProfiles.map(p => ({ ...p }));
57
+ try { localStorage.setItem(K('ceoProfiles'), JSON.stringify(profiles)); } catch { /* */ }
58
+ emit('ceo', { type: 'reprogram', text: 'Perfiles actualizados: ' + profiles.map(p => p.name).join(', '), profiles });
59
+ return profiles;
60
+ }
61
+
62
+ let enabled = false, running = false, lastActivity = Date.now(), timer = null, lastCycleEnd = 0, cycleN = 0;
63
+
64
+ // MISIÓN reprogramable: el usuario puede reorientar el cerebro desde la Mente
65
+ // («céntrate en seguridad», «optimiza mis Excel», «documenta todo»…).
66
+ let DEFAULT_MISSION = 'Revisar el espacio de trabajo y proponer mejoras concretas y accionables.';
67
+ let mission = null;
68
+ function ensureMission() { if (mission == null) { try { mission = localStorage.getItem(K('ceoMission')) || DEFAULT_MISSION; } catch { mission = DEFAULT_MISSION; } } }
69
+ export function getMission() { ensureMission(); return mission; }
70
+ export function setMission(text) {
71
+ mission = (text || '').trim() || DEFAULT_MISSION;
72
+ try { localStorage.setItem(K('ceoMission'), mission); } catch { /* */ }
73
+ emit('ceo', { type: 'reprogram', text: 'Nueva misión recibida: ' + mission });
74
+ lastCycleEnd = 0; lastActivity = Date.now() - IDLE_MS; // que arranque un ciclo pronto con la nueva misión
75
+ return mission;
76
+ }
77
+
78
+ // Carpeta-«alma» donde el cerebro crea y guarda TODO (configurable).
79
+ let DEFAULT_SOUL = '.elffuss/soul';
80
+ let soulDir = null;
81
+ function ensureSoulDir() { if (soulDir == null) { try { soulDir = localStorage.getItem(K('ceoDir')) || DEFAULT_SOUL; } catch { soulDir = DEFAULT_SOUL; } } }
82
+ export function getSoulDir() { ensureSoulDir(); return soulDir; }
83
+ export function setSoulDir(dir) {
84
+ soulDir = (dir || '').trim().replace(/^\/+|\/+$/g, '') || DEFAULT_SOUL;
85
+ try { localStorage.setItem(K('ceoDir'), soulDir); } catch { /* */ }
86
+ emit('ceo', { type: 'reprogram', text: 'Nueva carpeta-alma: ' + soulDir + '/' });
87
+ return soulDir;
88
+ }
89
+
90
+ // ── semáforo cross-pestaña: UN SOLO cerebro ejecuta, TODAS visualizan ───────
91
+ // El líder tiene un Web Lock exclusivo (se libera solo al cerrar la pestaña);
92
+ // difunde sus pensamientos por BroadcastChannel para que el resto los vea.
93
+ // (Aislado por origen por el propio navegador: Code y Claw nunca se cruzan.)
94
+ let isLeader = false, bc = null, realEmit = () => {}, crossTabWired = false;
95
+ function initCrossTab() {
96
+ if (crossTabWired) return;
97
+ crossTabWired = true;
98
+ try {
99
+ bc = new BroadcastChannel(NS + '-ceo');
100
+ bc.onmessage = e => { if (e.data && e.data.kind === 'thought') realEmit(e.data.channel, e.data.ev); };
101
+ } catch { /* sin BroadcastChannel */ }
102
+ if (navigator.locks && navigator.locks.request) {
103
+ navigator.locks.request(NS + '-ceo-leader', { mode: 'exclusive' }, () => new Promise(() => { isLeader = true; }))
104
+ .catch(() => { isLeader = true; });
105
+ } else { isLeader = true; }
106
+ }
107
+ function emit(channel, ev) {
108
+ realEmit(channel, ev);
109
+ try { bc && bc.postMessage({ kind: 'thought', channel, ev }); } catch { /* ev no serializable */ }
110
+ }
111
+
112
+ // init({ workspace, provider, onEvent, isBusy, namespace, defaultProfiles, defaultMission, defaultSoulDir })
113
+ export function init(opts = {}) {
114
+ if (opts.workspace) ws = opts.workspace;
115
+ if (opts.provider) getProvider = opts.provider;
116
+ if (opts.onEvent) realEmit = opts.onEvent;
117
+ if (opts.isBusy) isBusy = opts.isBusy;
118
+ if (opts.namespace) NS = opts.namespace;
119
+ if (opts.defaultProfiles) defaultProfiles = opts.defaultProfiles;
120
+ if (opts.defaultMission) DEFAULT_MISSION = opts.defaultMission;
121
+ if (opts.defaultSoulDir) DEFAULT_SOUL = opts.defaultSoulDir;
122
+ initCrossTab();
123
+ }
124
+ export function isThisTabLeader() { return isLeader; }
125
+ export function noteActivity() { lastActivity = Date.now(); if (running) running = 'interrupt'; }
126
+ export function isEnabled() { return enabled; }
127
+ export function isRunning() { return !!running; }
128
+ // Play/stop: persistido AQUÍ (fuente única) — cualquier botón que lo toque
129
+ // queda sincronizado, y la elección sobrevive a recargar la página.
130
+ export function wasEnabledLastSession() { try { return localStorage.getItem(K('ceoEnabled')) === '1'; } catch { return false; } }
131
+ // ¿el usuario llegó a decidir alguna vez (play o stop)? Distingue «nunca lo
132
+ // tocó» de «lo pausó a propósito» — solo lo primero debe auto-activarse al
133
+ // abrir la Mente; lo segundo hay que RESPETARLO, no pisarlo.
134
+ export function hasDecided() { try { return localStorage.getItem(K('ceoEnabled')) != null; } catch { return false; } }
135
+ export function enable() {
136
+ if (enabled) return;
137
+ enabled = true; lastActivity = Date.now(); schedule();
138
+ try { localStorage.setItem(K('ceoEnabled'), '1'); } catch { /* */ }
139
+ emit('sys', { type: 'status', text: 'CEO en guardia — trabajaré cuando estés ocioso' });
140
+ }
141
+ export function disable() {
142
+ enabled = false; running = false; if (timer) clearTimeout(timer);
143
+ try { localStorage.setItem(K('ceoEnabled'), '0'); } catch { /* */ }
144
+ emit('sys', { type: 'status', text: 'CEO en pausa' });
145
+ }
146
+
147
+ function schedule() { if (timer) clearTimeout(timer); timer = setTimeout(tick, TICK_MS); }
148
+
149
+ // «Pensar ahora» — salta la espera de ociosidad. Sigue respetando el
150
+ // semáforo: si otra pestaña es la líder, no compite por la GPU.
151
+ export async function forceCycle() {
152
+ if (!isLeader) { emit('ceo', { type: 'paused', text: 'Otra pestaña lleva el cerebro — ábrela ahí para forzar un ciclo.' }); return false; }
153
+ if (running) return false;
154
+ // isReady() puede ser síncrona (Code: handle en memoria) o async (Claw: consulta IndexedDB) — se espera siempre.
155
+ if (!(await ws?.isReady()) || !getProvider()) { emit('ceo', { type: 'paused', text: 'Necesito un espacio de trabajo abierto y un modelo cargado.' }); return false; }
156
+ try { await runCycle(); } finally { lastCycleEnd = Date.now(); }
157
+ return true;
158
+ }
159
+
160
+ async function tick() {
161
+ if (!enabled) return;
162
+ const idle = Date.now() - lastActivity;
163
+ const rested = Date.now() - lastCycleEnd > COOLDOWN_MS;
164
+ const mightRun = isLeader && !running && !isBusy() && idle >= IDLE_MS && rested && getProvider();
165
+ if (mightRun && await ws?.isReady()) {
166
+ try { await runCycle(); } catch { /* siguiente ciclo */ }
167
+ lastCycleEnd = Date.now();
168
+ }
169
+ schedule();
170
+ }
171
+
172
+ // helper: corre el agente con el proveedor actual sobre un prompt, emitiendo
173
+ // tokens/herramientas a un canal. Devuelve el texto final. Las tool-calls pasan
174
+ // SIEMPRE por el mismo runTool que usa el chat normal (Agent.handle real).
175
+ async function think(channel, task) {
176
+ const prov = getProvider();
177
+ if (!prov) return '';
178
+ const a = new Agent({ chat: (h, s, cb) => prov.chat(h, s, cb) });
179
+ let out = '';
180
+ await a.handle(task, ev => {
181
+ if (running === 'interrupt') throw new Error('interrumpido');
182
+ if (ev.type === 'token') { out += ev.text; emit(channel, { type: 'token', text: ev.text }); }
183
+ else if (ev.type === 'tool') emit(channel, { type: 'tool', text: humanizeTool(ev.call.tool, ev.call.args), tool: ev.call.tool, path: ev.call.args?.path || null });
184
+ else if (ev.type === 'tool_result') emit(channel, { type: 'tool_result', tool: ev.tool, text: String(ev.result || '').replace(/\s+/g, ' ').trim().slice(0, 100) });
185
+ else if (ev.type === 'text') { out = ev.text; }
186
+ });
187
+ return out;
188
+ }
189
+
190
+ async function runCycle() {
191
+ running = true;
192
+ cycleN++;
193
+ emit('ceo', { type: 'cycle', n: cycleN, text: 'Nuevo ciclo: reviso el espacio de trabajo y reparto el trabajo…' });
194
+
195
+ let tree = '';
196
+ try { tree = await ws.tree({ depth: 2 }); } catch { /* sin workspace */ }
197
+ emit('ceo', { type: 'survey', text: 'Panorama captado (' + tree.split('\n').length + ' entradas). Delegando…' });
198
+
199
+ const brief = (d) => `MISIÓN del equipo (fijada por el usuario): ${getMission()}\n` +
200
+ `Eres el jefe de ${d.name}. Dentro de esa misión, céntrate en: ${d.focus}. ` +
201
+ `Explora lo mínimo con tus herramientas y propón UNA mejora CONCRETA y accionable, ` +
202
+ `entendible por un humano. Sé breve. No modifiques nada existente: solo la propuesta.`;
203
+ const proposals = await Promise.all(getProfiles().map(async d => {
204
+ emit(d.id, { type: 'open', name: d.name, focus: d.focus });
205
+ try { const p = await think(d.id, brief(d)); emit(d.id, { type: 'done', text: p }); return { dept: d.name, text: p }; }
206
+ catch { emit(d.id, { type: 'done', text: '(interrumpido)' }); return null; }
207
+ }));
208
+ if (running === 'interrupt') { running = false; emit('ceo', { type: 'paused', text: 'Vuelves tú — dejo lo mío y te cedo el mando.' }); return; }
209
+
210
+ const valid = proposals.filter(Boolean).filter(p => p.text && p.text.length > 8);
211
+ const md = `# Propuestas de mejora — ciclo ${cycleN}\n\n**Misión:** ${getMission()}\n\n` +
212
+ valid.map(p => `## ${p.dept}\n${p.text}\n`).join('\n') +
213
+ `\n_— generado por el cerebro CEO de Elffuss mientras estabas ocioso._\n`;
214
+ const d = new Date();
215
+ const stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`;
216
+ const topic = slug((valid[0]?.text || 'ciclo').split(/[.,;\n]/)[0]) || 'ciclo';
217
+ const path = `${getSoulDir()}/${stamp}-${topic}.md`;
218
+ try {
219
+ await rotateSoul();
220
+ await ws.write({ path, content: md });
221
+ emit('ceo', { type: 'built', text: `Propuesta guardada en ${path}`, path, md, proposals: valid });
222
+ } catch (e) {
223
+ emit('ceo', { type: 'built', text: 'Propuesta lista (no pude escribir el fichero)', md, proposals: valid });
224
+ }
225
+ running = false;
226
+ }
227
+
228
+ // Rotación: si hay demasiados ficheros sueltos, los MÁS ANTIGUOS se consolidan
229
+ // (recopilan) en un único archivo.md y se borran — para no acabar con miles.
230
+ async function rotateSoul() {
231
+ try {
232
+ const soul = getSoulDir();
233
+ const names = (await ws.list(soul)).filter(n => n.endsWith('.md') && n !== 'archivo.md');
234
+ if (names.length < SOUL_CAP) return;
235
+ names.sort(); // el nombre empieza por fecha → orden cronológico
236
+ const excess = names.slice(0, names.length - SOUL_CAP + 1);
237
+ let archive = ''; try { archive = await ws.read({ path: `${soul}/archivo.md` }); } catch { archive = '# Archivo histórico del cerebro\n'; }
238
+ for (const name of excess) {
239
+ try { archive += `\n---\n## ${name}\n${await ws.read({ path: `${soul}/${name}` })}\n`; } catch { /* */ }
240
+ }
241
+ await ws.write({ path: `${soul}/archivo.md`, content: archive.slice(-80000) });
242
+ for (const name of excess) { try { await ws.remove(soul, name); } catch { /* */ } }
243
+ emit('ceo', { type: 'reprogram', text: `Consolidé ${excess.length} propuestas antiguas en archivo.md` });
244
+ } catch { /* soulDir aún sin crear o sin permiso: nada que rotar */ }
245
+ }
js/city.js ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vista «Ciudad 3D» — el proyecto abierto renderizado con el MOTOR REAL de
2
+ // VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0), vendorizado en js/vcc.
3
+ // Aquí solo hacemos de glue: escaneamos la carpeta abierta al árbol empaquetado
4
+ // que espera buildModel, montamos el render (cámara + OrbitControls + bloom) y
5
+ // conectamos el raycast → abrir fichero en Monaco. La geometría (ciudad anidada
6
+ // distrito→edificio→planta→…, cristal fresnel, fachadas con el árbol impreso,
7
+ // suelo de circuito, leyenda) es la del builder original, sin tocar.
8
+ import * as THREE from 'three';
9
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
10
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
11
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
12
+ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
13
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
14
+ import * as code from './tools/code.js';
15
+ import { buildModel } from './vcc/model.js';
16
+ import { buildCity } from './vcc/builder.js';
17
+ import { THEME } from './vcc/theme.js';
18
+
19
+ const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
20
+ const MAX_FILES = 8000; // tope de seguridad para no colgar el navegador en monorepos
21
+
22
+ let disposer = null;
23
+
24
+ // Escanea el handle del proyecto al formato empaquetado de VibeCodeViewer:
25
+ // dir = [nombre, [subdirs], [ficheros]] · fichero = [nombre, bytes]
26
+ async function scanTree(dir, budget) {
27
+ const dirs = [], files = [];
28
+ for await (const e of dir.values()) {
29
+ if (IGNORE.has(e.name)) continue;
30
+ (e.kind === 'directory' ? dirs : files).push(e);
31
+ }
32
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
33
+ files.sort((a, b) => a.name.localeCompare(b.name));
34
+ const outFiles = [];
35
+ for (const f of files) {
36
+ if (budget.n >= MAX_FILES) break;
37
+ let size = 0; try { size = (await f.getFile()).size; } catch { /* ilegible */ }
38
+ outFiles.push([f.name, size]); budget.n++;
39
+ }
40
+ const outDirs = [];
41
+ for (const d of dirs) { if (budget.n >= MAX_FILES) break; outDirs.push(await scanTree(d, budget)); }
42
+ return [dir.name, outDirs, outFiles];
43
+ }
44
+
45
+ export async function renderCity(container, onOpenFile) {
46
+ disposeCity();
47
+ const root = code.handle();
48
+ if (!root) { container.innerHTML = '<div class="view-loading">Abre un proyecto primero.</div>'; return; }
49
+ container.innerHTML = '<div class="view-loading">Construyendo la metrópolis del proyecto…</div>';
50
+
51
+ const budget = { n: 0 };
52
+ const tree = await scanTree(root, budget);
53
+ const model = buildModel(tree, null);
54
+ if (!model.files.length) { container.innerHTML = '<div class="view-loading">Proyecto vacío.</div>'; return; }
55
+
56
+ container.innerHTML =
57
+ '<canvas id="city-canvas"></canvas>' +
58
+ '<div class="view-head">Ciudad 3D · ' + model.files.length + (budget.n >= MAX_FILES ? '+' : '') +
59
+ ' ficheros en ' + model.districts.length + ' distritos' +
60
+ '<span class="view-hint">arrastra para orbitar · rueda para zoom · clic en un edificio → abrir</span></div>';
61
+ const canvas = container.querySelector('#city-canvas');
62
+ const W = container.clientWidth || 800, H = container.clientHeight || 600;
63
+
64
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
65
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
66
+ renderer.setSize(W, H, false);
67
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
68
+ renderer.toneMappingExposure = 1.0;
69
+ renderer.setClearColor(THEME.background, 1);
70
+
71
+ const scene = new THREE.Scene();
72
+ scene.background = new THREE.Color(THEME.background);
73
+ const extent = Math.max(model.city.box.sx, model.city.box.sz);
74
+ scene.fog = new THREE.FogExp2(THEME.fog, 0.62 / extent);
75
+
76
+ const camera = new THREE.PerspectiveCamera(55, W / H, 0.1, extent * 14);
77
+ camera.position.set(extent * 0.7, extent * 0.6, extent * 0.7);
78
+
79
+ const controls = new OrbitControls(camera, canvas);
80
+ controls.enableDamping = true;
81
+ controls.dampingFactor = 0.08;
82
+ controls.maxPolarAngle = Math.PI * 0.495; // no bajar del suelo
83
+ controls.target.set(0, extent * 0.04, 0);
84
+ controls.update();
85
+
86
+ // pipeline con bloom (la receta del builder: HDR + bloom solo en lo resaltado)
87
+ const composer = new EffectComposer(renderer);
88
+ composer.setSize(W, H);
89
+ composer.addPass(new RenderPass(scene, camera));
90
+ const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), THEME.bloom.strength, 0.25, THEME.bloom.threshold);
91
+ composer.addPass(bloom);
92
+ composer.addPass(new OutputPass());
93
+
94
+ const city = buildCity(scene, model, THEME);
95
+
96
+ // ── interacción: hover resalta edificios, clic abre el fichero ────────────
97
+ const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
98
+ let hovered = null, lastHover = 0, downXY = null;
99
+ const setPtr = e => { const r = canvas.getBoundingClientRect(); ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); };
100
+ const onMove = e => {
101
+ setPtr(e);
102
+ const now = performance.now(); if (now - lastHover < 50) return; lastHover = now;
103
+ ray.setFromCamera(ptr, camera);
104
+ const b = ray.intersectObjects(city.shells)[0]?.object.userData.building ?? null;
105
+ if (b !== hovered) {
106
+ if (hovered) city.paintNode(hovered, 0);
107
+ hovered = b;
108
+ if (b) city.paintNode(b, 0.16);
109
+ canvas.style.cursor = b ? 'pointer' : 'grab';
110
+ }
111
+ };
112
+ const onDown = e => { downXY = [e.clientX, e.clientY]; };
113
+ const onUp = e => {
114
+ if (!downXY) return;
115
+ const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
116
+ if (moved > 5) return; // fue un arrastre de cámara
117
+ setPtr(e); ray.setFromCamera(ptr, camera);
118
+ const fileHit = ray.intersectObject(city.filesMesh)[0];
119
+ const shellHit = ray.intersectObjects(city.shells)[0];
120
+ if (fileHit && (!shellHit || fileHit.distance < shellHit.distance + 2)) {
121
+ const f = model.files[fileHit.instanceId];
122
+ if (f) onOpenFile(f.rel);
123
+ } else if (shellHit) {
124
+ // clic en un edificio (carpeta): centra la cámara en él
125
+ const b = shellHit.object.userData.building;
126
+ controls.target.set(b.box.cx, b.box.cy, b.box.cz);
127
+ }
128
+ };
129
+ canvas.addEventListener('pointermove', onMove);
130
+ canvas.addEventListener('pointerdown', onDown);
131
+ canvas.addEventListener('pointerup', onUp);
132
+
133
+ const onResize = () => {
134
+ const w = container.clientWidth || W, h = container.clientHeight || H;
135
+ renderer.setSize(w, h, false); composer.setSize(w, h); bloom.resolution.set(w, h);
136
+ camera.aspect = w / h; camera.updateProjectionMatrix();
137
+ };
138
+ window.addEventListener('resize', onResize);
139
+
140
+ let raf = null;
141
+ const loop = () => { raf = requestAnimationFrame(loop); controls.update(); composer.render(); };
142
+ loop();
143
+
144
+ disposer = () => {
145
+ if (raf) cancelAnimationFrame(raf);
146
+ window.removeEventListener('resize', onResize);
147
+ canvas.removeEventListener('pointermove', onMove);
148
+ canvas.removeEventListener('pointerdown', onDown);
149
+ canvas.removeEventListener('pointerup', onUp);
150
+ controls.dispose();
151
+ city.dispose();
152
+ composer.dispose?.();
153
+ renderer.dispose();
154
+ };
155
+ }
156
+
157
+ export function disposeCity() { if (disposer) { disposer(); disposer = null; } }
js/context.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Gestor de contexto ACE-lite: eviction de historial por relevancia, portado
2
+ // del attention-context-eviction (ACE_R) de agentic-install a nivel de
3
+ // mensaje (IDF + BM25-lite; en navegador no hay acceso a las atenciones).
4
+ // turboquant es cuantización de PESOS — para contexto, esto es lo que aplica.
5
+ //
6
+ // Reglas: el presupuesto es en tokens (~4 chars/token). Se conservan SIEMPRE
7
+ // los últimos RECENT mensajes; los antiguos compiten por el hueco restante
8
+ // puntuados por BM25-lite contra la consulta actual. Los evictados se
9
+ // sustituyen por una marca de omisión para que el modelo sepa que falta algo.
10
+
11
+ const RECENT = 6;
12
+ const MAX_MSG_CHARS = 12000; // ningún mensaje (p.ej. un README enorme) revienta el contexto
13
+
14
+ // Trunca por el MEDIO conservando cabeza y cola (útil para código/documentos).
15
+ function clampMsg(m) {
16
+ const c = m.content || '';
17
+ if (c.length <= MAX_MSG_CHARS) return m;
18
+ const head = Math.floor(MAX_MSG_CHARS * 0.7);
19
+ const tail = MAX_MSG_CHARS - head - 40;
20
+ return { ...m, content: c.slice(0, head) + `\n… [recortado ${c.length - MAX_MSG_CHARS} caracteres] …\n` + c.slice(-tail) };
21
+ }
22
+ const STOP = new Set(('de la que el en y a los del se las por un para con no una su al lo como más pero sus le ' +
23
+ 'ya o este sí porque esta entre cuando muy sin sobre también me hasta hay donde quien desde todo nos durante ' +
24
+ 'todos uno les ni contra otros ese eso ante ellos e esto mí antes algunos qué unos yo otro otras otra él tanto ' +
25
+ 'esa estos mucho quienes nada muchos cual poco ella estar estas algunas algo nosotros tu te ti mi es son era eres').split(' '));
26
+
27
+ const tokens = s => (s.toLowerCase().match(/[a-záéíóúñü0-9_.]{2,}/g) || []).filter(w => !STOP.has(w));
28
+ const tokEstimate = m => Math.ceil((m.content || '').length / 4) + 4;
29
+
30
+ function bm25Scores(messages, query) {
31
+ const q = [...new Set(tokens(query))];
32
+ const docs = messages.map(m => tokens(m.content));
33
+ const N = docs.length || 1;
34
+ const avgLen = docs.reduce((s, d) => s + d.length, 0) / N || 1;
35
+ const df = new Map();
36
+ for (const d of docs) for (const w of new Set(d)) df.set(w, (df.get(w) || 0) + 1);
37
+ const k = 1.2, b = 0.75;
38
+ return docs.map(d => {
39
+ if (!d.length) return 0;
40
+ const tf = new Map();
41
+ for (const w of d) tf.set(w, (tf.get(w) || 0) + 1);
42
+ let score = 0;
43
+ for (const w of q) {
44
+ const f = tf.get(w);
45
+ if (!f) continue;
46
+ const idf = Math.log(1 + (N - (df.get(w) || 0) + 0.5) / ((df.get(w) || 0) + 0.5));
47
+ score += idf * (f * (k + 1)) / (f + k * (1 - b + b * (d.length / avgLen)));
48
+ }
49
+ return score;
50
+ });
51
+ }
52
+
53
+ // Recorta resultados de herramientas antiguos: la cola larga rara vez importa.
54
+ const shrink = m => m.content.startsWith('[resultado') && m.content.length > 600
55
+ ? { ...m, content: m.content.slice(0, 600) + '\n… (recortado por antigüedad)' }
56
+ : m;
57
+
58
+ export function packHistory(history, budgetTokens = 2200) {
59
+ if (!history.length) return history;
60
+ // los recientes también se recortan por mensaje: un solo tool-result gigante
61
+ // (README de un repo grande) reventaba el contexto → «Too many tokens».
62
+ const recent = history.slice(-RECENT).map(clampMsg);
63
+ let used = recent.reduce((s, m) => s + tokEstimate(m), 0);
64
+ const old = history.slice(0, -RECENT).map(shrink);
65
+ if (!old.length || used >= budgetTokens) return recent;
66
+
67
+ const query = [...history].reverse().find(m =>
68
+ m.role === 'user' && !m.content.startsWith('[resultado'))?.content || '';
69
+ const scores = bm25Scores(old, query);
70
+ // orden por relevancia; a igualdad gana lo más nuevo
71
+ const ranked = old.map((m, i) => ({ m, i, s: scores[i] }))
72
+ .sort((a, b) => (b.s - a.s) || (b.i - a.i));
73
+
74
+ const keep = new Set();
75
+ for (const { m, i } of ranked) {
76
+ const cost = tokEstimate(m);
77
+ if (used + cost > budgetTokens) continue;
78
+ used += cost;
79
+ keep.add(i);
80
+ }
81
+ const packed = [];
82
+ let dropped = 0;
83
+ old.forEach((m, i) => {
84
+ if (keep.has(i)) {
85
+ if (dropped) { packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` }); dropped = 0; }
86
+ packed.push(m);
87
+ } else dropped++;
88
+ });
89
+ if (dropped) packed.push({ role: 'user', content: `[…${dropped} mensajes antiguos omitidos…]` });
90
+ return [...packed, ...recent];
91
+ }
js/conversations.js ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Conversaciones: varias a la vez, como pestañas — cada una con su propio
2
+ // historial (su propio Agent) y su propia cola de mensajes pendientes.
3
+ // Cerrar una pestaña NO borra la conversación (solo la Historial sí). Todo
4
+ // persiste en IndexedDB, sobrevive a recargar.
5
+ //
6
+ // Procesado: cada conversación acepta y encola mensajes de forma totalmente
7
+ // independiente (mandar en la pestaña B mientras la A está pensando no
8
+ // bloquea nada). La generación en sí (la llamada real al modelo) se
9
+ // serializa con un cerrojo global — solo hay UN modelo cargado en el
10
+ // navegador y una sesión de inferencia no admite dos generate() a la vez sin
11
+ // corromperse — así que las conversaciones se turnan para «hablar» con el
12
+ // modelo, pero cada una sigue funcionando y aceptando mensajes de forma
13
+ // independiente mientras espera su turno.
14
+ import { Agent } from './agent.js';
15
+ import { runGoal, TASK_PREFIX } from './goal.js';
16
+ import * as db from './db.js';
17
+ import * as telemetry from './telemetry.js';
18
+
19
+ const NS = 'elffusscode';
20
+ const TABS_KEY = NS + '.openTabs';
21
+ const ACTIVE_KEY = NS + '.activeConv';
22
+ const HIST_CAP = 80; // mensajes por conversación que se persisten
23
+
24
+ let providerRef = null;
25
+ const convs = new Map(); // id -> { id, title, agent, queue, pumping, createdAt, updatedAt }
26
+ let openTabIds = [];
27
+ let activeId = null;
28
+ let onChange = () => {};
29
+
30
+ const genId = () => 'c' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
31
+ const cleanTitle = s => String(s).replace(/\s+/g, ' ').trim();
32
+
33
+ const GOAL_PREFIX = TASK_PREFIX + 'Objetivo: ';
34
+ export function titleFor(conv) {
35
+ if (conv.title) return conv.title;
36
+ const firstUser = conv.agent.history.find(m =>
37
+ m.role === 'user' && !m.content.startsWith('[resultado') &&
38
+ (!m.content.startsWith(TASK_PREFIX) || m.content.startsWith(GOAL_PREFIX)));
39
+ if (!firstUser) return 'Nueva conversación';
40
+ // el objetivo lleva un prefijo interno ([tarea-objetivo] Objetivo: …) que
41
+ // no debe filtrarse al título de la pestaña — se muestra el texto limpio.
42
+ const raw = firstUser.content.startsWith(GOAL_PREFIX) ? firstUser.content.slice(GOAL_PREFIX.length) : firstUser.content;
43
+ const t = cleanTitle(raw);
44
+ return t.length > 40 ? t.slice(0, 40) + '…' : t || 'Nueva conversación';
45
+ }
46
+
47
+ export function setProvider(mod) {
48
+ providerRef = mod;
49
+ for (const c of convs.values()) c.agent.setProvider(mod);
50
+ }
51
+ export function getProvider() { return providerRef; }
52
+
53
+ export function isBusy() {
54
+ for (const c of convs.values()) if (c.pumping || c.queue.length) return true;
55
+ return false;
56
+ }
57
+
58
+ 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),
66
+ queue: conv.queue, plan: conv.plan || null, createdAt: conv.createdAt, updatedAt: conv.updatedAt,
67
+ };
68
+ if (i >= 0) all[i] = rec; else all.push(rec);
69
+ await db.set('kv', 'conversations', all).catch(() => {});
70
+ }
71
+ function persistMeta() {
72
+ try { localStorage.setItem(TABS_KEY, JSON.stringify(openTabIds)); } catch { /* lleno */ }
73
+ try { localStorage.setItem(ACTIVE_KEY, activeId || ''); } catch { /* lleno */ }
74
+ }
75
+
76
+ function makeConv(id, saved) {
77
+ const conv = {
78
+ id, title: saved?.title || null, agent: new Agent(providerRef),
79
+ queue: saved?.queue ? [...saved.queue] : [], pumping: false, plan: saved?.plan || null,
80
+ createdAt: saved?.createdAt || Date.now(), updatedAt: saved?.updatedAt || Date.now(),
81
+ };
82
+ if (saved?.history) conv.agent.history = saved.history;
83
+ convs.set(id, conv);
84
+ return conv;
85
+ }
86
+
87
+ export function create() {
88
+ const conv = makeConv(genId());
89
+ openTabIds.push(conv.id);
90
+ activeId = conv.id;
91
+ persistMeta();
92
+ onChange('switch', conv.id);
93
+ return conv;
94
+ }
95
+
96
+ // reabre una conversación guardada (de la Historial) como pestaña, o activa
97
+ // la pestaña si ya estaba abierta
98
+ export async function open(id) {
99
+ if (!convs.has(id)) {
100
+ const saved = (await db.get('kv', 'conversations').catch(() => null) || []).find(c => c.id === id);
101
+ if (!saved) return null;
102
+ makeConv(id, saved);
103
+ }
104
+ if (!openTabIds.includes(id)) openTabIds.push(id);
105
+ activeId = id;
106
+ persistMeta();
107
+ onChange('switch', id);
108
+ return convs.get(id);
109
+ }
110
+
111
+ export function switchTo(id) {
112
+ if (!convs.has(id) || activeId === id) return;
113
+ activeId = id;
114
+ persistMeta();
115
+ onChange('switch', id);
116
+ }
117
+
118
+ // añade un mensaje fuera del ciclo normal de envío (p.ej. el informe
119
+ // autónomo del cerebro CEO) al historial REAL de una conversación — si no,
120
+ // solo viviría en el DOM y desaparecería al cambiar de pestaña o recargar.
121
+ export async function appendMessage(id, role, content) {
122
+ const c = convs.get(id);
123
+ if (!c) return;
124
+ c.agent.history.push({ role, content });
125
+ c.updatedAt = Date.now();
126
+ await persistConv(c);
127
+ }
128
+
129
+ // quita la pestaña de la vista — la conversación sigue viva en la Historial
130
+ export function closeTab(id) {
131
+ openTabIds = openTabIds.filter(x => x !== id);
132
+ if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null;
133
+ persistMeta();
134
+ if (!openTabIds.length) create(); else onChange('tabs');
135
+ }
136
+
137
+ // borra la conversación de verdad (desde la Historial)
138
+ export async function remove(id) {
139
+ convs.delete(id);
140
+ openTabIds = openTabIds.filter(x => x !== id);
141
+ if (activeId === id) activeId = openTabIds[openTabIds.length - 1] || null;
142
+ const all = (await db.get('kv', 'conversations').catch(() => null) || []).filter(c => c.id !== id);
143
+ await db.set('kv', 'conversations', all).catch(() => {});
144
+ persistMeta();
145
+ if (!openTabIds.length) create(); else onChange('tabs');
146
+ }
147
+
148
+ export async function listAll() {
149
+ const all = await db.get('kv', 'conversations').catch(() => null) || [];
150
+ return all.slice().sort((a, b) => b.updatedAt - a.updatedAt);
151
+ }
152
+
153
+ // ---- envío / procesado ----
154
+ // items de la cola: {kind:'chat'|'goal', text} — 'chat' es un turno normal
155
+ // (agent.handle), 'goal' dispara el planificador/ejecutor de goal.js. Las
156
+ // colas persistidas ANTES de que existiera el modo Objetivo guardaban texto
157
+ // suelto (strings) — normalizeItem() los sigue aceptando como 'chat'.
158
+ let inferenceLock = Promise.resolve();
159
+ const normalizeItem = it => (typeof it === 'string' ? { kind: 'chat', text: it } : it);
160
+
161
+ export function send(id, text) {
162
+ const conv = convs.get(id);
163
+ if (!conv) return;
164
+ conv.queue.push({ kind: 'chat', text });
165
+ persistConv(conv);
166
+ pump(conv);
167
+ }
168
+
169
+ // 🎯 Modo Objetivo: en vez de un turno de chat normal, el mensaje se trata
170
+ // como un objetivo — se planifica en tareas y se ejecutan una a una (ver
171
+ // goal.js, mismo patrón planificador/ejecutor de clonagent).
172
+ export function startGoal(id, text) {
173
+ const conv = convs.get(id);
174
+ if (!conv) return;
175
+ conv.queue.push({ kind: 'goal', text });
176
+ persistConv(conv);
177
+ pump(conv);
178
+ }
179
+
180
+ async function pump(conv) {
181
+ if (conv.pumping) return;
182
+ conv.pumping = true;
183
+ onChange('pumping', conv.id, true);
184
+ while (conv.queue.length) {
185
+ const item = normalizeItem(conv.queue[0]);
186
+ const myTurn = inferenceLock;
187
+ let release;
188
+ inferenceLock = new Promise(r => { release = r; });
189
+ await myTurn; // espera su turno de inferencia (un solo modelo cargado, no admite 2 generate() a la vez)
190
+ try {
191
+ // un fallo aquí (del modelo, de una tool, o del propio repintado en
192
+ // main.js) NUNCA debe dejar la conversación "pumping" para siempre —
193
+ // eso bloquearía sus futuros mensajes Y al cerebro CEO (isBusy()).
194
+ const onEvent = ev => {
195
+ try { onChange('event', conv.id, ev); } catch (e) { console.error('[elffuss] fallo pintando un evento de chat', e); }
196
+ };
197
+ if (item.kind === 'goal') await runGoal(conv, item.text, onEvent);
198
+ else await conv.agent.handle(item.text, onEvent);
199
+ } catch (e) {
200
+ console.error('[elffuss] fallo procesando el turno', e);
201
+ telemetry.reportError('pump: ' + (e?.message || e), { stack: e?.stack || '' });
202
+ try { onChange('event', conv.id, { type: 'error', text: 'Error interno: ' + (e?.message || e) }); } catch { /* ya está registrado arriba */ }
203
+ } finally { release(); }
204
+ conv.queue.shift();
205
+ conv.updatedAt = Date.now();
206
+ await persistConv(conv);
207
+ }
208
+ conv.pumping = false;
209
+ onChange('pumping', conv.id, false);
210
+ }
211
+
212
+ export async function init({ onEvent }) {
213
+ onChange = onEvent;
214
+
215
+ // migración única: el esquema viejo era UNA sola conversación (kv/history + kv/queue)
216
+ const already = await db.get('kv', 'conversations').catch(() => null);
217
+ if (!already) {
218
+ const oldHistory = await db.get('kv', 'history').catch(() => null);
219
+ if (oldHistory?.length) {
220
+ const oldQueue = await db.get('kv', 'queue').catch(() => null);
221
+ await db.set('kv', 'conversations', [{
222
+ id: genId(), title: null, history: oldHistory, queue: oldQueue || [],
223
+ createdAt: Date.now(), updatedAt: Date.now(),
224
+ }]).catch(() => {});
225
+ }
226
+ }
227
+
228
+ let tabIds = [], active = null;
229
+ try { tabIds = JSON.parse(localStorage.getItem(TABS_KEY) || '[]'); } catch { /* corrupto */ }
230
+ try { active = localStorage.getItem(ACTIVE_KEY) || null; } catch { /* */ }
231
+ const all = await db.get('kv', 'conversations').catch(() => null) || [];
232
+
233
+ for (const id of tabIds) {
234
+ const saved = all.find(c => c.id === id);
235
+ if (saved) { makeConv(id, saved); openTabIds.push(id); }
236
+ }
237
+ if (active && convs.has(active)) activeId = active;
238
+ else if (openTabIds.length) activeId = openTabIds[0];
239
+
240
+ if (!openTabIds.length) {
241
+ // nada abierto (primera vez, o se perdió el estado de pestañas): retoma
242
+ // la conversación más reciente si hay alguna, si no, una nueva vacía
243
+ const mostRecent = all.slice().sort((a, b) => b.updatedAt - a.updatedAt)[0];
244
+ if (mostRecent) { makeConv(mostRecent.id, mostRecent); openTabIds.push(mostRecent.id); activeId = mostRecent.id; }
245
+ else create();
246
+ }
247
+ for (const conv of convs.values()) if (conv.queue.length) pump(conv);
248
+ persistMeta();
249
+ }
js/db.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // IndexedDB mínima: stores clave→valor.
2
+ const NAME = 'elffuss-code', VERSION = 2;
3
+ const STORES = ['kv', 'apps', 'tasks', 'vault', 'fs', 'memory'];
4
+ let dbPromise = null;
5
+
6
+ function open() {
7
+ if (dbPromise) return dbPromise;
8
+ dbPromise = new Promise((resolve, reject) => {
9
+ const req = indexedDB.open(NAME, VERSION);
10
+ req.onupgradeneeded = () => {
11
+ for (const s of STORES)
12
+ if (!req.result.objectStoreNames.contains(s)) req.result.createObjectStore(s);
13
+ };
14
+ req.onsuccess = () => resolve(req.result);
15
+ req.onerror = () => reject(req.error);
16
+ });
17
+ return dbPromise;
18
+ }
19
+
20
+ function tx(store, mode, fn) {
21
+ return open().then(db => new Promise((resolve, reject) => {
22
+ const req = fn(db.transaction(store, mode).objectStore(store));
23
+ req.onsuccess = () => resolve(req.result);
24
+ req.onerror = () => reject(req.error);
25
+ }));
26
+ }
27
+
28
+ export const get = (store, key) => tx(store, 'readonly', s => s.get(key));
29
+ export const set = (store, key, val) => tx(store, 'readwrite', s => s.put(val, key));
30
+ export const del = (store, key) => tx(store, 'readwrite', s => s.delete(key));
31
+ export const keys = (store) => tx(store, 'readonly', s => s.getAllKeys());
32
+ export const all = (store) => tx(store, 'readonly', s => s.getAll());
js/goal.js ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Modo Objetivo (🎯 Goal): mismo patrón planificador/ejecutor que clonagent
2
+ // (~/work2026/clonagent) — un PLANIFICADOR descompone el objetivo en una
3
+ // lista de tareas con estado (pending/in-progress/done/failed/skipped), y un
4
+ // EJECUTOR las va ejecutando una a una hasta que no queda ninguna pendiente,
5
+ // marcando el resultado de cada una. Si una tarea falla, las siguientes se
6
+ // saltan (cascada) en vez de seguir a ciegas — igual que el fallo→skip de
7
+ // dependencias de clonagent.
8
+ //
9
+ // Aquí el "ejecutor" de cada tarea es el MISMO bucle de tool-calling que ya
10
+ // usa el chat normal (agent.handle): cada tarea del plan se manda como si
11
+ // fuera un mensaje de usuario, con todo el historial previo como contexto —
12
+ // así una tarea puede apoyarse en lo que hizo la anterior, igual que la
13
+ // sesión del ejecutor de clonagent tiene a la vista el fichero de tareas
14
+ // entero.
15
+ import { toolHelp, snapshot } from './tools/index.js';
16
+
17
+ const MAX_TASK_RETRIES = 2;
18
+ const TASK_PREFIX = '[tarea-objetivo] ';
19
+
20
+ function plannerPrompt(goalText, context) {
21
+ return `ROL: PLANIFICADOR
22
+
23
+ Objetivo del usuario: "${goalText}"
24
+
25
+ CONTEXTO AHORA (estado real del IDE):
26
+ ${context}
27
+
28
+ Herramientas disponibles para la fase de EJECUCIÓN (aquí solo planificas, no las llames):
29
+ ${toolHelp()}
30
+
31
+ Descompón el objetivo en una lista de tareas CONCRETAS, ORDENADAS y ejecutables una tras otra con esas herramientas. Entre 2 y 6 tareas normalmente basta — no fragmentes de más, cada tarea debe ser un paso real con sentido propio.
32
+
33
+ Responde SOLO con este JSON, nada de texto ni fences alrededor:
34
+ {"plan": "una frase resumiendo el enfoque", "tasks": [{"title": "título corto", "description": "qué hay que hacer, concreto y accionable"}]}`;
35
+ }
36
+
37
+ // Extrae el primer objeto JSON balanceado del texto (el modelo a veces mete
38
+ // prosa alrededor pese a la instrucción de "SOLO JSON").
39
+ function extractJson(text) {
40
+ const start = text.indexOf('{');
41
+ if (start < 0) return null;
42
+ let depth = 0, inStr = false, esc = false;
43
+ for (let i = start; i < text.length; i++) {
44
+ const ch = text[i];
45
+ if (esc) { esc = false; continue; }
46
+ if (ch === '\\') { esc = true; continue; }
47
+ if (ch === '"') inStr = !inStr;
48
+ else if (!inStr) {
49
+ if (ch === '{') depth++;
50
+ else if (ch === '}' && --depth === 0) {
51
+ try { return JSON.parse(text.slice(start, i + 1)); } catch { return null; }
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // Llamada ÚNICA al modelo con el rol de planificador — no toca agent.history
59
+ // (el plan resultante se guarda aparte; el objetivo en sí SÍ se añade al
60
+ // historial en runGoal, para que las tareas puedan apoyarse en él).
61
+ export async function planGoal(agent, goalText) {
62
+ const context = await snapshot().catch(() => '');
63
+ const raw = await agent.provider.chat([{ role: 'user', content: goalText }], plannerPrompt(goalText, context), () => {});
64
+ const parsed = extractJson(raw);
65
+ if (!parsed || !Array.isArray(parsed.tasks) || !parsed.tasks.length) {
66
+ throw new Error('el planificador no devolvió una lista de tareas válida');
67
+ }
68
+ return {
69
+ planText: String(parsed.plan || '').trim(),
70
+ tasks: parsed.tasks.slice(0, 10).map(t => ({
71
+ title: String(t.title || 'tarea').trim(),
72
+ description: String(t.description || t.title || '').trim(),
73
+ })),
74
+ };
75
+ }
76
+
77
+ function clonePlan(plan) { return JSON.parse(JSON.stringify(plan)); }
78
+
79
+ // Orquesta plan → ejecución. onEvent es el MISMO canal que usa el chat
80
+ // normal (conversations.js lo conecta a onConvEvent) — los eventos de las
81
+ // tareas (token/tool/tool_result/text/error) se reenvían tal cual, y además
82
+ // se emiten 'plan' / 'plan_update' / 'plan_complete' para la tarjeta de plan.
83
+ export async function runGoal(conv, goalText, onEvent) {
84
+ conv.agent.history.push({ role: 'user', content: TASK_PREFIX + 'Objetivo: ' + goalText });
85
+
86
+ const plan = {
87
+ id: 'g' + Date.now().toString(36), goal: goalText, planText: '', status: 'planning',
88
+ tasks: [], createdAt: Date.now(), updatedAt: Date.now(),
89
+ };
90
+ conv.plan = plan;
91
+ onEvent({ type: 'plan', plan: clonePlan(plan) });
92
+
93
+ let parsed;
94
+ try {
95
+ parsed = await planGoal(conv.agent, goalText);
96
+ } catch (e) {
97
+ plan.status = 'failed';
98
+ plan.updatedAt = Date.now();
99
+ onEvent({ type: 'plan_update', plan: clonePlan(plan) });
100
+ onEvent({ type: 'error', text: 'No pude planificar el objetivo: ' + e.message });
101
+ return;
102
+ }
103
+
104
+ plan.planText = parsed.planText;
105
+ plan.tasks = parsed.tasks.map((t, i) => ({
106
+ id: 't' + (i + 1), title: t.title, description: t.description,
107
+ status: 'pending', retries: 0, result: '',
108
+ }));
109
+ plan.status = 'running';
110
+ plan.updatedAt = Date.now();
111
+ onEvent({ type: 'plan_update', plan: clonePlan(plan) });
112
+
113
+ let cascadeFail = false;
114
+ for (const task of plan.tasks) {
115
+ if (cascadeFail) { task.status = 'skipped'; plan.updatedAt = Date.now(); onEvent({ type: 'plan_update', plan: clonePlan(plan) }); continue; }
116
+
117
+ task.status = 'in-progress';
118
+ plan.updatedAt = Date.now();
119
+ onEvent({ type: 'plan_update', plan: clonePlan(plan) });
120
+
121
+ const taskPrompt = `${TASK_PREFIX}Tarea ${task.id} del objetivo «${goalText}»: ${task.title}\n${task.description}`;
122
+ let done = false;
123
+ while (!done && task.retries <= MAX_TASK_RETRIES) {
124
+ let sawError = false, errText = '';
125
+ await conv.agent.handle(taskPrompt, ev => {
126
+ if (ev.type === 'error') { sawError = true; errText = ev.text; }
127
+ onEvent(ev);
128
+ });
129
+ if (!sawError) { done = true; }
130
+ else {
131
+ task.retries++;
132
+ task.result = errText;
133
+ if (task.retries > MAX_TASK_RETRIES) break;
134
+ }
135
+ }
136
+ task.status = done ? 'done' : 'failed';
137
+ plan.updatedAt = Date.now();
138
+ onEvent({ type: 'plan_update', plan: clonePlan(plan) });
139
+ if (!done) cascadeFail = true; // el resto se saltan, igual que la cascada de dependencias fallidas en clonagent
140
+ }
141
+
142
+ plan.status = plan.tasks.some(t => t.status === 'failed') ? 'failed' : 'done';
143
+ plan.updatedAt = Date.now();
144
+ onEvent({ type: 'plan_complete', plan: clonePlan(plan) });
145
+ }
146
+
147
+ export { TASK_PREFIX };
js/humanize.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Frase legible por humano para una tool-call («leyendo app.js…»). Compartido
2
+ // por el cerebro CEO (ceo.js) y el streaming en vivo del chat — así nunca se
3
+ // le enseña JSON crudo al usuario, siempre la misma redacción. Cubre los
4
+ // nombres de herramienta de Elffuss Code (code.*, terminal.*) y de Elffuss
5
+ // Claw (fs.*, app.*, web.*, skill.*, memory.*, tasks.*) — un tool desconocido
6
+ // cae al genérico «nombre argumento» sin romper nada.
7
+ export function humanizeTool(name, args) {
8
+ const p = args?.path, q = args?.query, c = args?.command, n = args?.name;
9
+ switch (name) {
10
+ case 'code.read': case 'fs.read': return `leyendo ${p}…`;
11
+ case 'code.write': case 'fs.write': return `escribiendo ${p}…`;
12
+ case 'code.tree': return `explorando ${p || 'el proyecto'}…`;
13
+ case 'fs.list': return `explorando ${p || 'la carpeta'}…`;
14
+ case 'code.search': return `buscando «${q}»…`;
15
+ case 'fs.pick_folder': return 'pidiendo acceso a una carpeta…';
16
+ case 'fs.copy': return `copiando ${args?.pattern || 'archivos'}…`;
17
+ case 'fs.watch': return `vigilando ${args?.from || 'una carpeta'}…`;
18
+ case 'terminal.run': return `ejecutando: ${c}`;
19
+ case 'app.create': return `creando la app «${n}»…`;
20
+ case 'app.open': return `abriendo la app «${n}»…`;
21
+ case 'skill.create': return `creando la skill «${n}»…`;
22
+ case 'memory.save': return `recordando: ${args?.fact}…`;
23
+ case 'tasks.add': return 'programando una tarea…';
24
+ case 'web.search': return `buscando en internet «${q}»…`;
25
+ case 'web.images': return `buscando imágenes de «${q}»…`;
26
+ case 'web.fetch': return `leyendo ${p || args?.url}…`;
27
+ default: return name + (p || q || c || n ? ' ' + (p || q || c || n) : '');
28
+ }
29
+ }
30
+
31
+ // Detecta si el buffer que va llegando EN STREAMING ha entrado en un bloque de
32
+ // tool-call (```tool { … }) y, si es así, devuelve una frase humana en vez del
33
+ // JSON crudo — «preparando una acción…» hasta que el nombre de la tool sea
34
+ // legible, luego «leyendo app.js…» tan pronto como el campo aparezca, aunque
35
+ // el JSON todavía no haya cerrado.
36
+ export function humanizeStreamPreview(buf) {
37
+ if (buf.search(/```/) === -1 && !/^\s*\{\s*"tool"/.test(buf)) return null;
38
+ const toolM = buf.match(/"tool"\s*:\s*"([\w.]+)"/);
39
+ if (!toolM) return 'preparando una acción…';
40
+ const pathM = buf.match(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"/);
41
+ const queryM = buf.match(/"query"\s*:\s*"((?:[^"\\]|\\.)*)"/);
42
+ const cmdM = buf.match(/"command"\s*:\s*"((?:[^"\\]|\\.)*)"/);
43
+ const nameM = buf.match(/"name"\s*:\s*"((?:[^"\\]|\\.)*)"/);
44
+ const out = humanizeTool(toolM[1], { path: pathM?.[1], query: queryM?.[1], command: cmdM?.[1], name: nameM?.[1] });
45
+ // el nombre de la tool ya se ve pero su campo (path/query/…) aún no llegó:
46
+ // mejor el genérico que un «leyendo undefined…» a medio streamear
47
+ return /undefined/.test(out) ? 'preparando una acción…' : out;
48
+ }
js/icons.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Iconos estilo VS Code, en SVG inline propio (sin dependencias).
2
+ // Archivos: insignia con la abreviatura y el color canónico del lenguaje.
3
+ const FILE_TYPES = {
4
+ js: ['JS', '#f1dd3f'], mjs: ['JS', '#f1dd3f'], cjs: ['JS', '#f1dd3f'],
5
+ ts: ['TS', '#3178c6'], tsx: ['TS', '#3178c6'], jsx: ['JS', '#61dafb'],
6
+ py: ['PY', '#4b8bbe'], rs: ['RS', '#dea584'], go: ['GO', '#00add8'],
7
+ java: ['J', '#e76f00'], c: ['C', '#659ad2'], h: ['H', '#659ad2'],
8
+ cpp: ['C+', '#f34b7d'], cs: ['C#', '#178600'],
9
+ json: ['{}', '#cbcb41'], yaml: ['Y', '#cb4b4b'], yml: ['Y', '#cb4b4b'],
10
+ toml: ['T', '#9c4221'], xml: ['<>', '#e37933'],
11
+ html: ['<>', '#e34c26'], css: ['#', '#9b7cf6'], scss: ['#', '#c6538c'],
12
+ md: ['M', '#519aba'], txt: ['≡', '#8b93a8'], csv: ['⊞', '#89e051'],
13
+ sh: ['$', '#89e051'], sql: ['DB', '#e38c00'],
14
+ svg: ['SV', '#ffb13b'], png: ['IMG', '#a074c4'], jpg: ['IMG', '#a074c4'],
15
+ gif: ['IMG', '#a074c4'], ico: ['IMG', '#a074c4'],
16
+ lock: ['🔒', '#8b93a8'], gitignore: ['GIT', '#f05033'],
17
+ };
18
+
19
+ export function fileIcon(name) {
20
+ const base = name.toLowerCase();
21
+ const ext = base.startsWith('.git') ? 'gitignore' : (base.split('.').pop() || '');
22
+ const [label, color] = FILE_TYPES[ext] || ['·', '#6b7391'];
23
+ const fontSize = label.length > 2 ? 5 : (label.length === 2 ? 6.5 : 8);
24
+ return `<svg class="fico" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
25
+ <rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="${color}1f" stroke="${color}" stroke-width="1"/>
26
+ <text x="8" y="${label.length > 2 ? 10.4 : 10.8}" font-size="${fontSize}" text-anchor="middle"
27
+ fill="${color}" font-family="system-ui" font-weight="700">${label}</text></svg>`;
28
+ }
29
+
30
+ export function folderIcon() {
31
+ return `<svg class="fico chev" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
32
+ <path class="chevron" d="M6 4.5 L10 8 L6 11.5" fill="none" stroke="#8b93a8" stroke-width="1.6" stroke-linecap="round"/>
33
+ </svg><svg class="fico" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
34
+ <path d="M1.5 4 a1 1 0 0 1 1-1 h3.2 l1.6 1.8 h6.2 a1 1 0 0 1 1 1 v6.7 a1 1 0 0 1 -1 1 h-11 a1 1 0 0 1 -1-1 Z"
35
+ fill="#dcb67a33" stroke="#dcb67a" stroke-width="1"/></svg>`;
36
+ }
37
+
38
+ // Codicons de UI (estilo VS Code, sin emojis). Trazos con currentColor.
39
+ const ico = (p, sw = 1.6) => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
40
+ export const UI = {
41
+ clear: ico('<path d="M4 7h16"/><path d="M9 7V5h6v2"/><path d="M6 7l1 13h10l1-13"/><path d="M10 11v6M14 11v6"/>'),
42
+ history: ico('<circle cx="12" cy="13" r="8"/><path d="M12 9v4l3 2"/><path d="M5 3.5l1.5 2.5M19 3.5l-1.5 2.5"/>'),
43
+ gear: ico('<circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8"/>'),
44
+ send: ico('<path d="M4 12l16-8-6 16-3.5-6.5L4 12z"/>'),
45
+ add: ico('<path d="M12 5v14M5 12h14"/>', 1.8),
46
+ slash: ico('<path d="M15 5L9 19"/>', 1.8),
47
+ editor: ico('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/>'),
48
+ chat: ico('<path d="M4 5h16v11H9l-5 4z"/>'),
49
+ puzzle: ico('<path d="M10 4h4a1 1 0 0 1 1 1v2a1.5 1.5 0 1 0 3 0V6h2a1 1 0 0 1 1 1v4h-1a1.5 1.5 0 1 0 0 3h1v4a1 1 0 0 1-1 1h-4v-1a1.5 1.5 0 1 0-3 0v1H6a1 1 0 0 1-1-1v-4h1a1.5 1.5 0 1 0 0-3H5V7a1 1 0 0 1 1-1h4z"/>'),
50
+ close: ico('<path d="M6 6l12 12M18 6L6 18"/>', 1.8),
51
+ code: ico('<path d="M9 8l-5 4 5 4M15 8l5 4-5 4"/>'),
52
+ graph: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="8" r="2.4"/><circle cx="12" cy="18" r="2.4"/><path d="M8 7l8 1M7 8l4 8M16 10l-3 6"/></svg>`,
53
+ city: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 21h18"/><rect x="4" y="10" width="5" height="11"/><rect x="10" y="5" width="5" height="16"/><rect x="16" y="13" width="4" height="8"/></svg>`,
54
+ terminal: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M13 15h4"/></svg>`,
55
+ };
56
+
57
+ // Codicons de la barra de actividad (trazos simples estilo VS Code).
58
+ export const ACTIVITY = {
59
+ files: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
60
+ <path d="M7 4h7l4 4v12H7z"/><path d="M14 4v4h4"/><path d="M4 8v13h10" opacity=".55"/></svg>`,
61
+ search: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
62
+ <circle cx="10.5" cy="10.5" r="5.5"/><path d="M15 15l5 5"/></svg>`,
63
+ gear: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
64
+ <circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8"/></svg>`,
65
+ chat: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
66
+ <path d="M4 5h16v11H9l-5 4z"/></svg>`,
67
+ };
js/ide.js ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // IDE: Monaco (el editor de VS Code) + árbol de archivos + pestañas.
2
+ import * as code from './tools/code.js';
3
+ import { fileIcon, folderIcon } from './icons.js';
4
+ import { renderMarkdown } from './md.js';
5
+
6
+ let editor = null;
7
+ let monacoRef = null;
8
+ const tabs = []; // { path, model, dirty, preview }
9
+ let active = null;
10
+ let onDirtyChange = () => {};
11
+
12
+ const LANG = {
13
+ js: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript',
14
+ jsx: 'javascript', json: 'json', html: 'html', css: 'css', md: 'markdown',
15
+ py: 'python', rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c',
16
+ cpp: 'cpp', sh: 'shell', yml: 'yaml', yaml: 'yaml', toml: 'ini',
17
+ svg: 'xml', xml: 'xml', sql: 'sql', txt: 'plaintext',
18
+ };
19
+ const langOf = p => LANG[p.split('.').pop().toLowerCase()] || 'plaintext';
20
+ const $ = id => document.getElementById(id);
21
+
22
+ export async function initEditor() {
23
+ await new Promise(res => {
24
+ const s = document.createElement('script');
25
+ s.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
26
+ s.onload = res;
27
+ document.head.appendChild(s);
28
+ });
29
+ window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
30
+ await new Promise(res => window.require(['vs/editor/editor.main'], res));
31
+ monacoRef = window.monaco;
32
+ editor = monacoRef.editor.create($('editor'), {
33
+ theme: 'vs-dark',
34
+ automaticLayout: true,
35
+ fontSize: 13,
36
+ minimap: { enabled: false },
37
+ padding: { top: 8 },
38
+ });
39
+ editor.addCommand(monacoRef.KeyMod.CtrlCmd | monacoRef.KeyCode.KeyS, saveActive);
40
+ editor.onDidChangeModelContent(() => {
41
+ const tab = tabs.find(t => t.path === active);
42
+ if (tab && !tab.dirty) { tab.dirty = true; renderTabs(); }
43
+ });
44
+ // los cambios del agente se reflejan al instante en el editor
45
+ code.setOnFileWritten((path, content) => {
46
+ const tab = tabs.find(t => t.path === path);
47
+ if (tab && tab.model.getValue() !== content) {
48
+ tab.model.setValue(content);
49
+ tab.dirty = false;
50
+ renderTabs();
51
+ if (path === active) applyView();
52
+ }
53
+ refreshTree();
54
+ });
55
+ }
56
+
57
+ export function gotoLine(n) {
58
+ if (!editor || !n) return;
59
+ editor.revealLineInCenter(n);
60
+ editor.setPosition({ lineNumber: n, column: 1 });
61
+ editor.focus();
62
+ }
63
+ export function triggerEditor(actionId) {
64
+ editor?.getAction(actionId)?.run();
65
+ editor?.focus();
66
+ }
67
+ export function hasEditor() { return !!editor; }
68
+
69
+ export async function openFile(path) {
70
+ let tab = tabs.find(t => t.path === path);
71
+ if (!tab) {
72
+ let content;
73
+ try { content = await code.read({ path }); }
74
+ catch (e) {
75
+ // la ruta exacta no existe (típico: un enlace del chat solo mencionaba
76
+ // el NOMBRE, «config.py», y el fichero real vive en una subcarpeta) →
77
+ // si hay una única coincidencia por nombre en el proyecto, ábrela ella
78
+ // en vez de rendirte; con varias o ninguna, el error de siempre.
79
+ const base = path.split('/').pop();
80
+ const hits = await code.findByName?.(base, 2).catch(() => []) || [];
81
+ if (hits.length === 1 && hits[0] !== path) return openFile(hits[0]);
82
+ return alertBar('No pude abrir ' + path + ': ' + e.message);
83
+ }
84
+ tab = { path, model: monacoRef.editor.createModel(content, langOf(path)), dirty: false, preview: false };
85
+ tabs.push(tab);
86
+ }
87
+ active = path;
88
+ code.setCurrentFile(path);
89
+ editor.setModel(tab.model);
90
+ setEmptyState(false);
91
+ renderTabs();
92
+ await applyView();
93
+ }
94
+
95
+ // modo «Vista previa» de un tab .md/.html: en vez del código fuente en
96
+ // Monaco, muestra el markdown renderizado (mismo motor que el chat y la
97
+ // Mente) o el HTML en un iframe real.
98
+ const PREVIEWABLE = new Set(['markdown', 'html']);
99
+
100
+ // resuelve una ruta relativa (href/src) contra la carpeta del propio fichero
101
+ function resolveRelative(ownPath, rel) {
102
+ if (/^([a-z][\w+.-]*:)?\/\//i.test(rel) || rel.startsWith('data:') || rel.startsWith('#')) return null; // externa/absoluta: no tocar
103
+ const baseDir = ownPath.includes('/') ? ownPath.slice(0, ownPath.lastIndexOf('/')) : '';
104
+ const segs = (baseDir ? baseDir.split('/') : []).concat(rel.split('/'));
105
+ const out = [];
106
+ for (const s of segs) { if (!s || s === '.') continue; if (s === '..') out.pop(); else out.push(s); }
107
+ return out.join('/');
108
+ }
109
+ // un iframe con srcdoc no tiene base URL propia: <link href="style.css"> o
110
+ // <script src="app.js"> del proyecto real NUNCA se resuelven (por eso salía
111
+ // sin CSS) — se leen del proyecto de verdad y se incrustan antes de mostrarlo.
112
+ // El iframe de Vista previa va con sandbox="allow-scripts" A PROPÓSITO, SIN
113
+ // allow-same-origin (aislado de tu proyecto real — ver applyView). Pero eso
114
+ // hace que referenciar `localStorage`/`sessionStorage` LANCE una excepción
115
+ // síncrona — y como suele ser la primera línea de un script (guardar datos
116
+ // es de lo más común), el resto del fichero entero se queda sin ejecutar,
117
+ // sin aviso: los botones no hacen nada y parece que la app está rota. Se
118
+ // incrusta un shim en memoria (nunca toca almacenamiento real, se pierde al
119
+ // cerrar la vista previa) SOLO si de verdad hace falta — si el sandbox
120
+ // permitiera acceso real, no se toca nada.
121
+ const STORAGE_SHIM = `<script>(()=>{try{window.localStorage;return}catch(e){}` +
122
+ `const m=()=>{const d=new Map();return{getItem:k=>d.has(''+k)?d.get(''+k):null,` +
123
+ `setItem:(k,v)=>{d.set(''+k,''+v)},removeItem:k=>{d.delete(''+k)},clear:()=>{d.clear()},` +
124
+ `key:i=>[...d.keys()][i]??null,get length(){return d.size}}};` +
125
+ `for(const p of ['localStorage','sessionStorage'])` +
126
+ `try{Object.defineProperty(window,p,{value:m(),configurable:true})}catch(e){}})()<\/script>`;
127
+
128
+ async function inlineLocalAssets(html, ownPath) {
129
+ const doc = new DOMParser().parseFromString(html, 'text/html');
130
+ (doc.head || doc.documentElement).insertAdjacentHTML('afterbegin', STORAGE_SHIM);
131
+ for (const link of doc.querySelectorAll('link[rel~="stylesheet"][href]')) {
132
+ const path = resolveRelative(ownPath, link.getAttribute('href'));
133
+ if (!path) continue;
134
+ try {
135
+ const style = doc.createElement('style');
136
+ style.textContent = await code.read({ path });
137
+ link.replaceWith(style);
138
+ } catch { /* no se pudo leer: se deja el link (el navegador lo ignorará sin más) */ }
139
+ }
140
+ for (const script of doc.querySelectorAll('script[src]')) {
141
+ const path = resolveRelative(ownPath, script.getAttribute('src'));
142
+ if (!path) continue;
143
+ try {
144
+ const inline = doc.createElement('script');
145
+ if (script.type) inline.type = script.type;
146
+ inline.textContent = await code.read({ path });
147
+ script.replaceWith(inline);
148
+ } catch { /* deja el script tal cual */ }
149
+ }
150
+ return '<!doctype html>\n' + doc.documentElement.outerHTML;
151
+ }
152
+
153
+ async function applyView() {
154
+ const tab = tabs.find(t => t.path === active);
155
+ const lang = tab ? langOf(tab.path) : null;
156
+ const showPreview = !!(tab && tab.preview && PREVIEWABLE.has(lang));
157
+ $('editor').style.display = showPreview ? 'none' : '';
158
+ let pane = document.getElementById('md-preview-pane');
159
+ if (showPreview) {
160
+ if (!pane) {
161
+ pane = document.createElement('div');
162
+ pane.id = 'md-preview-pane';
163
+ $('editor').parentElement.appendChild(pane);
164
+ }
165
+ pane.classList.toggle('html-preview', lang === 'html');
166
+ if (lang === 'html') {
167
+ pane.replaceChildren();
168
+ const frame = document.createElement('iframe');
169
+ frame.sandbox = 'allow-scripts'; // sin allow-same-origin: aislado de tu proyecto real
170
+ frame.srcdoc = await inlineLocalAssets(tab.model.getValue(), tab.path);
171
+ pane.appendChild(frame);
172
+ } else {
173
+ pane.innerHTML = renderMarkdown(tab.model.getValue());
174
+ }
175
+ pane.style.display = '';
176
+ } else if (pane) {
177
+ pane.style.display = 'none';
178
+ }
179
+ }
180
+ async function togglePreview(path) {
181
+ // el fichero puede no tener pestaña abierta todavía (p.ej. desde el menú
182
+ // del EXPLORADOR, no el de una pestaña ya abierta) — se abre primero.
183
+ if (active !== path || !tabs.find(t => t.path === path)) await openFile(path);
184
+ const tab = tabs.find(t => t.path === path);
185
+ if (!tab) return; // openFile no pudo (fichero borrado, etc.)
186
+ tab.preview = !tab.preview;
187
+ renderTabs();
188
+ await applyView();
189
+ }
190
+
191
+ async function saveActive() {
192
+ const tab = tabs.find(t => t.path === active);
193
+ if (!tab) return;
194
+ await code.write({ path: tab.path, content: tab.model.getValue() });
195
+ tab.dirty = false;
196
+ renderTabs();
197
+ alertBar('💾 ' + tab.path + ' guardado');
198
+ }
199
+
200
+ async function closeTab(path) {
201
+ const i = tabs.findIndex(t => t.path === path);
202
+ if (i < 0) return;
203
+ tabs[i].model.dispose();
204
+ tabs.splice(i, 1);
205
+ if (active === path) {
206
+ active = tabs[i - 1]?.path || tabs[0]?.path || null;
207
+ code.setCurrentFile(active);
208
+ if (active) { editor.setModel(tabs.find(t => t.path === active).model); setEmptyState(false); }
209
+ else { editor.setModel(monacoRef.editor.createModel('', 'plaintext')); setEmptyState(true); }
210
+ }
211
+ renderTabs();
212
+ await applyView();
213
+ }
214
+
215
+ // Estado vacío: cuando no hay ningún fichero abierto, en vez de dejar el Monaco
216
+ // en blanco con un «1», mostramos un placeholder limpio.
217
+ export function setEmptyState(on) {
218
+ let el = document.getElementById('editor-empty');
219
+ if (!el) {
220
+ el = document.createElement('div');
221
+ el.id = 'editor-empty';
222
+ el.innerHTML = '<img src="img/elffuss-code.svg" alt=""><p>Abre un fichero del explorador,<br>o pídele algo a la elfa.</p>';
223
+ $('editor').parentElement.appendChild(el);
224
+ }
225
+ el.style.display = on ? 'flex' : 'none';
226
+ }
227
+
228
+ function renderTabs() {
229
+ const bar = $('tabs-bar');
230
+ bar.replaceChildren();
231
+ for (const t of tabs) {
232
+ const el = document.createElement('div');
233
+ el.className = 'tab' + (t.path === active ? ' active' : '') + (t.preview ? ' tab-preview' : '');
234
+ el.dataset.path = t.path;
235
+ const ico = document.createElement('span');
236
+ ico.className = 'tab-ico';
237
+ ico.innerHTML = fileIcon(t.path.split('/').pop());
238
+ const name = document.createElement('span');
239
+ name.className = 'tab-name';
240
+ name.textContent = t.path.split('/').pop();
241
+ name.title = t.path;
242
+ // como VS Code: punto ● si hay cambios sin guardar; × al pasar el ratón
243
+ const x = document.createElement('b');
244
+ x.className = t.dirty ? 'dirty' : '';
245
+ x.textContent = t.dirty ? '●' : '×';
246
+ x.onclick = e => { e.stopPropagation(); closeTab(t.path); };
247
+ el.append(ico, name, x);
248
+ el.onclick = () => openFile(t.path);
249
+ el.oncontextmenu = e => { e.preventDefault(); openTabMenu(e.clientX, e.clientY, t.path); };
250
+ bar.appendChild(el);
251
+ }
252
+ }
253
+
254
+ // ---------- menú contextual de las pestañas (botón derecho, estilo VS Code) ----------
255
+ function openTabMenu(x, y, path) {
256
+ document.getElementById('tab-menu')?.remove();
257
+ const menu = document.createElement('div');
258
+ menu.id = 'tab-menu';
259
+ menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
260
+ const i = tabs.findIndex(t => t.path === path);
261
+ const tab = tabs[i];
262
+ const items = [];
263
+ items.push(['Cerrar', () => closeTab(path)]);
264
+ items.push(['Cerrar otras', () => { for (const t of tabs.filter(t => t.path !== path)) closeTab(t.path); }]);
265
+ const toTheRight = tabs.slice(i + 1).map(t => t.path);
266
+ if (toTheRight.length) items.push(['Cerrar las de la derecha', () => { for (const p of toTheRight) closeTab(p); }]);
267
+ items.push(['Cerrar todas', () => { for (const t of [...tabs]) closeTab(t.path); }]);
268
+ items.push(['sep']);
269
+ items.push(['Copiar ruta', () => navigator.clipboard?.writeText(path).catch(() => {})]);
270
+ if (PREVIEWABLE.has(langOf(path))) {
271
+ items.push([tab?.preview ? 'Ver código fuente' : 'Vista previa', () => togglePreview(path)]);
272
+ }
273
+ for (const item of items) {
274
+ if (item[0] === 'sep') { menu.appendChild(Object.assign(document.createElement('div'), { className: 'tm-sep' })); continue; }
275
+ const [label, fn] = item;
276
+ const b = document.createElement('button');
277
+ b.className = 'tm-item';
278
+ b.textContent = label;
279
+ b.onclick = () => { menu.remove(); fn(); };
280
+ menu.appendChild(b);
281
+ }
282
+ document.body.appendChild(menu);
283
+ const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
284
+ setTimeout(() => document.addEventListener('mousedown', close), 0);
285
+ }
286
+
287
+ let alertTimer;
288
+ function alertBar(msg) {
289
+ const el = $('statusbar');
290
+ el.textContent = msg;
291
+ clearTimeout(alertTimer);
292
+ alertTimer = setTimeout(() => { el.textContent = ''; }, 4000);
293
+ }
294
+
295
+ // ---------- árbol lateral ----------
296
+ const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
297
+
298
+ export async function refreshTree(handle = null) {
299
+ const rootHandle = handle || window.__elffussProject;
300
+ if (!rootHandle) return;
301
+ window.__elffussProject = rootHandle;
302
+ const cont = $('tree');
303
+ cont.replaceChildren(await renderDir(rootHandle, ''));
304
+ setupTreeMenu();
305
+ }
306
+
307
+ // ---------- menú contextual del árbol (botón derecho, estilo VS Code) ----------
308
+ let treeMenuWired = false;
309
+ function setupTreeMenu() {
310
+ if (treeMenuWired) return;
311
+ treeMenuWired = true;
312
+ const tree = $('tree');
313
+ tree.addEventListener('contextmenu', e => {
314
+ e.preventDefault();
315
+ const node = e.target.closest('[data-path]');
316
+ const path = node ? node.dataset.path : '';
317
+ const kind = node ? node.dataset.kind : 'directory'; // vacío = raíz
318
+ openTreeMenu(e.clientX, e.clientY, path, kind);
319
+ });
320
+ }
321
+
322
+ // resuelve el handle de un directorio por su ruta (segmentos desde la raíz)
323
+ async function dirByPath(path) {
324
+ let dir = code.handle();
325
+ for (const s of (path || '').split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(s);
326
+ return dir;
327
+ }
328
+ const parentOf = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
329
+ const baseOf = p => p.split('/').pop();
330
+
331
+ async function fsCreate(parentPath, name, isDir) {
332
+ const dir = await dirByPath(parentPath);
333
+ if (isDir) await dir.getDirectoryHandle(name, { create: true });
334
+ else { const fh = await dir.getFileHandle(name, { create: true }); const w = await fh.createWritable(); await w.close(); }
335
+ }
336
+ async function fsDelete(path, kind) {
337
+ const dir = await dirByPath(parentOf(path));
338
+ await dir.removeEntry(baseOf(path), { recursive: kind === 'directory' });
339
+ }
340
+ // copia recursiva (la File System API no tiene rename nativo)
341
+ async function fsCopy(srcDir, name, dstDir, newName) {
342
+ const src = await (srcDir.getDirectoryHandle(name).catch(() => null));
343
+ if (src) { // directorio
344
+ const nd = await dstDir.getDirectoryHandle(newName, { create: true });
345
+ for await (const e of src.values()) await fsCopy(src, e.name, nd, e.name);
346
+ } else { // fichero
347
+ const text = await (await (await srcDir.getFileHandle(name)).getFile()).text();
348
+ const fh = await dstDir.getFileHandle(newName, { create: true });
349
+ const w = await fh.createWritable(); await w.write(text); await w.close();
350
+ }
351
+ }
352
+ async function fsRename(path, kind, newName) {
353
+ const parent = await dirByPath(parentOf(path));
354
+ await fsCopy(parent, baseOf(path), parent, newName);
355
+ await parent.removeEntry(baseOf(path), { recursive: kind === 'directory' });
356
+ }
357
+
358
+ function openTreeMenu(x, y, path, kind) {
359
+ document.getElementById('tree-menu')?.remove();
360
+ const menu = document.createElement('div');
361
+ menu.id = 'tree-menu';
362
+ menu.style.cssText = `position:fixed;left:${x}px;top:${y}px;z-index:200`;
363
+ // en un fichero, el "aquí dentro" es su carpeta; en carpeta/raíz, ella misma
364
+ const container = kind === 'file' ? parentOf(path) : path;
365
+ const items = [];
366
+ items.push(['Nuevo archivo…', async () => {
367
+ const name = prompt('Nombre del nuevo archivo:'); if (!name) return;
368
+ await fsCreate(container, name.trim(), false); code.invalidateFileList?.();
369
+ await refreshTree(); openFile(container ? container + '/' + name.trim() : name.trim());
370
+ }]);
371
+ items.push(['Nueva carpeta…', async () => {
372
+ const name = prompt('Nombre de la nueva carpeta:'); if (!name) return;
373
+ await fsCreate(container, name.trim(), true); code.invalidateFileList?.(); await refreshTree();
374
+ }]);
375
+ if (path) {
376
+ if (kind === 'file') items.push(['Abrir', () => openFile(path)]);
377
+ if (kind === 'file' && PREVIEWABLE.has(langOf(path))) items.push(['Vista previa', () => togglePreview(path)]);
378
+ items.push(['Renombrar…', async () => {
379
+ const name = prompt('Nuevo nombre:', baseOf(path)); if (!name || name === baseOf(path)) return;
380
+ await fsRename(path, kind, name.trim()); code.invalidateFileList?.(); await refreshTree();
381
+ }]);
382
+ items.push(['Eliminar', async () => {
383
+ if (!confirm(`¿Eliminar «${baseOf(path)}»?`)) return;
384
+ await fsDelete(path, kind); code.invalidateFileList?.(); await refreshTree();
385
+ }, 'danger']);
386
+ }
387
+ for (const [label, fn, cls] of items) {
388
+ const b = document.createElement('button');
389
+ b.className = 'tm-item' + (cls ? ' ' + cls : '');
390
+ b.textContent = label;
391
+ b.onclick = async () => { menu.remove(); try { await fn(); } catch (err) { alert('No se pudo: ' + err.message); } };
392
+ menu.appendChild(b);
393
+ }
394
+ document.body.appendChild(menu);
395
+ const close = ev => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close); } };
396
+ setTimeout(() => document.addEventListener('mousedown', close), 0);
397
+ }
398
+
399
+ async function renderDir(dir, prefix) {
400
+ const ul = document.createElement('ul');
401
+ const entries = [];
402
+ for await (const e of dir.values()) if (!IGNORE.has(e.name)) entries.push(e);
403
+ entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
404
+ for (const e of entries.slice(0, 200)) {
405
+ const li = document.createElement('li');
406
+ const p = prefix ? prefix + '/' + e.name : e.name;
407
+ if (e.kind === 'directory') {
408
+ const det = document.createElement('details');
409
+ const sum = document.createElement('summary');
410
+ sum.innerHTML = folderIcon();
411
+ sum.appendChild(document.createTextNode(e.name));
412
+ sum.dataset.path = p; sum.dataset.kind = 'directory';
413
+ det.appendChild(sum);
414
+ det.addEventListener('toggle', async () => {
415
+ if (det.open && det.children.length === 1)
416
+ det.appendChild(await renderDir(e, p));
417
+ }, { once: false });
418
+ li.appendChild(det);
419
+ } else {
420
+ const a = document.createElement('span');
421
+ a.className = 'file';
422
+ a.innerHTML = fileIcon(e.name);
423
+ a.appendChild(document.createTextNode(e.name));
424
+ a.dataset.path = p; a.dataset.kind = 'file';
425
+ a.onclick = () => openFile(p);
426
+ li.appendChild(a);
427
+ }
428
+ ul.appendChild(li);
429
+ }
430
+ return ul;
431
+ }
js/main.js ADDED
@@ -0,0 +1,1510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Elffuss Code: landing («abre tu carpeta») → IDE con la elfa integrada.
2
+ import { parseToolCall } from './agent.js';
3
+ import * as rules from './providers/rules.js';
4
+ import * as codeTools from './tools/code.js';
5
+ import * as db from './db.js';
6
+ import * as settings from './settings.js';
7
+ import { initEditor, refreshTree, openFile, gotoLine, triggerEditor, hasEditor } from './ide.js';
8
+ import { ACTIVITY, UI } from './icons.js';
9
+ import { renderMarkdown } from './md.js';
10
+ import * as skills from './skills.js';
11
+ import * as terminal from './terminal.js';
12
+ import * as shell from './shell.js';
13
+ import { setTerminalEcho } from './tools/index.js';
14
+ import { ensureModelCache, cacheEstimate, clearModelCache } from './model-cache.js';
15
+ import * as ceo from './ceo.js';
16
+ import * as mind from './mind.js';
17
+ import { buildCityAdapter, loadThoughtsAdapter } from './mind-adapter.js';
18
+ import { humanizeStreamPreview } from './humanize.js';
19
+ import * as bridge from './bridge.js';
20
+ import * as conv from './conversations.js';
21
+ import { TASK_PREFIX } from './goal.js';
22
+ import * as telemetry from './telemetry.js';
23
+
24
+ const $ = id => document.getElementById(id);
25
+ telemetry.init('elffuss-code'); // opt-in, apagado por defecto — ver Ajustes
26
+ conv.setProvider(rules); // proveedor por defecto para cualquier conversación que se cree
27
+ let activeModel = 'rules';
28
+
29
+ // ---------- chat ----------
30
+ // Los `paths` entre backticks del chat (README.md, .elffuss/soul/x.md…) se
31
+ // vuelven clicables → abren el fichero en el editor directamente, sin tener
32
+ // que ir a buscarlos a mano.
33
+ function linkifyFilePaths(root) {
34
+ root.querySelectorAll('code').forEach(el => {
35
+ const t = el.textContent.trim();
36
+ if (!/^[\w.-]+(\/[\w.-]+)*\.[a-z0-9]{1,10}$/i.test(t) || /[{}()<>]/.test(t)) return;
37
+ el.classList.add('file-link');
38
+ el.title = 'abrir ' + t;
39
+ el.onclick = () => openFile(t);
40
+ });
41
+ }
42
+
43
+ function addMsg(cls, text) {
44
+ const div = document.createElement('div');
45
+ div.className = 'msg ' + cls;
46
+ // las respuestas se renderizan como markdown (estilo plugin de Claude Code);
47
+ // lo del usuario y los errores, como texto plano
48
+ if (/^(assistant$|sys)/.test(cls)) {
49
+ div.classList.add('md');
50
+ // cap defensivo: no volcar miles de nodos si una respuesta trae un archivo entero
51
+ div.innerHTML = renderMarkdown(text.length > 8000 ? text.slice(0, 8000) + '\n… (recortado)' : text);
52
+ linkifyFilePaths(div);
53
+ } else {
54
+ div.textContent = text;
55
+ }
56
+ $('chat-log').appendChild(div);
57
+ $('chat-log').scrollTop = $('chat-log').scrollHeight;
58
+ return div;
59
+ }
60
+
61
+ // estilo plugin de Claude Code: ● herramienta en negrita + detalle plegable
62
+ function addTool(tool, arg) {
63
+ const div = document.createElement('div');
64
+ div.className = 'msg tool';
65
+ const dot = document.createElement('span');
66
+ dot.className = 'tdot';
67
+ const b = document.createElement('b');
68
+ b.textContent = tool;
69
+ div.append(dot, b);
70
+ if (arg) {
71
+ const a = document.createElement('span');
72
+ a.className = 'targ';
73
+ a.textContent = String(arg).slice(0, 60);
74
+ div.appendChild(a);
75
+ }
76
+ $('chat-log').appendChild(div);
77
+ $('chat-log').scrollTop = $('chat-log').scrollHeight;
78
+ return div;
79
+ }
80
+
81
+ function addToolResult(result) {
82
+ const err = result.startsWith('ERROR');
83
+ const det = document.createElement('details');
84
+ det.className = 'msg tool-result' + (err ? ' err' : '');
85
+ const sum = document.createElement('summary');
86
+ sum.textContent = (err ? '⚠️ ' : '') + result.split('\n')[0].slice(0, 90);
87
+ const pre = document.createElement('pre');
88
+ pre.textContent = result.slice(0, 1500);
89
+ det.append(sum, pre);
90
+ $('chat-log').appendChild(det);
91
+ $('chat-log').scrollTop = $('chat-log').scrollHeight;
92
+ return det;
93
+ }
94
+
95
+ const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
96
+
97
+ // 🎯 Modo Objetivo: tarjeta de plan (planificador → lista de tareas →
98
+ // ejecutor). Un solo nodo por plan (mismo id), repintado in-place según
99
+ // llegan los eventos 'plan'/'plan_update'/'plan_complete' — así se ve la
100
+ // lista evolucionar en vivo en vez de reconstruirse entera cada vez.
101
+ const PLAN_ICO = { pending: '⏳', 'in-progress': '🔄', done: '✅', failed: '❌', skipped: '⏭️' };
102
+ const PLAN_STATUS_LABEL = { planning: 'planificando…', running: 'ejecutando…', done: 'completado', failed: 'con fallos' };
103
+ function renderPlanCard(plan) {
104
+ const domId = 'plan-' + plan.id;
105
+ let div = document.getElementById(domId);
106
+ if (!div) {
107
+ div = document.createElement('div');
108
+ div.id = domId;
109
+ div.className = 'msg plan-card';
110
+ $('chat-log').appendChild(div);
111
+ }
112
+ div.innerHTML =
113
+ `<span class="plan-status">${escapeHtml(PLAN_STATUS_LABEL[plan.status] || plan.status)}</span>` +
114
+ `<div class="plan-head">🎯 Objetivo: ${escapeHtml(plan.goal)}</div>` +
115
+ (plan.planText ? `<div class="plan-summary">${escapeHtml(plan.planText)}</div>` : '') +
116
+ `<ul class="plan-tasks">` +
117
+ plan.tasks.map(t =>
118
+ `<li class="task-${t.status}"><span class="task-ico">${PLAN_ICO[t.status] || '⏳'}</span>` +
119
+ `<div><div class="task-title">${escapeHtml(t.title)}</div><div class="task-desc">${escapeHtml(t.description)}</div></div></li>`
120
+ ).join('') +
121
+ `</ul>`;
122
+ $('chat-log').scrollTop = $('chat-log').scrollHeight;
123
+ return div;
124
+ }
125
+
126
+ export function thinkingBubble() {
127
+ const div = addMsg('thinking', '');
128
+ const label = document.createElement('span');
129
+ label.textContent = 'Elffuss está pensando';
130
+ const gen = document.createElement('div');
131
+ gen.className = 'gen';
132
+ div.append(label, gen);
133
+ let buf = '';
134
+ return {
135
+ tick(t) {
136
+ buf += t;
137
+ label.textContent = `Elffuss escribe · ${buf.length} car.`; // caracteres, no tokens
138
+ // si el buffer entró en un bloque de tool-call, no enseñes el JSON crudo
139
+ // según va llegando: una frase humana («leyendo app.js…») en su lugar.
140
+ const preview = humanizeStreamPreview(buf);
141
+ gen.classList.toggle('tool-preview', !!preview);
142
+ gen.textContent = preview ? '⟐ ' + preview : (buf.length > 200 ? '…' : '') + buf.slice(-200);
143
+ $('chat-log').scrollTop = $('chat-log').scrollHeight;
144
+ },
145
+ tool(name) { buf = ''; gen.textContent = ''; label.textContent = `Elffuss usa ${name}`; },
146
+ remove() { div.remove(); },
147
+ };
148
+ }
149
+
150
+ // Conversaciones (pestañas, en paralelo, con su propia cola): la lógica de
151
+ // estado vive en conversations.js — aquí solo se pinta lo que corresponde.
152
+ let activeThinking = null;
153
+
154
+ function renderActiveLog() {
155
+ $('chat-log').replaceChildren();
156
+ const active = conv.getActive();
157
+ activeThinking = null;
158
+ if (!active) return;
159
+ for (const m of active.agent.history) {
160
+ if (m.role === 'user') {
161
+ if (m.content.startsWith('[resultado ')) {
162
+ const nl = m.content.indexOf('\n');
163
+ addToolResult(nl > 0 ? m.content.slice(nl + 1) : '');
164
+ } else if (m.content.startsWith(TASK_PREFIX)) {
165
+ // objetivo/tareas internas del modo Goal: no son burbujas sueltas —
166
+ // la tarjeta de plan las muestra todas. Se pinta justo en el punto
167
+ // donde se lanzó el objetivo, igual que aparece en directo.
168
+ if (m.content.startsWith(TASK_PREFIX + 'Objetivo: ') && active.plan) renderPlanCard(active.plan);
169
+ } else if (!m.content.startsWith('[…')) {
170
+ addMsg('user', m.content);
171
+ }
172
+ } else {
173
+ const call = parseToolCall(m.content);
174
+ if (call) addTool(call.tool, call.args?.path || call.args?.query || '');
175
+ else addMsg('assistant', m.content);
176
+ }
177
+ }
178
+ if (active.pumping) activeThinking = thinkingBubble();
179
+ }
180
+
181
+ // evento del módulo de conversaciones: 'switch' (cambiaste de pestaña o se
182
+ // creó una), 'tabs' (se abrió/cerró una pestaña), 'pumping' (empezó/terminó
183
+ // de procesar), 'event' (token/texto/tool/resultado/error de un turno real).
184
+ // Las conversaciones EN SEGUNDO PLANO nunca tocan el DOM visible — solo se
185
+ // refleja su punto de «ocupada» en la barra de pestañas.
186
+ function onConvEvent(kind, id, payload) {
187
+ if (kind !== 'event') renderTabsBar();
188
+ if (kind === 'switch') { renderActiveLog(); return; }
189
+ const active = conv.getActive();
190
+ if (!active || id !== active.id) return;
191
+ if (kind === 'pumping') {
192
+ $('btn-send').disabled = payload;
193
+ $('btn-send').classList.toggle('sending', payload);
194
+ if (payload) { if (!activeThinking) activeThinking = thinkingBubble(); }
195
+ else { activeThinking?.remove(); activeThinking = null; }
196
+ return;
197
+ }
198
+ if (kind === 'event') {
199
+ const ev = payload;
200
+ if (ev.type === 'token') activeThinking?.tick(ev.text);
201
+ if (ev.type === 'text') addMsg('assistant', ev.text);
202
+ if (ev.type === 'tool') { activeThinking?.tool(ev.call.tool); addTool(ev.call.tool, ev.call.args?.path || ev.call.args?.query || ''); }
203
+ if (ev.type === 'tool_result') addToolResult(ev.result);
204
+ if (ev.type === 'error') addMsg('assistant err', ev.text);
205
+ // 🎯 Modo Objetivo: la tarjeta de plan se crea con 'plan' y se repinta
206
+ // in-place en cada 'plan_update'/'plan_complete' (mismo nodo, por id).
207
+ if (ev.type === 'plan' || ev.type === 'plan_update' || ev.type === 'plan_complete') renderPlanCard(ev.plan);
208
+ }
209
+ }
210
+
211
+ function send(text) {
212
+ const active = conv.getActive();
213
+ if (!active) return;
214
+ addMsg('user', text);
215
+ if (goalMode) conv.startGoal(active.id, text);
216
+ else conv.send(active.id, text);
217
+ }
218
+
219
+ function renderTabsBar() {
220
+ const bar = $('conv-tabs');
221
+ if (!bar) return;
222
+ bar.replaceChildren();
223
+ const active = conv.getActive();
224
+ for (const c of conv.getOpenTabs()) {
225
+ const el = document.createElement('div');
226
+ el.className = 'tab' + (active && c.id === active.id ? ' active' : '') + (c.pumping ? ' tab-busy' : '');
227
+ el.dataset.id = c.id;
228
+ const name = document.createElement('span');
229
+ name.className = 'tab-name';
230
+ const title = conv.titleFor(c);
231
+ name.textContent = title;
232
+ name.title = title;
233
+ const x = document.createElement('b');
234
+ x.textContent = '×';
235
+ x.title = 'Cerrar pestaña (no borra la conversación)';
236
+ x.onclick = e => { e.stopPropagation(); conv.closeTab(c.id); };
237
+ el.append(name, x);
238
+ el.onclick = () => conv.switchTo(c.id);
239
+ bar.appendChild(el);
240
+ }
241
+ const plus = document.createElement('button');
242
+ plus.className = 'tab-add';
243
+ plus.title = 'Nueva conversación';
244
+ plus.textContent = '+';
245
+ plus.onclick = () => conv.create();
246
+ bar.appendChild(plus);
247
+ }
248
+
249
+ async function renderHistoryPanel() {
250
+ const panel = $('history-panel');
251
+ panel.replaceChildren();
252
+ const head = document.createElement('div');
253
+ head.className = 'hp-head';
254
+ head.append(document.createTextNode('Historial de conversaciones'));
255
+ const xBtn = document.createElement('button');
256
+ xBtn.textContent = '✕';
257
+ xBtn.onclick = () => { panel.hidden = true; };
258
+ head.appendChild(xBtn);
259
+ panel.appendChild(head);
260
+ const list = document.createElement('div');
261
+ list.id = 'hp-list';
262
+ panel.appendChild(list);
263
+ const all = await conv.listAll();
264
+ if (!all.length) {
265
+ const empty = document.createElement('div');
266
+ empty.className = 'hp-empty';
267
+ empty.textContent = 'Sin conversaciones guardadas todavía.';
268
+ list.appendChild(empty);
269
+ return;
270
+ }
271
+ for (const c of all) {
272
+ const row = document.createElement('div');
273
+ row.className = 'hp-row';
274
+ const t = document.createElement('div');
275
+ t.className = 'hp-title';
276
+ t.textContent = c.title || 'Nueva conversación';
277
+ const d = new Date(c.updatedAt);
278
+ const date = document.createElement('div');
279
+ date.className = 'hp-date';
280
+ date.textContent = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
281
+ const del = document.createElement('button');
282
+ del.className = 'hp-del';
283
+ del.title = 'Eliminar para siempre';
284
+ del.textContent = '🗑';
285
+ del.onclick = async e => {
286
+ e.stopPropagation();
287
+ if (!confirm(`¿Eliminar «${c.title || 'esta conversación'}» para siempre?`)) return;
288
+ await conv.remove(c.id);
289
+ renderHistoryPanel();
290
+ };
291
+ row.append(t, date, del);
292
+ row.onclick = async () => { await conv.open(c.id); panel.hidden = true; };
293
+ list.appendChild(row);
294
+ }
295
+ }
296
+
297
+ // ---------- modelos (local primero; externos = ⚙️ opt-in) ----------
298
+ const localProviders = { litert: () => import('./providers/litert.js'), onnx: () => import('./providers/onnx.js') };
299
+
300
+ async function resolveProvider(id) {
301
+ if (localProviders[id]) return localProviders[id]();
302
+ if (id.startsWith('litert:')) { // Gemma vía LiteRT-LM (build elegido)
303
+ const mod = await import('./providers/litert.js');
304
+ mod.configure(id.slice(7));
305
+ return mod;
306
+ }
307
+ if (id.startsWith('ext:')) {
308
+ const cfg = settings.get(id.slice(4));
309
+ const mod = await import('./providers/api.js');
310
+ mod.configure(cfg);
311
+ return mod;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ // El E4B healed (4.12 GB) NO carga aún en navegador (export prefill_decode, no
317
+ // artisan) — descargarlo y fallar deja la GPU en estado inválido y TUMBA la
318
+ // pestaña. Se oculta hasta el reexport artisan. Poner true para reactivarlo.
319
+ const LITERT_READY = false;
320
+
321
+ const isMobile = () => matchMedia('(max-width: 820px)').matches || matchMedia('(pointer: coarse)').matches;
322
+ // navigator.gpu puede EXISTIR como API sin que haya un adaptador real (ciertos
323
+ // Linux/drivers, entornos sandboxed/headless…) — comprobarlo UNA vez al
324
+ // arrancar evita ofrecer/elegir Gemma (2-4 GB) cuando de todos modos va a
325
+ // fallar al crear el motor (onnx.js y litert.js ya comprueban el adaptador
326
+ // por su cuenta como defensa adicional, pero esto evita hasta OFRECERLO).
327
+ let realGPU = !!navigator.gpu;
328
+ const realGPUCheck = (async () => {
329
+ if (!navigator.gpu) return (realGPU = false);
330
+ try { return (realGPU = !!(await navigator.gpu.requestAdapter())); }
331
+ catch { return (realGPU = false); }
332
+ })();
333
+ const defaultBrain = () => !realGPU ? 'onnx' : (isMobile() ? 'litert:gemma-e2b' : 'litert:gemma-e4b');
334
+
335
+ function modelOptions() {
336
+ const opts = [];
337
+ if (realGPU) opts.push({ id: 'litert:gemma-e4b', label: 'Gemma-4 E4B · LiteRT-LM (~4 GB) ★' });
338
+ if (realGPU) opts.push({ id: 'litert:gemma-e2b', label: 'Gemma-4 E2B · LiteRT-LM (~2 GB)' });
339
+ opts.push({ id: 'onnx', label: 'Elffuss LM (healed · 850 MB) — ligero' });
340
+ opts.push({ id: 'rules', label: 'Básico (sin modelo)' });
341
+ return [...opts, ...settings.enabledExternals()];
342
+ }
343
+
344
+ function rebuildSelect() {
345
+ const sel = $('model-select');
346
+ sel.replaceChildren();
347
+ for (const o of modelOptions()) {
348
+ const opt = document.createElement('option');
349
+ opt.value = o.id;
350
+ opt.textContent = o.label;
351
+ sel.appendChild(opt);
352
+ }
353
+ sel.value = activeModel;
354
+ }
355
+
356
+ // progreso del modelo: píldora prominente visible sobre landing e IDE
357
+ function showModelProgress(text, pct = null) {
358
+ const box = $('model-progress');
359
+ if (text == null) { box.hidden = true; return; }
360
+ box.hidden = false;
361
+ $('model-progress-text').textContent = text;
362
+ const bar = $('model-bar');
363
+ if (pct == null) {
364
+ bar.classList.add('indet'); // sin % conocido → barra animada
365
+ bar.style.width = '';
366
+ } else {
367
+ bar.classList.remove('indet');
368
+ bar.style.width = pct + '%';
369
+ }
370
+ }
371
+
372
+ let loadingId = null;
373
+ async function changeModel(id) {
374
+ if (id === 'rules') {
375
+ conv.setProvider(rules);
376
+ activeModel = 'rules';
377
+ localStorage.setItem('elffusscode.model', 'rules');
378
+ $('model-dot').className = 'dot off';
379
+ return true;
380
+ }
381
+ if (loadingId === id || activeModel === id) return true; // un solo modelo, una sola carga
382
+ loadingId = id;
383
+ $('model-dot').className = 'dot loading';
384
+ showModelProgress('Cargando el modelo IA…', 4);
385
+ try {
386
+ const mod = await resolveProvider(id);
387
+ await mod.load(p => {
388
+ if (typeof p === 'string') return showModelProgress(p);
389
+ if (p?.status === 'progress' && p.total) {
390
+ const pct = Math.round(p.loaded / p.total * 100);
391
+ showModelProgress(`Descargando el modelo IA · ${pct}% · ${(p.loaded / 1e6 | 0)}/${(p.total / 1e6 | 0)} MB`, pct);
392
+ }
393
+ });
394
+ conv.setProvider(mod);
395
+ activeModel = id;
396
+ localStorage.setItem('elffusscode.model', id);
397
+ $('model-dot').className = 'dot on';
398
+ showModelProgress('Modelo IA listo · готово ✳', 100);
399
+ setTimeout(() => showModelProgress(null), 2500);
400
+ $('statusbar').textContent = 'Modelo IA listo';
401
+ rebuildSelect();
402
+ return true;
403
+ } catch (e) {
404
+ console.error('[elffuss-code] fallo cargando', e);
405
+ // Un Gemma (LiteRT) que no cabe → cae al Elffuss LM healed (onnx, ligero).
406
+ if (id.startsWith('litert') && !_fellBack) {
407
+ _fellBack = true;
408
+ sessionStorage.setItem('elffusscode.skipGemma', '1');
409
+ showModelProgress('Ese Gemma no cargó (memoria/GPU) — uso Elffuss LM (ligero)…');
410
+ loadingId = null;
411
+ const okc = await changeModel('onnx');
412
+ _fellBack = false;
413
+ if (okc) return true;
414
+ }
415
+ conv.setProvider(rules);
416
+ activeModel = 'rules';
417
+ $('model-dot').className = 'dot off';
418
+ showModelProgress('⚠️ No pude cargar el modelo: ' + (e?.message || e));
419
+ setTimeout(() => showModelProgress(null), 6000);
420
+ rebuildSelect();
421
+ return false;
422
+ } finally {
423
+ loadingId = null;
424
+ }
425
+ }
426
+ let _fellBack = false;
427
+
428
+ // El cerebro empieza a descargarse desde el PRIMER segundo, en segundo plano,
429
+ // mientras el usuario elige la carpeta. Solo el modelo local — los externos
430
+ // jamás se activan solos.
431
+ // Cadena de respaldo: el modelo preferido → LFM2.5 local → básico. Si el E4B
432
+ // healed falla (p. ej. «HF_Tokenizer_Zlib not supported» del runtime LiteRT),
433
+ // cae solo a LFM2.5 en vez de dejar al usuario sin cerebro.
434
+ async function preloadModel() {
435
+ const saved = localStorage.getItem('elffusscode.model');
436
+ if (saved === 'rules') return;
437
+ await realGPUCheck; // que defaultBrain()/modelOptions() vean el adaptador real, no solo la API
438
+ const avail = new Set(modelOptions().map(o => o.id));
439
+ const skipGemma = sessionStorage.getItem('elffusscode.skipGemma') === '1';
440
+ const def = skipGemma ? 'onnx' : defaultBrain();
441
+ const chain = [...new Set([saved, def, 'onnx']
442
+ .filter(id => id && id !== 'rules' && avail.has(id)))];
443
+ for (const id of chain) if (await changeModel(id)) return;
444
+ }
445
+
446
+ // ---------- panel ⚙️ Ajustes: modelo (cerebro) + API keys ----------
447
+ const el = (t, cls, txt) => { const e = document.createElement(t); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; };
448
+
449
+ function settingsShell(title) {
450
+ const box = $('settings-panel');
451
+ box.hidden = false;
452
+ box.replaceChildren();
453
+ const close = el('button', 'panel-close'); close.innerHTML = UI.close; close.title = 'cerrar';
454
+ close.onclick = () => { box.hidden = true; };
455
+ box.append(close, el('h3', 'panel-title', title));
456
+ return box;
457
+ }
458
+
459
+ function showBridgeMsg(card, text, isErr) {
460
+ let el2 = card.querySelector('.br-msg');
461
+ if (!el2) { el2 = document.createElement('div'); el2.className = 'br-msg muted'; el2.style.cssText = 'font-size:.7rem;margin-top:6px'; card.appendChild(el2); }
462
+ el2.textContent = text;
463
+ el2.style.color = isErr ? '#ff6b8b' : '';
464
+ }
465
+
466
+ // pequeño estallido de fuegos artificiales (canvas, un par de segundos) para
467
+ // que se entienda de un vistazo que el bridge quedó conectado.
468
+ function fireworks() {
469
+ const cv = document.createElement('canvas');
470
+ cv.id = 'fireworks-fx';
471
+ cv.width = innerWidth; cv.height = innerHeight;
472
+ document.body.appendChild(cv);
473
+ const ctx = cv.getContext('2d');
474
+ const colors = ['#ff4d8d', '#7c5cff', '#49e8ff', '#3fb970', '#ffd479'];
475
+ const bursts = Array.from({ length: 5 }, () => ({
476
+ x: innerWidth * (0.2 + Math.random() * 0.6), y: innerHeight * (0.2 + Math.random() * 0.4),
477
+ t0: performance.now() + Math.random() * 500,
478
+ particles: Array.from({ length: 40 }, (_, i) => { const a = (i / 40) * Math.PI * 2; const sp = 2 + Math.random() * 3; return { a, sp, color: colors[i % colors.length] }; }),
479
+ }));
480
+ const start = performance.now();
481
+ function frame(now) {
482
+ ctx.clearRect(0, 0, cv.width, cv.height);
483
+ let alive = false;
484
+ for (const b of bursts) {
485
+ const age = (now - b.t0) / 1000;
486
+ if (age < 0) { alive = true; continue; }
487
+ if (age > 1.4) continue;
488
+ alive = true;
489
+ for (const p of b.particles) {
490
+ const r = p.sp * age * 40;
491
+ const x = b.x + Math.cos(p.a) * r, y = b.y + Math.sin(p.a) * r + age * age * 60;
492
+ ctx.globalAlpha = Math.max(0, 1 - age / 1.4);
493
+ ctx.fillStyle = p.color;
494
+ ctx.beginPath(); ctx.arc(x, y, 2.4, 0, Math.PI * 2); ctx.fill();
495
+ }
496
+ }
497
+ if (alive && now - start < 3000) requestAnimationFrame(frame);
498
+ else cv.remove();
499
+ }
500
+ requestAnimationFrame(frame);
501
+ }
502
+
503
+ function renderSettings() {
504
+ const box = settingsShell('Ajustes');
505
+
506
+ // --- Cerebro (modelo) ---
507
+ box.append(el('div', 'sk-h', 'Cerebro (modelo)'));
508
+ const LOCAL = [
509
+ { id: 'litert:gemma-e4b', name: 'Gemma-4 E4B ★', sub: 'El mejor · WebGPU local · ~4 GB', need: 'gpu' },
510
+ { id: 'litert:gemma-e2b', name: 'Gemma-4 E2B', sub: 'Ligero · WebGPU local · ~2 GB', need: 'gpu' },
511
+ { id: 'onnx', name: 'Elffuss LM (healed)', sub: 'Modelo propio · 850 MB · tool-calls + apps' },
512
+ { id: 'rules', name: 'Básico (sin modelo)', sub: 'Órdenes directas, cero descarga' },
513
+ ];
514
+ const grid = el('div', 'model-grid');
515
+ for (const m of LOCAL) {
516
+ if (m.need === 'gpu' && !realGPU) continue;
517
+ const card = el('button', 'model-card' + (activeModel === m.id ? ' active' : '') + (m.disabled ? ' disabled' : ''));
518
+ if (m.disabled) card.disabled = true;
519
+ card.innerHTML = `<b>${m.name}</b><span>${m.sub}</span>`;
520
+ card.onclick = () => { changeModel(m.id); renderSettings(); };
521
+ grid.appendChild(card);
522
+ }
523
+ box.appendChild(grid);
524
+
525
+ // --- Almacenamiento del modelo (caché persistente) ---
526
+ box.append(el('div', 'sk-h', 'Modelo descargado (se cachea en tu navegador)'));
527
+ const storeCard = el('div', 'prov-card');
528
+ const storeInfo = el('span', null, 'Calculando espacio…'); storeInfo.style.cssText = 'font-size:.8rem;color:var(--fg)';
529
+ const storeSub = el('div', 'field'); storeSub.style.marginTop = '2px';
530
+ const storeMuted = el('span', 'muted', ''); storeMuted.style.fontSize = '.72rem';
531
+ const clearBtn = el('button', 'prov-use', 'Vaciar caché');
532
+ clearBtn.style.marginTop = '8px';
533
+ clearBtn.onclick = async () => { clearBtn.textContent = 'Vaciando…'; await clearModelCache(); await paintStorage(); clearBtn.textContent = 'Vaciar caché'; };
534
+ const storeHead = el('div', 'prov-head'); storeHead.append(storeInfo);
535
+ storeCard.append(storeHead, storeMuted, clearBtn);
536
+ box.appendChild(storeCard);
537
+ async function paintStorage() {
538
+ const { usage, quota, persisted } = await cacheEstimate();
539
+ const gb = n => (n / 1073741824).toFixed(2) + ' GB';
540
+ storeInfo.textContent = usage ? `${gb(usage)} en caché` : 'Nada cacheado todavía';
541
+ storeMuted.textContent = (persisted ? '✓ almacenamiento persistente (no se borra solo)' : '⚠ sin persistencia: el navegador podría desalojarlo')
542
+ + (quota ? ` · límite ~${gb(quota)}` : '');
543
+ }
544
+ paintStorage();
545
+
546
+ // --- Bridge local (ejecución REAL en tu máquina: node, npm, python…) ---
547
+ box.append(el('div', 'sk-h', '🔌 Bridge local (ejecución real en tu máquina)'));
548
+ const brCard = el('div', 'prov-card bridge-card');
549
+ const guessOS = () => {
550
+ const ua = navigator.userAgent;
551
+ if (/Mac/.test(ua)) return /Intel/.test(navigator.platform) && !/arm/i.test(ua) ? 'elffuss-bridge-mac-intel' : 'elffuss-bridge-mac-arm64';
552
+ if (/Win/.test(ua)) return 'elffuss-bridge-windows.exe';
553
+ if (/Linux/.test(ua)) return /aarch64|arm64/i.test(ua) ? 'elffuss-bridge-linux-arm64' : 'elffuss-bridge-linux';
554
+ return 'elffuss-bridge-mac-arm64';
555
+ };
556
+ const OTHER = { 'elffuss-bridge-mac-arm64': 'Mac (Apple Silicon)', 'elffuss-bridge-mac-intel': 'Mac (Intel)', 'elffuss-bridge-windows.exe': 'Windows', 'elffuss-bridge-linux': 'Linux (x64)', 'elffuss-bridge-linux-arm64': 'Linux (ARM)' };
557
+ const primary = guessOS();
558
+ brCard.innerHTML =
559
+ `<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>` +
560
+ `<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>` +
561
+ `<a class="prov-use" style="text-decoration:none;display:inline-block" href="https://elffuss-code.utopiaia.com/bridge-dl/${primary}" download>⬇ Descargar para ${OTHER[primary]}</a>` +
562
+ `<details style="margin-top:6px"><summary class="muted" style="font-size:.7rem;cursor:pointer">otro sistema operativo</summary>` +
563
+ Object.entries(OTHER).filter(([f]) => f !== primary).map(([f, label]) => `<div><a href="https://elffuss-code.utopiaia.com/bridge-dl/${f}" download style="color:var(--accent2);font-size:.72rem">${label}</a></div>`).join('') +
564
+ `</details>` +
565
+ `<div class="field" style="margin-top:8px"><label class="muted" style="font-size:.68rem">Token (lo imprime el programa al arrancarlo)</label><input id="br-token" placeholder="pega aquí el token…"></div>` +
566
+ `<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>` +
567
+ `<button id="br-connect" class="prov-use" style="margin-top:8px">Conectar</button>`;
568
+ box.appendChild(brCard);
569
+ brCard.querySelector('#br-folder').value = bridge.getFolder();
570
+ brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
571
+ const paintBridge = () => {
572
+ // el panel puede haberse cerrado entre sondeos de fondo — si ya no está
573
+ // en el DOM, nos desuscribimos en vez de tocar nodos huérfanos.
574
+ if (!document.body.contains(brCard)) { bridge.onStatusChange(() => {}); return; }
575
+ const on = bridge.isConnected();
576
+ brCard.querySelector('#br-dot').className = 'dot ' + (on ? 'on' : 'off');
577
+ brCard.querySelector('#br-status').textContent = on ? '✓ conectado — ejecución real activa' : 'desconectado';
578
+ if (on) brCard.querySelector('#br-token').value = localStorage.getItem('elffusscode.bridgeToken') || '';
579
+ };
580
+ paintBridge();
581
+ bridge.onStatusChange(paintBridge); // repinta solo si el bridge conecta/cae mientras Ajustes está abierto
582
+ brCard.querySelector('#br-connect').onclick = async () => {
583
+ const btn = brCard.querySelector('#br-connect');
584
+ bridge.setFolder(brCard.querySelector('#br-folder').value);
585
+ const token = brCard.querySelector('#br-token').value.trim();
586
+ if (!token) return showBridgeMsg(brCard, '⚠️ pega el token que imprimió el programa al arrancarlo', true);
587
+ btn.disabled = true; btn.textContent = 'Conectando…';
588
+ try { await bridge.connect(token); paintBridge(); showBridgeMsg(brCard, '✔ conectado — ejecución real en tu máquina'); fireworks(); }
589
+ catch (e) { showBridgeMsg(brCard, '⚠️ ' + e.message, true); }
590
+ finally { btn.disabled = false; btn.textContent = 'Conectar'; }
591
+ };
592
+
593
+ // --- Permisos de ejecución (mismo interruptor que </> Auto de la barra) ---
594
+ box.append(el('div', 'sk-h', '✅ Permisos de ejecución'));
595
+ const permCard = el('div', 'prov-card');
596
+ permCard.innerHTML =
597
+ `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
598
+ `<input type="checkbox" id="perm-autoedit"> ` +
599
+ `<span>Ejecutar todo automáticamente (escribir/editar ficheros y comandos de terminal), sin pedir confirmación</span>` +
600
+ `</label>` +
601
+ `<p class="muted" style="font-size:.7rem;margin:6px 0 0">terminal.run ya se ejecuta siempre sin preguntar. Esto controla solo la escritura de ficheros (code.write/code.edit) — desmárcalo si prefieres revisar cada cambio antes de aplicarlo.</p>`;
602
+ box.appendChild(permCard);
603
+ const permCheckbox = permCard.querySelector('#perm-autoedit');
604
+ permCheckbox.checked = autoEdit;
605
+ permCheckbox.onchange = () => {
606
+ autoEdit = permCheckbox.checked;
607
+ localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
608
+ paintAuto();
609
+ };
610
+
611
+ // --- 🎯 Modo Objetivo (planificador + ejecutor, mismo patrón que Auto) ---
612
+ box.append(el('div', 'sk-h', '🎯 Modo Objetivo'));
613
+ const goalCard = el('div', 'prov-card');
614
+ goalCard.innerHTML =
615
+ `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
616
+ `<input type="checkbox" id="perm-goalmode"> ` +
617
+ `<span>Tratar el próximo mensaje como un objetivo: lo descompone en tareas y las ejecuta una a una</span>` +
618
+ `</label>` +
619
+ `<p class="muted" style="font-size:.7rem;margin:6px 0 0">Igual que el botón 🎯 Goal de la barra — un planificador crea la lista de tareas y un ejecutor las va cumpliendo, marcando cada una como hecha o fallida (si una falla, las siguientes se saltan en vez de seguir a ciegas).</p>`;
620
+ box.appendChild(goalCard);
621
+ const goalCheckbox = goalCard.querySelector('#perm-goalmode');
622
+ goalCheckbox.checked = goalMode;
623
+ goalCheckbox.onchange = () => {
624
+ goalMode = goalCheckbox.checked;
625
+ localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
626
+ paintGoal();
627
+ };
628
+
629
+ // --- 📨 Errores y feedback (opt-in — apagado no sale NADA de tu máquina) ---
630
+ box.append(el('div', 'sk-h', '📨 Errores y feedback'));
631
+ const telCard = el('div', 'prov-card');
632
+ telCard.innerHTML =
633
+ `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">` +
634
+ `<input type="checkbox" id="tel-enabled"> ` +
635
+ `<span>Enviar automáticamente los errores técnicos que ocurran, para poder arreglarlos</span>` +
636
+ `</label>` +
637
+ `<p class="muted" style="font-size:.7rem;margin:6px 0 10px">Apagado por defecto — tu código y el contenido de tus proyectos NUNCA se incluyen, solo el mensaje de error técnico, la pila y datos del navegador.</p>` +
638
+ `<label class="muted" style="font-size:.68rem">O manda algo tú directamente (un fallo que viste, algo que eches en falta…)</label>` +
639
+ `<textarea id="tel-feedback" rows="2" style="width:100%;margin-top:4px;resize:vertical;background:var(--bg2);border:1px solid var(--line);border-radius:8px;color:var(--fg);padding:6px 8px;font:inherit" placeholder="Cuéntanos qué ha pasado o qué te gustaría que hiciera…"></textarea>` +
640
+ `<button id="tel-send" class="prov-use" style="margin-top:6px">Enviar</button>` +
641
+ `<span id="tel-msg" class="muted" style="font-size:.7rem;margin-left:8px"></span>`;
642
+ box.appendChild(telCard);
643
+ const telCheckbox = telCard.querySelector('#tel-enabled');
644
+ telCheckbox.checked = telemetry.isEnabled();
645
+ telCheckbox.onchange = () => telemetry.setEnabled(telCheckbox.checked);
646
+ telCard.querySelector('#tel-send').onclick = async () => {
647
+ const ta = telCard.querySelector('#tel-feedback');
648
+ const msg = telCard.querySelector('#tel-msg');
649
+ const text = ta.value.trim();
650
+ if (!text) { msg.textContent = 'escribe algo primero'; return; }
651
+ const wasEnabled = telemetry.isEnabled();
652
+ if (!wasEnabled) telemetry.setEnabled(true); // el envío manual es explícito, se permite aunque el automático esté apagado
653
+ await telemetry.sendFeedback(text);
654
+ if (!wasEnabled) telemetry.setEnabled(false); // no activa el automático de fondo si no lo pidió
655
+ ta.value = '';
656
+ msg.textContent = '¡enviado, gracias!';
657
+ setTimeout(() => { msg.textContent = ''; }, 4000);
658
+ };
659
+
660
+ // --- Proveedores externos (API keys) ---
661
+ box.append(el('div', 'sk-h', 'Proveedores externos (opcional · la clave se queda en tu navegador)'));
662
+ for (const [id, c] of Object.entries(settings.configs())) {
663
+ const card = el('div', 'prov-card' + (c.enabled ? ' on' : ''));
664
+ const head = el('div', 'prov-head');
665
+ const toggle = document.createElement('input'); toggle.type = 'checkbox'; toggle.checked = c.enabled;
666
+ const title = el('b', null, c.label);
667
+ const use = el('button', 'prov-use', 'Usar');
668
+ use.hidden = !c.enabled || activeModel === 'ext:' + id;
669
+ use.onclick = () => { changeModel('ext:' + id); renderSettings(); };
670
+ head.append(toggle, title, use);
671
+ const fields = el('div', 'prov-fields' + (c.enabled ? '' : ' hide'));
672
+ const model = document.createElement('input'); model.value = c.model || ''; model.placeholder = 'modelo (p. ej. gpt-4o-mini)';
673
+ const key = document.createElement('input'); key.type = 'password'; key.value = c.apiKey || '';
674
+ key.placeholder = c.kind === 'anthropic' ? 'sk-ant-…' : (id === 'ollama' || id === 'server' ? 'clave (no necesaria)' : 'sk-…');
675
+ fields.append(labeled('Modelo', model), labeled('API key', key));
676
+ const save = () => {
677
+ settings.update(id, { enabled: toggle.checked, model: model.value.trim(), apiKey: key.value });
678
+ rebuildSelect();
679
+ };
680
+ toggle.onchange = () => { save(); renderSettings(); };
681
+ model.onchange = key.onchange = save;
682
+ card.append(head, fields);
683
+ box.appendChild(card);
684
+ }
685
+
686
+ // --- Skills ---
687
+ box.append(el('div', 'sk-h', 'Skills'));
688
+ const skBtn = el('button', 'primary wide', '🧩 Gestionar skills de Claude Code');
689
+ skBtn.textContent = 'Gestionar skills de Claude Code';
690
+ skBtn.onclick = openSkillsPanel;
691
+ box.appendChild(skBtn);
692
+ }
693
+ function labeled(label, input) {
694
+ const w = el('label', 'field'); w.append(el('span', 'muted', label), input); return w;
695
+ }
696
+
697
+ // ---------- arranque ----------
698
+ let galaxy = null;
699
+ async function enterIDE(handle) {
700
+ const name = await codeTools.openProject(handle);
701
+ shell.reset(); // el terminal arranca en la raíz del proyecto
702
+ galaxy?.stop();
703
+ $('landing').hidden = true;
704
+ $('ide').hidden = false;
705
+ $('project-name').textContent = name;
706
+ $('explorer-proj').textContent = name.toUpperCase();
707
+ await initEditor();
708
+ await refreshTree(handle);
709
+ // el sistema de conversaciones primero: renderActiveLog() REEMPLAZA todo
710
+ // #chat-log con el historial replay de la conversación activa — si el
711
+ // saludo se añadiera antes, desaparecería sin dejar rastro.
712
+ await conv.init({ onEvent: onConvEvent });
713
+ renderTabsBar();
714
+ renderActiveLog();
715
+ addMsg('sys', `Привіт 👋 Proyecto «${name}» abierto. Pregúntame por el código, pídeme cambios o dime «árbol». Todo se queda en tu máquina.`);
716
+ refreshGit();
717
+ preloadModel(); // por si el arranque en la landing no llegó a dispararse
718
+ }
719
+
720
+ async function boot() {
721
+ // Caché de modelos (persistente + service worker) ANTES de descargar nada,
722
+ // para que hasta la primera descarga de pesos quede cacheada y no se repita.
723
+ ensureModelCache().finally(() => preloadModel());
724
+ bridge.tryAutoConnect(); // reconecta en silencio si ya se conectó antes (mismo token)
725
+ bridge.startAutoDetect(); // sigue sondeando en 2º plano: detecta el bridge si se arranca/reinicia más tarde
726
+ // galaxia en la landing (misma técnica que Elffuss)
727
+ import('./splash-gl.js').then(m => { galaxy = m.startGalaxy($('landing')); }).catch(() => {});
728
+
729
+ $('open-project').onclick = async () => {
730
+ try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); }
731
+ catch { /* cancelado */ }
732
+ };
733
+
734
+ // clonar un repo público (GitHub/Bitbucket): elige carpeta destino, se
735
+ // descarga ahí (fichero a fichero, sin git) y se abre como proyecto.
736
+ // RESUMIBLE: el trabajo (lista de ficheros + cuáles ya se bajaron + el
737
+ // handle de la carpeta) se persiste en IndexedDB — si el usuario navega
738
+ // atrás, cierra la pestaña o recarga a medias, al volver se ofrece
739
+ // «Reanudar» en vez de forzar a empezar de cero.
740
+ const cloneUrl = $('clone-url'), cloneBtn = $('clone-btn'), cloneStatus = $('clone-status');
741
+ const showCloneStatus = (text, isErr) => { cloneStatus.textContent = text; cloneStatus.hidden = !text; cloneStatus.classList.toggle('err', !!isErr); };
742
+ const saveCloneJob = job => db.set('kv', 'cloneJob', job).catch(() => {});
743
+ const loadCloneJob = () => db.get('kv', 'cloneJob').catch(() => null);
744
+ const clearCloneJob = () => db.del('kv', 'cloneJob').catch(() => {});
745
+
746
+ async function runDownload(job) {
747
+ cloneBtn.disabled = true;
748
+ const doneSet = new Set(job.done);
749
+ const clone = await import('./tools/clone.js');
750
+ try {
751
+ await clone.downloadFiles(job, job.handle, doneSet,
752
+ p => showCloneStatus(p.text || ''),
753
+ async path => {
754
+ job.done.push(path);
755
+ // persistir el progreso poco a poco (no en cada fichero: de sobra
756
+ // para no perder más de un puñado si se interrumpe a media descarga)
757
+ if (job.done.length % 5 === 0) await saveCloneJob(job);
758
+ });
759
+ await saveCloneJob(job);
760
+ showCloneStatus(`✔ ${job.files.length} ficheros descargados` + (job.skipped ? ` (${job.skipped} omitidos por tamaño)` : ''));
761
+ await clearCloneJob();
762
+ await enterIDE(job.handle);
763
+ } catch (e) {
764
+ await saveCloneJob(job); // que lo ya bajado no se pierda aunque falle a mitad
765
+ showCloneStatus('⚠️ ' + (e?.message || e) + ' — puedes reintentar, retoma donde se quedó', true);
766
+ } finally { cloneBtn.disabled = false; }
767
+ }
768
+
769
+ const runClone = async () => {
770
+ const url = cloneUrl.value.trim();
771
+ if (!url) return;
772
+ let handle;
773
+ try { handle = await window.showDirectoryPicker({ mode: 'readwrite' }); }
774
+ catch { return; } // cancelado, sin error
775
+ showCloneStatus('Listando el repo…');
776
+ try {
777
+ const clone = await import('./tools/clone.js');
778
+ const info = await clone.listRepo(url);
779
+ const job = { url, handle, done: [], ...info };
780
+ await saveCloneJob(job);
781
+ await runDownload(job);
782
+ } catch (e) {
783
+ showCloneStatus('⚠️ ' + (e?.message || e), true);
784
+ }
785
+ };
786
+ cloneBtn.addEventListener('click', runClone);
787
+ cloneUrl.addEventListener('keydown', e => { if (e.key === 'Enter') runClone(); });
788
+
789
+ // ¿había una descarga a medias de una sesión anterior? ofrece reanudarla
790
+ const job = await loadCloneJob();
791
+ if (job && job.handle && job.files && job.done.length < job.files.length) {
792
+ const banner = $('resume-clone');
793
+ banner.hidden = false;
794
+ banner.querySelector('.rc-text').textContent = `Descarga a medias de ${job.owner}/${job.repo} (${job.done.length}/${job.files.length} ficheros)`;
795
+ $('resume-clone-btn').onclick = async () => {
796
+ banner.hidden = true;
797
+ try {
798
+ const q = job.handle.queryPermission ? await job.handle.queryPermission({ mode: 'readwrite' }) : 'granted';
799
+ if (q !== 'granted' && await job.handle.requestPermission({ mode: 'readwrite' }) !== 'granted') throw new Error('permiso denegado para la carpeta');
800
+ showCloneStatus(`Reanudando ${job.owner}/${job.repo}…`);
801
+ await runDownload(job);
802
+ } catch (e) { showCloneStatus('⚠️ ' + (e?.message || e), true); }
803
+ };
804
+ $('discard-clone-btn').onclick = async () => { await clearCloneJob(); banner.hidden = true; };
805
+ } else if (job) {
806
+ await clearCloneJob(); // job corrupto o ya completo: limpiar
807
+ }
808
+
809
+ // gancho de test: ?test-opfs usa el almacenamiento del navegador como proyecto
810
+ if (location.search.includes('test-opfs')) {
811
+ const opfs = await navigator.storage.getDirectory();
812
+ return enterIDE(opfs);
813
+ }
814
+
815
+ // reabrir el último proyecto (un clic si el navegador exige gesto)
816
+ const prev = await codeTools.restoreProject();
817
+ if (prev?.ready) return enterIDE(await db.get('kv', 'project'));
818
+ if (prev?.handle) {
819
+ const btn = $('reopen-project');
820
+ btn.hidden = false;
821
+ btn.textContent = `Reabrir «${prev.name}»`;
822
+ btn.onclick = async () => {
823
+ try { await codeTools.regrant(prev.handle); await enterIDE(prev.handle); }
824
+ catch (e) { $('statusbar').textContent = '⚠️ ' + e.message; }
825
+ };
826
+ }
827
+ }
828
+
829
+ // Historial del prompt del chat: ↑/↓ recuperan lo enviado (como una shell).
830
+ // Persistente entre recargas (localStorage, últimos 100).
831
+ const PROMPT_HIST_KEY = 'elffusscode.promptHistory';
832
+ let promptHistory = [];
833
+ try { promptHistory = JSON.parse(localStorage.getItem(PROMPT_HIST_KEY) || '[]'); } catch { /* corrupto */ }
834
+ let promptHistIdx = -1, promptDraft = '';
835
+ function pushPromptHistory(text) {
836
+ if (promptHistory[promptHistory.length - 1] !== text) promptHistory.push(text);
837
+ if (promptHistory.length > 100) promptHistory = promptHistory.slice(-100);
838
+ try { localStorage.setItem(PROMPT_HIST_KEY, JSON.stringify(promptHistory)); } catch { /* lleno */ }
839
+ promptHistIdx = -1; promptDraft = '';
840
+ }
841
+ $('prompt').addEventListener('keydown', e => {
842
+ if (!$('menu').hidden) return; // el menú «/» ya usa ↑/↓
843
+ const inp = e.target;
844
+ if (e.key === 'ArrowUp') {
845
+ if (!promptHistory.length) return;
846
+ if (promptHistIdx === -1) { promptDraft = inp.value; promptHistIdx = promptHistory.length; }
847
+ promptHistIdx = Math.max(0, promptHistIdx - 1);
848
+ inp.value = promptHistory[promptHistIdx];
849
+ e.preventDefault();
850
+ requestAnimationFrame(() => inp.setSelectionRange(inp.value.length, inp.value.length));
851
+ } else if (e.key === 'ArrowDown') {
852
+ if (promptHistIdx === -1) return;
853
+ promptHistIdx++;
854
+ inp.value = promptHistIdx >= promptHistory.length ? (promptHistIdx = -1, promptDraft) : promptHistory[promptHistIdx];
855
+ e.preventDefault();
856
+ } else if (e.key === 'Enter' && !e.isComposing && !e.shiftKey) {
857
+ // envío explícito: no depender del submit-on-Enter nativo del <form>,
858
+ // que algún navegador puede "tragarse" el primer Enter (autocompletar/
859
+ // sugerencias) justo después de rellenar el valor por código (↑ del
860
+ // historial) en vez de por tecleo real.
861
+ e.preventDefault();
862
+ $('composer').requestSubmit();
863
+ }
864
+ });
865
+ $('composer').addEventListener('submit', e => {
866
+ e.preventDefault();
867
+ const text = $('prompt').value.trim();
868
+ if (!text) return;
869
+ $('prompt').value = '';
870
+ pushPromptHistory(text);
871
+ send(text);
872
+ });
873
+ $('model-select').addEventListener('change', e => changeModel(e.target.value));
874
+ // móvil: conmutar chat ↔ editor
875
+ $('code-flip').addEventListener('click', () => {
876
+ const showEditor = !document.body.classList.contains('show-editor');
877
+ document.body.classList.toggle('show-editor', showEditor);
878
+ paintFlip(showEditor);
879
+ });
880
+
881
+ // vistas Arquitectura (grafo) y Ciudad 3D — leen el proyecto abierto y al
882
+ // hacer clic abren el fichero en Monaco (inspirado en CodeFlow + VibeCodeViewer)
883
+ $('act-arch').innerHTML = UI.graph || UI.code;
884
+ $('act-city').innerHTML = UI.city || UI.code;
885
+ let viewRenderSeq = 0;
886
+ async function openView(kind) {
887
+ const overlay = $('view-overlay'), body = $('view-body');
888
+ const seq = ++viewRenderSeq;
889
+ // destruir CUALQUIER vista previa (su RAF/canvas seguía vivo al cambiar → la
890
+ // nueva no cargaba bien). Ambas, no solo la del mismo tipo.
891
+ try { (await import('./city.js')).disposeCity(); } catch { /* */ }
892
+ try { (await import('./arch.js')).disposeArch(); } catch { /* */ }
893
+ body.innerHTML = '<div class="view-loading">Construyendo…</div>';
894
+ overlay.hidden = false;
895
+ document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-' + kind));
896
+ // esperar a que el layout dé tamaño real al overlay (si no, canvas 0×0/mal aspect)
897
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
898
+ if (seq !== viewRenderSeq) return; // el usuario cambió de vista mientras tanto
899
+ try {
900
+ if (kind === 'arch') { const m = await import('./arch.js'); await m.renderArchitecture(body, p => { closeView(); openFile(p); }); }
901
+ else { const m = await import('./city.js'); await m.renderCity(body, p => { closeView(); openFile(p); }); }
902
+ } catch (e) { body.innerHTML = '<div class="view-loading">No pude construir la vista: ' + (e?.message || e) + '</div>'; }
903
+ }
904
+ function closeView() {
905
+ $('view-overlay').hidden = true;
906
+ import('./city.js').then(m => m.disposeCity()).catch(() => {});
907
+ import('./arch.js').then(m => m.disposeArch()).catch(() => {});
908
+ document.querySelectorAll('#activity button').forEach(b => b.classList.toggle('on', b.id === 'act-files'));
909
+ }
910
+ $('act-arch').addEventListener('click', () => openView('arch'));
911
+ $('act-city').addEventListener('click', () => openView('city'));
912
+ $('view-close').addEventListener('click', closeView);
913
+
914
+ // ── Cerebro CEO autónomo + Mente de Elffuss ───────────────────────────────
915
+ // Cuando NO estás pidiendo nada, la elfa trabaja: revisa el proyecto y propone
916
+ // mejoras (en elffuss-mind/, sin tocar tu código). Clic en la elfa → abre la
917
+ // «Mente» (mundo trance + pensamientos paralelos + música) y activa el cerebro.
918
+ mind.setOpenFile(openFile);
919
+ mind.init({ buildCity: buildCityAdapter, loadThoughts: loadThoughtsAdapter });
920
+ // «Ejecutar esta propuesta»: la manda a la MISMA cola/agente del chat normal
921
+ // (mismas herramientas reales, mismo gate de Auto-edit) — no un auto-apply
922
+ // aparte sin supervisión.
923
+ mind.setExecuteProposal(md => { mind.closeMind(); send('Implementa esta propuesta de mejora con cambios reales en el código:\n\n' + md); });
924
+ // «perfiles»: cada uno es una línea de pensamiento paralela (foco + color de
925
+ // su estrella) del cerebro CEO (compartido, core/ceo.js). El usuario los
926
+ // edita/crea/borra desde ⚙ en la Mente.
927
+ const CEO_DEFAULT_PROFILES = [
928
+ { id: 'arq', name: 'Arquitectura', focus: 'estructura, acoplamiento y dependencias; qué módulo conviene dividir o unificar', color: '#7c5cff' },
929
+ { id: 'cal', name: 'Calidad', focus: 'bugs latentes, casos borde sin cubrir, validaciones que faltan', color: '#49e8ff' },
930
+ { id: 'rend', name: 'Rendimiento', focus: 'cuellos de botella, trabajo repetido, estructuras de datos mejorables', color: '#ffb454' },
931
+ { id: 'dx', name: 'Producto/DX', focus: 'legibilidad, nombres, documentación y ergonomía para quien lo usa', color: '#3fb970' },
932
+ ];
933
+ // adaptador de workspace para el cerebro CEO genérico (core/ceo.js): traduce
934
+ // sus operaciones a las herramientas de code.js (proyecto de código único).
935
+ async function ceoDirHandle(dirPath) {
936
+ let dir = codeTools.handle();
937
+ for (const seg of dirPath.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg, { create: true });
938
+ return dir;
939
+ }
940
+ const ceoWorkspace = {
941
+ isReady: () => !!codeTools.handle(),
942
+ tree: (opts) => codeTools.tree(opts),
943
+ write: (opts) => codeTools.write(opts),
944
+ read: (opts) => codeTools.read(opts),
945
+ list: async (dirPath) => {
946
+ const dir = await ceoDirHandle(dirPath);
947
+ const names = [];
948
+ for await (const e of dir.values()) if (e.kind === 'file') names.push(e.name);
949
+ return names;
950
+ },
951
+ remove: async (dirPath, name) => (await ceoDirHandle(dirPath)).removeEntry(name),
952
+ };
953
+ ceo.init({
954
+ namespace: 'elffusscode',
955
+ workspace: ceoWorkspace,
956
+ defaultProfiles: CEO_DEFAULT_PROFILES,
957
+ defaultMission: 'Revisar el proyecto y proponer mejoras concretas y accionables (código, procesos, datos o docs).',
958
+ provider: () => conv.getProvider(),
959
+ // el usuario tiene PRIORIDAD: si CUALQUIER conversación está en cola o procesándose, el cerebro espera
960
+ isBusy: () => conv.isBusy(),
961
+ onEvent: (ch, ev) => {
962
+ mind.pushThought(ch, ev);
963
+ if (ch === 'ceo' && ev.type === 'built') reportImprovements(ev);
964
+ // la elfa se anima mientras el cerebro trabaja (invita a hacer clic para ver la Mente)
965
+ const av = document.querySelector('#activity img');
966
+ if (av) {
967
+ if (ch === 'ceo' && ev.type === 'cycle') av.classList.add('working');
968
+ if (ch === 'ceo' && (ev.type === 'built' || ev.type === 'paused')) av.classList.remove('working');
969
+ }
970
+ },
971
+ });
972
+ // Reporta las mejoras SIEMPRE en el chat (visual, entendible); la notificación
973
+ // del navegador solo salta si de verdad hay algo interesante y bien trabajado
974
+ // — nada de avisar por un ciclo flojo con una propuesta de una línea.
975
+ const WEAK_RE = /^(no (encontr|hay|se me ocurre)|nada que|sin cambios|todo (está|esta) bien|no aplica)/i;
976
+ export function isNoteworthy(ev) {
977
+ const solid = (ev.proposals || []).filter(p => p.text && p.text.trim().length >= 60 && !WEAK_RE.test(p.text.trim()));
978
+ return solid.length >= 2; // al menos 2 departamentos con algo sustancioso, no una ocurrencia suelta
979
+ }
980
+ // manda una propuesta (el .md completo del ciclo) a la MISMA cola/agente real
981
+ // del chat — usado tanto por el botón del chat como por el de la notificación.
982
+ function executeProposal(md) { send('Implementa esta propuesta de mejora con cambios reales en el código:\n\n' + md); }
983
+
984
+ export function reportImprovements(ev) {
985
+ const props = ev.proposals || [];
986
+ if (!props.length) return;
987
+ const firstLine = t => (t || '').split('\n').find(l => l.trim())?.trim().slice(0, 160) || '';
988
+ const body = `💡 **Mientras estabas fuera revisé el proyecto y encontré ${props.length} mejora${props.length === 1 ? '' : 's'}:**\n\n` +
989
+ props.map(p => `**${p.dept}** — ${firstLine(p.text)}`).join('\n\n') +
990
+ (ev.path ? `\n\n_Guardado en \`${ev.path}\`. Dale a la elfa para verlo en la Mente, o ábrelo en el editor._` : '');
991
+ const div = addMsg('assistant', body);
992
+ // se guarda en el historial REAL de la conversación activa — si no, el
993
+ // aviso vive solo en el DOM y desaparece al cambiar de pestaña o recargar
994
+ const active = conv.getActive();
995
+ if (active) conv.appendMessage(active.id, 'assistant', body);
996
+ // botón REAL para ejecutar la propuesta desde el propio chat (antes solo
997
+ // se podía desde el panel de la Mente, había que ir a buscarlo)
998
+ const md = ev.md || body;
999
+ if (div) {
1000
+ const btn = document.createElement('button');
1001
+ btn.className = 'proposal-exec-btn';
1002
+ btn.textContent = '▶ Ejecutar esta propuesta';
1003
+ btn.onclick = () => { btn.disabled = true; btn.textContent = '▶ enviado a la cola…'; executeProposal(md); };
1004
+ div.appendChild(btn);
1005
+ }
1006
+ if (isNoteworthy(ev)) notify(`Elffuss encontró ${props.length} mejora${props.length === 1 ? '' : 's'}`, props.map(p => p.dept).join(' · '), md);
1007
+ }
1008
+ // SOLO notifica si el permiso YA está concedido. Nunca pide permiso aquí:
1009
+ // fuera de un gesto de usuario, Chrome/Firefox lo bloquean en silencio —
1010
+ // pedirlo vive únicamente en el clic de la elfa y en ⚙ de la Mente.
1011
+ // Vía el service worker (registration.showNotification) para poder incluir
1012
+ // un botón «▶ Ejecutar» de verdad en la propia notificación del sistema
1013
+ // (new Notification() normal no soporta acciones); si el SW no está listo,
1014
+ // cae a la notificación simple sin botón.
1015
+ async function notify(title, body, md) {
1016
+ if (!('Notification' in window) || Notification.permission !== 'granted') return;
1017
+ try {
1018
+ if ('serviceWorker' in navigator) {
1019
+ const reg = await navigator.serviceWorker.ready;
1020
+ if (reg.showNotification) {
1021
+ await reg.showNotification(title, {
1022
+ body, icon: 'img/elffuss-code.svg',
1023
+ actions: [{ action: 'execute', title: '▶ Ejecutar' }, { action: 'open', title: 'Ver en la Mente' }],
1024
+ data: { md },
1025
+ });
1026
+ return;
1027
+ }
1028
+ }
1029
+ } catch { /* cae a la notificación simple */ }
1030
+ try { new Notification(title, { body, icon: 'img/elffuss-code.svg' }); } catch { /* */ }
1031
+ }
1032
+ // clic en el botón de la notificación del sistema → lo mismo que en el chat
1033
+ if ('serviceWorker' in navigator) {
1034
+ navigator.serviceWorker.addEventListener('message', e => {
1035
+ if (e.data?.type !== 'notif-action') return;
1036
+ if (e.data.action === 'execute' && e.data.md) executeProposal(e.data.md);
1037
+ else mind.openMind();
1038
+ });
1039
+ }
1040
+ // cualquier interacción FUERA de la Mente cuenta como actividad → pausa el CEO
1041
+ const noteAct = e => { if (!e.target.closest?.('#mind-overlay')) ceo.noteActivity(); };
1042
+ document.addEventListener('pointerdown', noteAct, true);
1043
+ document.addEventListener('keydown', noteAct, true);
1044
+ const elfAvatar = document.querySelector('#activity img');
1045
+ if (elfAvatar) {
1046
+ elfAvatar.style.cursor = 'pointer';
1047
+ elfAvatar.title = 'Mente de Elffuss — cerebro autónomo';
1048
+ elfAvatar.addEventListener('click', () => {
1049
+ // auto-activa SOLO la primera vez (nunca decidiste play/stop); si lo
1050
+ // pausaste a propósito, abrir la Mente NO debe reactivarlo por sorpresa.
1051
+ if (!ceo.isEnabled() && !ceo.hasDecided()) ceo.enable();
1052
+ try { if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission(); } catch { /* */ }
1053
+ mind.openMind();
1054
+ });
1055
+ }
1056
+ // restaura tu última elección (play o stop) tal cual la dejaste
1057
+ if (ceo.wasEnabledLastSession()) ceo.enable();
1058
+
1059
+ // terminal integrado: xterm.js + shell sobre los ficheros REALES del proyecto.
1060
+ // La elfa comparte este shell (tool terminal.run) y su salida se refleja aquí.
1061
+ $('act-term').innerHTML = UI.terminal;
1062
+ shell.setHooks({ fsChange: () => refreshTree().catch(() => {}), openFile });
1063
+ setTerminalEcho((cmd, out) => terminal.echoAgent(cmd, out));
1064
+ async function toggleTerminal(force) {
1065
+ const show = force != null ? force : $('terminal-panel').hidden;
1066
+ $('terminal-panel').hidden = !show;
1067
+ $('act-term').classList.toggle('on', show);
1068
+ if (show) {
1069
+ const cap = shell.capabilities();
1070
+ $('term-caps').textContent = cap.bridge ? '🔌 Bridge local: node/npm/python reales' : 'shell del proyecto · node/npm/python → Bridge local (⚙ Ajustes)';
1071
+ await terminal.mount($('terminal-host'));
1072
+ terminal.refit();
1073
+ }
1074
+ }
1075
+ $('act-term').addEventListener('click', () => toggleTerminal());
1076
+ $('term-close').addEventListener('click', () => toggleTerminal(false));
1077
+ $('term-clear').addEventListener('click', () => terminal.clearScreen());
1078
+ // Ctrl+` (o Cmd+`) alterna el terminal, como en VS Code
1079
+ document.addEventListener('keydown', e => {
1080
+ if ((e.ctrlKey || e.metaKey) && e.key === '`') { e.preventDefault(); toggleTerminal(); }
1081
+ });
1082
+ // redimensionar arrastrando la barra superior del panel
1083
+ (() => {
1084
+ const panel = $('terminal-panel'), handle = $('term-resize');
1085
+ let sy = 0, sh = 0;
1086
+ const move = e => { const h = Math.max(120, Math.min(window.innerHeight - 160, sh + (sy - e.clientY))); panel.style.height = h + 'px'; terminal.refit(); };
1087
+ const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
1088
+ handle.addEventListener('mousedown', e => { sy = e.clientY; sh = panel.offsetHeight; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); e.preventDefault(); });
1089
+ })();
1090
+ window.addEventListener('resize', () => terminal.refit());
1091
+
1092
+ // iconos VS Code (sin emojis) en cabecera, composer y flip
1093
+ $('btn-history').innerHTML = UI.history;
1094
+ $('btn-settings').innerHTML = UI.gear;
1095
+ $('btn-send').innerHTML = UI.send;
1096
+ $('btn-plus').innerHTML = UI.add;
1097
+ $('btn-slash').innerHTML = UI.slash;
1098
+ const paintFlip = editor => { $('code-flip').innerHTML = (editor ? UI.chat : UI.editor) + `<span>${editor ? 'Chat' : 'Editor'}</span>`; };
1099
+ paintFlip(false);
1100
+
1101
+ // barra de actividad estilo VS Code
1102
+ $('act-files').innerHTML = ACTIVITY.files;
1103
+ $('act-search').innerHTML = ACTIVITY.search;
1104
+ $('act-settings').innerHTML = ACTIVITY.gear;
1105
+ $('act-files').addEventListener('click', () => {
1106
+ document.body.classList.toggle('hide-tree');
1107
+ $('act-files').classList.toggle('on');
1108
+ });
1109
+ $('act-search').addEventListener('click', () => {
1110
+ $('prompt').value = 'busca ';
1111
+ $('prompt').focus();
1112
+ });
1113
+ $('act-settings').addEventListener('click', () => $('btn-settings').click());
1114
+
1115
+ $('btn-history').addEventListener('click', () => {
1116
+ const panel = $('history-panel');
1117
+ panel.hidden = !panel.hidden;
1118
+ if (!panel.hidden) renderHistoryPanel();
1119
+ });
1120
+ document.addEventListener('pointerdown', e => {
1121
+ const p = $('history-panel');
1122
+ if (p.hidden) return;
1123
+ if (p.contains(e.target) || e.target.closest('#btn-history')) return;
1124
+ p.hidden = true;
1125
+ }, true);
1126
+ $('btn-settings').addEventListener('click', () => {
1127
+ const p = $('settings-panel');
1128
+ p.hidden = !p.hidden;
1129
+ if (!p.hidden) renderSettings();
1130
+ });
1131
+ // clic fuera del panel (o ir a Archivo/chat) lo cierra — antes solo la X lo hacía
1132
+ document.addEventListener('pointerdown', e => {
1133
+ const p = $('settings-panel');
1134
+ if (p.hidden) return;
1135
+ if (p.contains(e.target) || e.target.closest('#btn-settings, #act-settings')) return;
1136
+ p.hidden = true;
1137
+ }, true);
1138
+
1139
+ // ---------- composer estilo plugin: +, /, medidor de contexto, Auto ----------
1140
+
1141
+ // medidor de contexto: chars del historial / presupuesto (~4 chars/token)
1142
+ // Al máximo del modelo de serie: LFM2.5-1.2B soporta 32K nativo; reservamos
1143
+ // ~2K de sistema + 2K de generación → 28K para historial.
1144
+ const CTX_BUDGET_TOK = 28000;
1145
+ function updateCtxMeter() {
1146
+ if (!$('ctx-text')) return; // aún en la landing
1147
+ const chars = (conv.getActive()?.agent.history || []).reduce((s, m) => s + (m.content || '').length, 0);
1148
+ const tok = Math.round(chars / 4);
1149
+ const pct = Math.min(100, Math.round(tok / CTX_BUDGET_TOK * 100));
1150
+ const k = n => n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n;
1151
+ $('ctx-text').textContent = `ctx ${k(tok)} / ${k(CTX_BUDGET_TOK)}`;
1152
+ $('ctx-bar').style.width = pct + '%';
1153
+ $('ctx-bar').className = pct > 85 ? 'hot' : '';
1154
+ $('ctx-meter').title = pct > 85
1155
+ ? `Contexto casi lleno (${pct}%). Los mensajes viejos se comprimen solos; usa 🧹 para empezar limpio.`
1156
+ : `Contexto: ${tok} de ~${CTX_BUDGET_TOK} tokens (${100 - pct}% libre)`;
1157
+ }
1158
+ const _origAdd = addMsg; // refrescar el medidor cuando cambia la conversación
1159
+ addMsg = (...a) => { const r = _origAdd(...a); updateCtxMeter(); return r; };
1160
+
1161
+ // menú flotante genérico sobre el composer
1162
+ function openMenu(items) {
1163
+ const menu = $('menu');
1164
+ menu.replaceChildren();
1165
+ for (const it of items) {
1166
+ if (it.sep) { const s = document.createElement('div'); s.className = 'sep'; s.textContent = it.sep; menu.appendChild(s); continue; }
1167
+ const row = document.createElement('button');
1168
+ row.className = 'menu-item';
1169
+ row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
1170
+ row.onclick = () => { menu.hidden = true; it.run(); };
1171
+ menu.appendChild(row);
1172
+ }
1173
+ menu.hidden = false;
1174
+ }
1175
+ document.addEventListener('click', e => {
1176
+ if (!e.target.closest('#menu, #btn-slash, #btn-plus')) $('menu').hidden = true;
1177
+ });
1178
+
1179
+ // [/] comandos
1180
+ $('btn-slash').addEventListener('click', () => openMenu([
1181
+ { sep: 'Comandos' },
1182
+ { label: '/tree', hint: 'árbol del proyecto', run: () => send('árbol') },
1183
+ { label: '/search', hint: 'buscar en el código', run: () => { $('prompt').value = 'busca '; $('prompt').focus(); } },
1184
+ { label: '/skills', hint: 'gestionar skills', run: () => openSkillsPanel() },
1185
+ { label: '/model', hint: 'proveedores y modelo', run: () => $('btn-settings').click() },
1186
+ { label: '/clear', hint: 'nueva conversación', run: () => conv.create() },
1187
+ ]));
1188
+
1189
+ // [+] adjuntar archivo del proyecto como @ruta
1190
+ $('btn-plus').addEventListener('click', async () => {
1191
+ let tree = '';
1192
+ try { tree = await codeTools.tree({ depth: 3 }); } catch { /* sin proyecto */ }
1193
+ const files = tree.split('\n').filter(l => l.trim() && !l.includes('📁')).map(l => l.trim()).slice(0, 40);
1194
+ const { currentFile } = codeTools.current();
1195
+ const items = [{ sep: 'Adjuntar al mensaje (@)' }];
1196
+ if (currentFile) items.push({ label: '@' + currentFile, hint: 'archivo abierto', run: () => attach(currentFile) });
1197
+ for (const f of files.filter(f => f !== currentFile).slice(0, 20))
1198
+ items.push({ label: '@' + f, run: () => attach(f) });
1199
+ openMenu(items.length > 1 ? items : [{ sep: 'Abre un proyecto primero' }]);
1200
+ });
1201
+ function attach(path) {
1202
+ const p = $('prompt');
1203
+ p.value = (p.value + ' @' + path).trim() + ' ';
1204
+ p.focus();
1205
+ }
1206
+
1207
+ // </> Auto (Edit automatically): si off, pide confirmación antes de escribir
1208
+ let autoEdit = localStorage.getItem('elffusscode.autoedit') !== '0';
1209
+ function paintAuto() {
1210
+ const b = $('btn-autoedit');
1211
+ b.classList.toggle('on', autoEdit);
1212
+ b.querySelector('.ae-txt').textContent = autoEdit ? 'Auto' : 'Revisar';
1213
+ b.title = autoEdit ? 'Editar archivos automáticamente (clic para revisar antes)' : 'Pedir confirmación antes de escribir (clic para automático)';
1214
+ }
1215
+ $('btn-autoedit').addEventListener('click', () => {
1216
+ autoEdit = !autoEdit;
1217
+ localStorage.setItem('elffusscode.autoedit', autoEdit ? '1' : '0');
1218
+ paintAuto();
1219
+ });
1220
+ paintAuto();
1221
+ codeTools.setWriteApprover(async (path, content) => {
1222
+ if (autoEdit) return true;
1223
+ return confirm(`Elffuss quiere escribir en «${path}» (${content.split('\n').length} líneas).\n\n¿Aplicar el cambio?`);
1224
+ });
1225
+
1226
+ // 🎯 Goal: el siguiente mensaje que mandes se trata como un OBJETIVO — se
1227
+ // planifica en tareas y se ejecutan una a una (goal.js), en vez del turno de
1228
+ // chat normal. Mismo patrón (interruptor persistente en localStorage) que
1229
+ // </> Auto de arriba.
1230
+ let goalMode = localStorage.getItem('elffusscode.goalmode') === '1';
1231
+ function paintGoal() {
1232
+ $('btn-goal').classList.toggle('on', goalMode);
1233
+ }
1234
+ $('btn-goal').addEventListener('click', () => {
1235
+ goalMode = !goalMode;
1236
+ localStorage.setItem('elffusscode.goalmode', goalMode ? '1' : '0');
1237
+ paintGoal();
1238
+ });
1239
+ paintGoal();
1240
+
1241
+ // resolver @rutas del mensaje: se leen y se adjuntan como contexto
1242
+ const _send = send;
1243
+ send = async (text) => {
1244
+ const refs = [...text.matchAll(/@([\w./-]+\.\w+)/g)].map(m => m[1]);
1245
+ if (refs.length) {
1246
+ let ctx = '';
1247
+ for (const r of [...new Set(refs)].slice(0, 4)) {
1248
+ try { ctx += `\n\n--- ${r} ---\n${await codeTools.read({ path: r })}`; } catch { /* ignora */ }
1249
+ }
1250
+ if (ctx) text += '\n\n[archivos adjuntos]' + ctx;
1251
+ }
1252
+ _send(text);
1253
+ };
1254
+
1255
+ // ---------- panel de skills (transparente: repo + lista + SKILL.md) ----------
1256
+ async function openSkillsPanel() {
1257
+ const box = $('settings-panel');
1258
+ box.hidden = false;
1259
+ box.replaceChildren();
1260
+ const close = document.createElement('button');
1261
+ close.id = 'settings-close'; close.textContent = '✕'; close.onclick = () => { box.hidden = true; };
1262
+ box.appendChild(close);
1263
+
1264
+ const wrap = document.createElement('div');
1265
+ wrap.className = 'skills-panel';
1266
+ wrap.innerHTML = `<h3>🧩 Skills de Claude Code</h3>
1267
+ <p class="muted">Instala instrucciones especializadas (formato SKILL.md) desde repos públicos.
1268
+ Todo se guarda en tu navegador. Se ve el repo y lo que se inyecta.</p>`;
1269
+ box.appendChild(wrap);
1270
+
1271
+ // instaladas
1272
+ const inst = skills.installed();
1273
+ const instBox = document.createElement('div');
1274
+ instBox.innerHTML = `<div class="sk-h">Instaladas (${inst.length})</div>`;
1275
+ for (const s of inst) {
1276
+ const row = document.createElement('div');
1277
+ row.className = 'sk-row';
1278
+ row.innerHTML = `<b>${s.name}</b><span class="muted">${s.repo || 'local'}</span>`;
1279
+ const rm = document.createElement('button');
1280
+ rm.className = 'ghost'; rm.textContent = 'Quitar';
1281
+ rm.onclick = async () => { await skills.remove(s.name); openSkillsPanel(); };
1282
+ row.appendChild(rm);
1283
+ instBox.appendChild(row);
1284
+ }
1285
+ if (!inst.length) instBox.innerHTML += '<p class="muted">Ninguna todavía.</p>';
1286
+ box.appendChild(instBox);
1287
+
1288
+ // fuentes + añadir repo
1289
+ const srcs = await skills.sources();
1290
+ const srcBox = document.createElement('div');
1291
+ srcBox.innerHTML = `<div class="sk-h">Catálogos (repos)</div>`;
1292
+ for (const s of srcs) {
1293
+ const row = document.createElement('div');
1294
+ row.className = 'sk-row';
1295
+ row.innerHTML = `<b>${s.label}</b><a href="https://github.com/${s.repo}" target="_blank" rel="noopener">${s.repo} ↗</a>`;
1296
+ const browse = document.createElement('button');
1297
+ browse.className = 'primary'; browse.textContent = 'Explorar';
1298
+ browse.onclick = () => browseRepo(s.repo, box);
1299
+ row.appendChild(browse);
1300
+ if (!s.official) {
1301
+ const rm = document.createElement('button');
1302
+ rm.className = 'ghost'; rm.textContent = '🗑';
1303
+ rm.onclick = async () => { await skills.removeSource(s.repo); openSkillsPanel(); };
1304
+ row.appendChild(rm);
1305
+ }
1306
+ srcBox.appendChild(row);
1307
+ }
1308
+ const addRow = document.createElement('div');
1309
+ addRow.className = 'sk-row';
1310
+ const inp = document.createElement('input');
1311
+ inp.placeholder = 'owner/repo o URL de GitHub (p. ej. OpenClaude/…)';
1312
+ const add = document.createElement('button');
1313
+ add.className = 'primary'; add.textContent = 'Añadir repo';
1314
+ add.onclick = async () => {
1315
+ try { await skills.addSource(inp.value); openSkillsPanel(); }
1316
+ catch (e) { alert(e.message); }
1317
+ };
1318
+ addRow.append(inp, add);
1319
+ srcBox.appendChild(addRow);
1320
+ box.appendChild(srcBox);
1321
+ }
1322
+
1323
+ async function browseRepo(repo, box) {
1324
+ const list = document.createElement('div');
1325
+ list.innerHTML = `<div class="sk-h">Cargando ${repo}…</div>`;
1326
+ box.appendChild(list);
1327
+ try {
1328
+ const found = await skills.listFromRepo(repo);
1329
+ list.innerHTML = `<div class="sk-h">${repo} — ${found.length} skills</div>`;
1330
+ for (const sk of found.slice(0, 120)) {
1331
+ const row = document.createElement('div');
1332
+ row.className = 'sk-row';
1333
+ row.innerHTML = `<b>${sk.name}</b><span class="muted">${sk.dir}</span>`;
1334
+ const btn = document.createElement('button');
1335
+ const on = skills.isInstalled(sk.repo, sk.path);
1336
+ btn.className = on ? 'ghost' : 'primary';
1337
+ btn.textContent = on ? 'Instalada ✓' : 'Instalar';
1338
+ btn.onclick = async () => {
1339
+ btn.textContent = '…';
1340
+ try {
1341
+ const entry = await skills.installFromRepo(sk);
1342
+ btn.textContent = 'Instalada ✓'; btn.className = 'ghost';
1343
+ $('settings-panel').hidden = true; // cierra el panel
1344
+ addMsg('assistant', skills.usageMessage(entry)); // «cómo usarla» en el chat
1345
+ } catch (e) { btn.textContent = 'Instalar'; alert(e.message); }
1346
+ };
1347
+ row.appendChild(btn);
1348
+ list.appendChild(row);
1349
+ }
1350
+ } catch (e) {
1351
+ list.innerHTML = `<div class="sk-h">⚠️ ${e.message}</div>`;
1352
+ }
1353
+ }
1354
+
1355
+ // ---------- command palette (Cmd/Ctrl+P) ----------
1356
+ let palItems = [], palSel = 0;
1357
+ const COMMANDS = [
1358
+ { label: 'Nueva conversación', run: () => conv.create() },
1359
+ { label: 'Historial de conversaciones', run: () => $('btn-history').click() },
1360
+ { label: 'Buscar en el código…', run: () => { closePalette(); $('prompt').value = 'busca '; $('prompt').focus(); } },
1361
+ { label: 'Gestionar skills', run: () => { closePalette(); openSkillsPanel(); } },
1362
+ { label: 'Ajustes y modelo', run: () => { closePalette(); renderSettings(); } },
1363
+ { label: 'Alternar explorador', run: () => $('act-files').click() },
1364
+ { label: 'Alternar terminal', hint: 'Ctrl+`', run: () => { closePalette(); toggleTerminal(); } },
1365
+ { label: 'Guardar (Ctrl+S)', run: () => triggerEditor('') || (hasEditor() && triggerEditor('workbench.action.files.save')) },
1366
+ ];
1367
+
1368
+ function openPalette(prefix = '') {
1369
+ $('palette').hidden = false;
1370
+ const inp = $('pal-input');
1371
+ inp.value = prefix;
1372
+ inp.focus();
1373
+ renderPalette();
1374
+ }
1375
+ function closePalette() { $('palette').hidden = true; }
1376
+
1377
+ function fuzzy(q, s) {
1378
+ q = q.toLowerCase(); s = s.toLowerCase();
1379
+ let i = 0, score = 0, streak = 0;
1380
+ for (const ch of s) {
1381
+ if (i < q.length && ch === q[i]) { i++; streak++; score += streak; }
1382
+ else streak = 0;
1383
+ }
1384
+ return i === q.length ? score + (s.endsWith(q) ? 20 : 0) : -1;
1385
+ }
1386
+
1387
+ async function renderPalette() {
1388
+ const raw = $('pal-input').value;
1389
+ const list = $('pal-list');
1390
+ list.replaceChildren();
1391
+ palItems = [];
1392
+
1393
+ if (raw.startsWith('>')) { // comandos
1394
+ const q = raw.slice(1).trim().toLowerCase();
1395
+ palItems = COMMANDS.filter(c => c.label.toLowerCase().includes(q)).map(c => ({ label: c.label, hint: 'comando', run: c.run }));
1396
+ } else if (raw.startsWith('@')) { // símbolos del archivo abierto (Monaco)
1397
+ palItems = [{ label: 'Ir a símbolo en el editor…', hint: '@', run: () => { closePalette(); triggerEditor('editor.action.quickOutline'); } }];
1398
+ } else { // archivos (con :línea opcional)
1399
+ const [pathQ, lineQ] = raw.split(':');
1400
+ const files = await codeTools.fileList().catch(() => []);
1401
+ const scored = pathQ.trim()
1402
+ ? files.map(f => ({ f, s: fuzzy(pathQ.trim(), f.split('/').pop()) * 2 + fuzzy(pathQ.trim(), f) })).filter(x => x.s > 0).sort((a, b) => b.s - a.s)
1403
+ : files.slice(0, 40).map(f => ({ f, s: 0 }));
1404
+ palItems = scored.slice(0, 40).map(({ f }) => ({
1405
+ label: f.split('/').pop(), hint: f, icon: true,
1406
+ run: () => { closePalette(); openFile(f); if (lineQ && +lineQ) setTimeout(() => gotoLine(+lineQ), 300); },
1407
+ }));
1408
+ }
1409
+ palSel = 0;
1410
+ palItems.forEach((it, i) => {
1411
+ const row = el('button', 'pal-item' + (i === 0 ? ' sel' : ''));
1412
+ row.innerHTML = `<span class="pi-name">${it.label}</span><span class="pi-hint">${it.hint || ''}</span>`;
1413
+ row.onmouseenter = () => { palSel = i; paintPalSel(); };
1414
+ row.onclick = () => it.run();
1415
+ list.appendChild(row);
1416
+ });
1417
+ if (!palItems.length) list.appendChild(el('div', 'pal-empty', 'Sin resultados'));
1418
+ }
1419
+ function paintPalSel() {
1420
+ [...$('pal-list').children].forEach((c, i) => c.classList.toggle('sel', i === palSel));
1421
+ $('pal-list').children[palSel]?.scrollIntoView({ block: 'nearest' });
1422
+ }
1423
+ $('pal-input').addEventListener('input', renderPalette);
1424
+ $('pal-input').addEventListener('keydown', e => {
1425
+ if (e.key === 'Escape') return closePalette();
1426
+ if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, palItems.length - 1); paintPalSel(); }
1427
+ if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); paintPalSel(); }
1428
+ if (e.key === 'Enter') { e.preventDefault(); palItems[palSel]?.run(); }
1429
+ });
1430
+ document.addEventListener('keydown', e => {
1431
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') { e.preventDefault(); openPalette(e.shiftKey ? '>' : ''); }
1432
+ if (e.key === 'Escape' && !$('palette').hidden) closePalette();
1433
+ });
1434
+ document.addEventListener('click', e => { if (!e.target.closest('#palette')) closePalette(); });
1435
+
1436
+ // ---------- barra de menú File/Edit/View/Git ----------
1437
+ async function menuFor(which) {
1438
+ if (which === 'file') return [
1439
+ { label: 'Nuevo archivo…', run: () => { const p = prompt('Ruta del nuevo archivo:'); if (p) codeTools.write({ path: p, content: '' }).then(() => { openFile(p); refreshTree(); }); } },
1440
+ { label: 'Guardar', hint: 'Ctrl+S', run: () => triggerEditor('workbench.action.files.save') },
1441
+ { label: 'Abrir carpeta…', run: async () => { try { await enterIDE(await window.showDirectoryPicker({ mode: 'readwrite' })); } catch {} } },
1442
+ ];
1443
+ if (which === 'edit') return [
1444
+ { label: 'Buscar en el código', run: () => { $('prompt').value = 'busca '; $('prompt').focus(); } },
1445
+ { label: 'Ir a archivo…', hint: 'Cmd+P', run: () => openPalette('') },
1446
+ { label: 'Ir a símbolo…', run: () => triggerEditor('editor.action.quickOutline') },
1447
+ ];
1448
+ if (which === 'view') return [
1449
+ { label: 'Explorador', run: () => $('act-files').click() },
1450
+ { label: 'Terminal', hint: 'Ctrl+`', run: () => toggleTerminal(true) },
1451
+ { label: 'Arquitectura', run: () => openView('arch') },
1452
+ { label: 'Ciudad 3D', run: () => openView('city') },
1453
+ { label: 'Command Palette', hint: 'Cmd+Shift+P', run: () => openPalette('>') },
1454
+ { label: 'Skills', run: () => openSkillsPanel() },
1455
+ { label: 'Acerca de Elffuss Code (GitHub)', hint: '↗', run: () => window.open('https://github.com/KikoCis/elffuss-code', '_blank', 'noopener') },
1456
+ ];
1457
+ if (which === 'git') {
1458
+ const g = await codeTools.gitInfo();
1459
+ if (!g.isRepo) return [{ label: 'No es un repositorio git', hint: '', run: () => {} }];
1460
+ const items = [{ label: 'Rama: ' + g.branch, hint: 'git', run: () => {} }];
1461
+ if (g.lastCommit) items.push({ label: 'Último: ' + g.lastCommit.msg.slice(0, 40), hint: g.lastCommit.author, run: () => {} });
1462
+ items.push({ label: 'Pídele a Elffuss que commitee', run: () => { $('prompt').value = 'resume los cambios y prepárame el mensaje de commit'; $('prompt').focus(); } });
1463
+ return items;
1464
+ }
1465
+ return [];
1466
+ }
1467
+ document.querySelectorAll('#menubar button').forEach(b => {
1468
+ b.addEventListener('click', async () => {
1469
+ document.querySelectorAll('#menubar button').forEach(x => x.classList.toggle('open', x === b));
1470
+ openMenuAt(await menuFor(b.dataset.menu), b);
1471
+ });
1472
+ });
1473
+ function openMenuAt(items, anchor) {
1474
+ const menu = $('topmenu');
1475
+ const r = anchor.getBoundingClientRect();
1476
+ menu.style.left = r.left + 'px';
1477
+ menu.style.top = (r.bottom + 3) + 'px';
1478
+ menu.replaceChildren();
1479
+ for (const it of items) {
1480
+ const row = el('button', 'menu-item');
1481
+ row.innerHTML = `<b>${it.label}</b>${it.hint ? `<span>${it.hint}</span>` : ''}`;
1482
+ row.onclick = () => { menu.hidden = true; it.run(); };
1483
+ menu.appendChild(row);
1484
+ }
1485
+ menu.hidden = false;
1486
+ }
1487
+ document.addEventListener('click', e => {
1488
+ if (!e.target.closest('#menubar, #topmenu')) {
1489
+ document.querySelectorAll('#menubar button').forEach(x => x.classList.remove('open'));
1490
+ $('topmenu').hidden = true;
1491
+ }
1492
+ });
1493
+
1494
+ // ---------- git en el header ----------
1495
+ async function refreshGit() {
1496
+ const g = await codeTools.gitInfo().catch(() => ({ isRepo: false }));
1497
+ const chip = $('git-branch');
1498
+ if (g.isRepo) { chip.hidden = false; chip.innerHTML = UI.code + ' ' + g.branch; }
1499
+ else chip.hidden = true;
1500
+ }
1501
+ $('git-branch').addEventListener('click', () => document.querySelector('#menubar button[data-menu="git"]').click());
1502
+
1503
+ // icono de skills en la barra de actividad
1504
+ $('act-skills').innerHTML = UI.puzzle;
1505
+ $('act-skills').addEventListener('click', () => openSkillsPanel());
1506
+
1507
+ rebuildSelect();
1508
+ skills.initSkills();
1509
+ boot();
1510
+ window.elffussClaw = { conv, get agent() { return conv.getActive()?.agent; }, send, openFile, parseToolCall, skills };
js/md.js ADDED
Binary file (1.71 kB). View file
 
js/mind-adapter.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Glue específico de Elffuss Code para la Mente genérica (mind.js, core): le
2
+ // da su ciudad de fondo real (motor VibeCodeViewer sobre el proyecto abierto)
3
+ // y la semilla de pensamientos ya guardados en la carpeta-alma del cerebro.
4
+ import * as THREE from 'three';
5
+ import * as codeTools from './tools/code.js';
6
+ import * as ceo from './ceo.js';
7
+ import { buildModel } from './vcc/model.js';
8
+ import { buildCity } from './vcc/builder.js';
9
+ import { THEME as VCC_THEME } from './vcc/theme.js';
10
+
11
+ const CITY_MAX_FILES = 2600;
12
+ const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
13
+
14
+ async function scanForCity(dir, budget) {
15
+ const dirs = [], files = [];
16
+ for await (const e of dir.values()) { if (IGNORE.has(e.name)) continue; (e.kind === 'directory' ? dirs : files).push(e); }
17
+ dirs.sort((a, b) => a.name.localeCompare(b.name)); files.sort((a, b) => a.name.localeCompare(b.name));
18
+ const outFiles = [];
19
+ for (const f of files) { if (budget.n >= CITY_MAX_FILES) break; let size = 0; try { size = (await f.getFile()).size; } catch { /* */ } outFiles.push([f.name, size]); budget.n++; }
20
+ const outDirs = [];
21
+ for (const d of dirs) { if (budget.n >= CITY_MAX_FILES) break; outDirs.push(await scanForCity(d, budget)); }
22
+ return [dir.name, outDirs, outFiles];
23
+ }
24
+
25
+ export async function buildCityAdapter(scene) {
26
+ const root = codeTools.handle();
27
+ if (!root) return null;
28
+ const tree = await scanForCity(root, { n: 0 });
29
+ const model = buildModel(tree, null);
30
+ if (!model.files.length) return null;
31
+ const extent = Math.max(model.city.box.sx, model.city.box.sz, 1);
32
+ const scale = Math.min(1, Math.max(0.05, 170 / extent));
33
+ const cityWrap = new THREE.Group();
34
+ cityWrap.position.set(0, -95, 0);
35
+ cityWrap.scale.setScalar(scale);
36
+ scene.add(cityWrap);
37
+ const cityCity = buildCity(cityWrap, model, VCC_THEME);
38
+ return { cityWrap, cityCity, cityModel: model };
39
+ }
40
+
41
+ export async function loadThoughtsAdapter() {
42
+ const soul = ceo.getSoulDir();
43
+ let dir = codeTools.handle();
44
+ for (const seg of soul.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(seg);
45
+ const files = [];
46
+ for await (const e of dir.values()) if (e.kind === 'file' && e.name.endsWith('.md')) files.push(e);
47
+ files.sort((a, b) => a.name.localeCompare(b.name));
48
+ const out = [];
49
+ for (const f of files.slice(-24)) out.push({ path: soul + '/' + f.name, md: await (await f.getFile()).text() });
50
+ return out;
51
+ }
js/mind.js ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vista «Mente de Elffuss» — el pensamiento subyacente del cerebro CEO en un
2
+ // mundo 3D de fantasía psicodélica trance, con la CIUDAD real del proyecto
3
+ // (motor VibeCodeViewer) flotando por debajo.
4
+ // · cada línea/tool-call que llega nace como una ESTRELLA de texto (color por
5
+ // PERFIL, tamaño/tipografía según lo que ocurre) que se desvanece sola.
6
+ // · los perfiles (nombre, foco, color) se editan desde ⚙ — el usuario los crea
7
+ // a su gusto.
8
+ // · «≡ historial»: panel con TODO lo que ha llegado, sin recortar.
9
+ // · cuando lee/escribe un fichero real, un haz baja hasta él en la ciudad.
10
+ // · overlay PERSISTENTE (música/animación no reinician al salir y volver).
11
+ import * as THREE from 'three';
12
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
13
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
14
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
15
+ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
16
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
17
+ import * as ceo from './ceo.js';
18
+ import * as db from './db.js';
19
+ import { renderMarkdown } from './md.js';
20
+
21
+ const SOUNDCLOUD = 'https://soundcloud.com/dekel-official/dekel-baoba-festival-2023';
22
+ const CEO_COLOR = '#ff4d8d';
23
+
24
+ let open = false, built = false, raf = null;
25
+ let overlay, S, widget, onOpenFile = () => {}, onExecute = () => {};
26
+ // Ciudad de fondo (vista real del workspace) y semilla de pensamientos previos:
27
+ // AMBOS opcionales e inyectados por la app anfitriona — Code tiene un motor
28
+ // VibeCodeViewer sobre su proyecto único; Claw (v1) no lo ofrece y la Mente
29
+ // funciona igual de bien sin ciudad de fondo.
30
+ let loadThoughts = async () => []; // () => [{path, md}]
31
+ let buildCityFn = null; // (scene) => {cityWrap, cityCity, cityModel} | null
32
+ export function init({ loadThoughts: lt, buildCity: bc } = {}) {
33
+ if (lt) loadThoughts = lt;
34
+ if (bc) buildCityFn = bc;
35
+ }
36
+ let anchorMap = new Map(); // id de perfil (+ 'ceo') → Vector3 (anclas del mundo)
37
+ const anchors = []; // { el, pos } — solo etiquetas de propuestas forjadas
38
+ let nodesGroup, thoughtNodes = [];
39
+ let starGroup, stars = []; // estrellas de pensamiento efímeras
40
+ const lineBuf = new Map(); // canal → texto acumulado hasta la línea completa
41
+ const streamed = new Map(); // canal → ¿llegaron tokens? (evita duplicar el texto final)
42
+ let cityWrap = null, cityCity = null, cityModel = null;
43
+ let beams = [], fileActivity = [];
44
+
45
+ export function isOpen() { return open; }
46
+ export function setOpenFile(fn) { onOpenFile = fn; }
47
+ export function setExecuteProposal(fn) { onExecute = fn; }
48
+
49
+ const escHtml = s => String(s).replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
50
+ const profileOf = channel => channel === 'ceo'
51
+ ? { id: 'ceo', name: 'CEO', color: CEO_COLOR }
52
+ : ceo.getProfiles().find(p => p.id === channel) || { id: channel, name: channel, color: '#7c5cff' };
53
+
54
+ // ── música con Widget API (persistente: pausa al salir, reanuda al volver) ──
55
+ function mountMusic(root) {
56
+ const wrap = document.createElement('div');
57
+ wrap.className = 'mind-music';
58
+ const url = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(SOUNDCLOUD) +
59
+ '&auto_play=true&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=false&color=%23ff4d8d';
60
+ wrap.innerHTML = '<iframe id="mind-sc" title="música" width="100%" height="120" frameborder="no" allow="autoplay; encrypted-media" src="' + url + '"></iframe>' +
61
+ '<div class="mind-music-cap">▶ Dekel · Baoba Festival — modo trance</div>';
62
+ root.appendChild(wrap);
63
+ const iframe = wrap.querySelector('#mind-sc');
64
+ const initWidget = () => { try { widget = window.SC.Widget(iframe); } catch { /* aún no */ } };
65
+ if (window.SC && window.SC.Widget) initWidget();
66
+ else { const s = document.createElement('script'); s.src = 'https://w.soundcloud.com/player/api.js'; s.onload = initWidget; document.head.appendChild(s); }
67
+ }
68
+
69
+ // ══ ESTRELLAS de pensamiento: cada línea/tool-call nace, brilla y se apaga ══
70
+ function makeTextSprite(text, color, { fontSize = 16, bold = false, glow = 10 } = {}) {
71
+ const pad = 8;
72
+ const probe = document.createElement('canvas').getContext('2d');
73
+ probe.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
74
+ const w = Math.ceil(probe.measureText(text).width) + pad * 2;
75
+ const h = fontSize + pad * 2;
76
+ const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
77
+ const g = cv.getContext('2d');
78
+ g.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
79
+ g.textBaseline = 'middle';
80
+ g.shadowColor = color; g.shadowBlur = glow;
81
+ g.fillStyle = color;
82
+ g.fillText(text, pad, h / 2 + 1);
83
+ const tex = new THREE.CanvasTexture(cv);
84
+ const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
85
+ const sprite = new THREE.Sprite(mat);
86
+ const scale = 0.09;
87
+ sprite.scale.set(w * scale, h * scale, 1);
88
+ return sprite;
89
+ }
90
+
91
+ // kind: 'line' (charla normal) · 'tool' (tool-call) · 'event' (ciclo/estado) · 'built' (propuesta forjada)
92
+ const KIND_STYLE = {
93
+ line: { fontSize: 13, bold: false, glow: 8, life: 6, jitter: 15 },
94
+ tool: { fontSize: 17, bold: true, glow: 16, life: 9, jitter: 9 },
95
+ event: { fontSize: 20, bold: true, glow: 14, life: 7, jitter: 6 },
96
+ built: { fontSize: 26, bold: true, glow: 22, life: 13, jitter: 4 },
97
+ };
98
+ function spawnStar(channel, text, kind = 'line') {
99
+ if (!starGroup || !text) return;
100
+ const prof = profileOf(channel);
101
+ const st = KIND_STYLE[kind] || KIND_STYLE.line;
102
+ const anchor = anchorMap.get(prof.id) || new THREE.Vector3(0, 20, 0);
103
+ const j = st.jitter;
104
+ const label = kind === 'tool' ? '⟐ ' + text : kind === 'event' ? '★ ' + text : kind === 'built' ? '✦ ' + text : text;
105
+ const sprite = makeTextSprite(label.slice(0, kind === 'line' ? 52 : 72), prof.color, st);
106
+ sprite.position.copy(anchor).add(new THREE.Vector3((Math.random() - 0.5) * j, (Math.random() - 0.5) * j * 0.6, (Math.random() - 0.5) * j));
107
+ sprite.material.opacity = 0;
108
+ starGroup.add(sprite);
109
+ stars.push({ obj: sprite, born: elapsed, life: st.life, seed: Math.random() * 1000, up: kind === 'built' ? 0.5 : 0.22 });
110
+ if (stars.length > 160) { const old = stars.shift(); starGroup.remove(old.obj); old.obj.material.map.dispose(); old.obj.material.dispose(); }
111
+ }
112
+
113
+ // ── historial COMPLETO (todo lo que llega, sin recortar) — PERSISTENTE: antes
114
+ // vivía solo en el DOM/memoria y una recarga de página lo borraba entero.
115
+ const LOG_CAP = 500;
116
+ let logHistory = []; // [{channel, text}] — se guarda en IndexedDB
117
+ let logSaveTimer = null;
118
+ function saveLogSoon() {
119
+ clearTimeout(logSaveTimer);
120
+ logSaveTimer = setTimeout(() => db.set('kv', 'mindLog', logHistory).catch(() => {}), 400);
121
+ }
122
+ function renderLogRow(channel, text) {
123
+ const body = document.getElementById('mind-log-body');
124
+ if (!body) return;
125
+ const prof = profileOf(channel);
126
+ const row = document.createElement('div');
127
+ row.className = 'ml-row';
128
+ row.innerHTML = `<span class="ml-dot" style="background:${prof.color}"></span><b style="color:${prof.color}">${escHtml(prof.name)}</b> <span class="ml-t">${escHtml(text)}</span>`;
129
+ body.appendChild(row);
130
+ while (body.children.length > LOG_CAP) body.removeChild(body.firstChild);
131
+ body.scrollTop = body.scrollHeight;
132
+ }
133
+ function logLine(channel, text) {
134
+ logHistory.push({ channel, text });
135
+ if (logHistory.length > LOG_CAP) logHistory = logHistory.slice(-LOG_CAP);
136
+ saveLogSoon();
137
+ renderLogRow(channel, text);
138
+ }
139
+ async function loadPersistedLog() {
140
+ try {
141
+ const saved = await db.get('kv', 'mindLog');
142
+ if (!Array.isArray(saved) || !saved.length) return;
143
+ logHistory = saved;
144
+ for (const { channel, text } of saved) renderLogRow(channel, text);
145
+ } catch { /* aún no hay historial guardado */ }
146
+ }
147
+
148
+ function flushLine(channel, force = false) {
149
+ const buf = (lineBuf.get(channel) || '').trim();
150
+ if (!buf && !force) return;
151
+ if (buf) { spawnStar(channel, buf, 'line'); logLine(channel, buf); }
152
+ lineBuf.set(channel, '');
153
+ }
154
+
155
+ // alimenta estrellas + historial + haces sobre la ciudad (ceo.js → aquí)
156
+ export function pushThought(channel, ev) {
157
+ if (!open) return;
158
+ if (channel === 'sys') { logLine('ceo', ev.text); return; }
159
+ if (channel === 'ceo') {
160
+ if (ev.type === 'cycle') { logLine('ceo', '● ' + ev.text); spawnStar('ceo', ev.text, 'event'); }
161
+ else if (ev.type === 'survey') { logLine('ceo', ev.text); }
162
+ else if (ev.type === 'reprogram') { logLine('ceo', '⚙ ' + ev.text); spawnStar('ceo', ev.text, 'event'); if (ev.profiles) rebuildWorld(); }
163
+ else if (ev.type === 'paused') { logLine('ceo', ev.text); spawnStar('ceo', ev.text, 'event'); }
164
+ else if (ev.type === 'built') { logLine('ceo', '✦ ' + ev.text); spawnStar('ceo', ev.text, 'built'); addThoughtNode(ev); }
165
+ else logLine('ceo', ev.text || '');
166
+ return;
167
+ }
168
+ if (ev.type === 'open') { lineBuf.set(channel, ''); streamed.set(channel, false); }
169
+ else if (ev.type === 'token') {
170
+ streamed.set(channel, true);
171
+ lineBuf.set(channel, (lineBuf.get(channel) || '') + ev.text);
172
+ const buf = lineBuf.get(channel);
173
+ if (/\n/.test(ev.text) || buf.length > 90) flushLine(channel);
174
+ } else if (ev.type === 'tool') {
175
+ flushLine(channel);
176
+ spawnStar(channel, ev.text, 'tool');
177
+ logLine(channel, '⟐ ' + ev.text);
178
+ if (ev.path) fileBeam(ev.path, /escrib/i.test(ev.text) ? 'write' : 'read');
179
+ } else if (ev.type === 'tool_result') {
180
+ // el RESULTADO real de la tool (lo que de verdad se leyó/escribió/ejecutó),
181
+ // enlazado justo debajo de su tool-call — no solo el nombre de la acción.
182
+ const t = '→ ' + (ev.text || '(sin salida)');
183
+ spawnStar(channel, t, 'line');
184
+ logLine(channel, t);
185
+ } else if (ev.type === 'done') {
186
+ flushLine(channel, true);
187
+ // el proveedor puede devolver el texto final SIN pasar por tokens (sin
188
+ // streaming) → si no vimos ningún token, esto es lo único que lo muestra.
189
+ if (ev.text && !streamed.get(channel)) { spawnStar(channel, ev.text, 'line'); logLine(channel, ev.text); }
190
+ }
191
+ }
192
+
193
+ // ── nodos de pensamiento clicables (cada .md forjado = un punto brillante) ──
194
+ function addThoughtNode({ path, md, text }) {
195
+ if (!nodesGroup) return;
196
+ const i = thoughtNodes.length;
197
+ const a = i * 2.399963; // ángulo áureo
198
+ const r = 30 + i * 5;
199
+ const mesh = new THREE.Mesh(
200
+ new THREE.IcosahedronGeometry(3.2, 0),
201
+ new THREE.MeshBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }));
202
+ mesh.position.set(Math.cos(a) * r, 6 + Math.sin(i * 0.7) * 10, Math.sin(a) * r);
203
+ mesh.userData = { path, md, text };
204
+ nodesGroup.add(mesh);
205
+ thoughtNodes.push(mesh);
206
+ const lab = document.createElement('div');
207
+ lab.className = 'mind-node-label';
208
+ lab.textContent = (path || 'pensamiento').split('/').pop();
209
+ // clicable ella misma — antes SOLO el raycast contra la diminuta malla 3D
210
+ // abría el panel, fácil de fallar; la etiqueta es el blanco visible real.
211
+ lab.addEventListener('click', () => selectThoughtNode(mesh));
212
+ document.getElementById('mind-anchors').appendChild(lab);
213
+ anchors.push({ el: lab, pos: mesh.position });
214
+ mesh.userData.label = lab;
215
+ }
216
+
217
+ async function loadExistingThoughts() {
218
+ try { for (const it of await loadThoughts()) addThoughtNode(it); } catch { /* aún no hay */ }
219
+ }
220
+
221
+ // selección de un nodo de propuesta — desde la malla 3D (raycast) o su etiqueta
222
+ function selectThoughtNode(mesh) { showThoughtPanel(mesh); focusOn(mesh.position, 26); }
223
+
224
+ function showThoughtPanel(node) {
225
+ const root = document.getElementById('mind-panel');
226
+ const md = node.userData.md || node.userData.text || '(sin contenido)';
227
+ const path = node.userData.path || 'pensamiento';
228
+ root.innerHTML = `<div class="mp-head">${escHtml(path)}<button id="mp-x">✕</button></div>` +
229
+ `<div class="mp-body">${renderMarkdown(md)}</div>` +
230
+ `<div class="mp-actions">` +
231
+ `<button id="mp-exec" class="mp-exec">▶ Ejecutar esta propuesta</button>` +
232
+ (node.userData.path ? `<button id="mp-open" class="mp-open">Abrir en el editor</button>` : '') +
233
+ `</div>`;
234
+ root.classList.add('show');
235
+ root.querySelector('#mp-x').onclick = () => root.classList.remove('show');
236
+ root.querySelector('#mp-exec').onclick = () => { onExecute(md); root.classList.remove('show'); };
237
+ const openBtn = root.querySelector('#mp-open');
238
+ if (openBtn) openBtn.onclick = () => { onOpenFile(node.userData.path); root.classList.remove('show'); };
239
+ }
240
+
241
+ // ══ perfiles: anclas 3D + leyenda (recalculadas al abrir o al reprogramar) ══
242
+ function computeAnchors() {
243
+ const map = new Map();
244
+ map.set('ceo', new THREE.Vector3(0, 46, 0));
245
+ const profs = ceo.getProfiles();
246
+ const n = Math.max(profs.length, 1);
247
+ profs.forEach((p, i) => {
248
+ const a = (i / n) * Math.PI * 2;
249
+ map.set(p.id, new THREE.Vector3(Math.cos(a) * 70, 8 + (i % 2) * 20, Math.sin(a) * 70));
250
+ });
251
+ return map;
252
+ }
253
+ function renderLegend() {
254
+ const el = document.getElementById('mind-legend');
255
+ if (!el) return;
256
+ const profs = ceo.getProfiles();
257
+ el.innerHTML = `<div class="ml-item" data-id="ceo"><span class="ml-sw" style="background:${CEO_COLOR}"></span>CEO</div>` +
258
+ profs.map(p => `<div class="ml-item" data-id="${p.id}"><span class="ml-sw" style="background:${p.color}"></span>${escHtml(p.name)}</div>`).join('');
259
+ el.querySelectorAll('.ml-item').forEach(row => {
260
+ row.onclick = () => { const pos = anchorMap.get(row.dataset.id); if (pos) focusOn(pos, 46); };
261
+ });
262
+ }
263
+ // vuelo suave de la cámara hacia un punto — así «clic en algo → la cámara se centra»
264
+ let flyTo = null;
265
+ function focusOn(pos, distance = 40) {
266
+ if (!S) return;
267
+ const dir = new THREE.Vector3().subVectors(S.camera.position, S.controls.target);
268
+ if (dir.lengthSq() < 0.001) dir.set(0, 0.3, 1);
269
+ dir.normalize().multiplyScalar(distance);
270
+ flyTo = { fromPos: S.camera.position.clone(), toPos: pos.clone().add(dir), fromTarget: S.controls.target.clone(), toTarget: pos.clone(), t0: elapsed, dur: 1.1 };
271
+ }
272
+ const easeInOut = k => k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
273
+ function rebuildWorld() { anchorMap = computeAnchors(); renderLegend(); }
274
+
275
+ // ── panel ⚙: reprograma misión, carpeta-alma y PERFILES (editables) ──────
276
+ function wireConfig(root) {
277
+ const btn = root.querySelector('#mind-config');
278
+ const cfg = root.querySelector('#mind-cfg');
279
+ const profRowHtml = p => `<div class="cfg-prof" data-id="${escHtml(p.id || '')}">
280
+ <input type="color" class="cp-color" value="${p.color || '#7c5cff'}">
281
+ <input type="text" class="cp-name" value="${escHtml(p.name || '')}" placeholder="Nombre">
282
+ <input type="text" class="cp-focus" value="${escHtml(p.focus || '')}" placeholder="En qué se centra">
283
+ <button class="cp-del" title="quitar">✕</button></div>`;
284
+ const notifState = () => (!('Notification' in window)) ? { txt: 'no soportadas por este navegador', can: false }
285
+ : Notification.permission === 'granted' ? { txt: '✓ concedidas — avisaré cuando encuentre algo bueno', can: false }
286
+ : Notification.permission === 'denied' ? { txt: '✕ bloqueadas — actívalas en los ajustes del sitio del navegador', can: false }
287
+ : { txt: 'aún no pedidas', can: true };
288
+ const draw = () => {
289
+ const profs = ceo.getProfiles();
290
+ const ns = notifState();
291
+ cfg.innerHTML =
292
+ '<div class="cfg-head">⚙ Reprogramar el cerebro<button id="cfg-x">✕</button></div>' +
293
+ '<div class="cfg-notif">🔔 Notificaciones del navegador: ' + escHtml(ns.txt) + (ns.can ? ' <button id="cfg-notif-ask" class="cfg-notif-btn">Activar</button>' : '') + '</div>' +
294
+ '<label>Misión (qué quieres que haga por su cuenta)</label>' +
295
+ '<textarea id="cfg-mission" rows="3">' + escHtml(ceo.getMission()) + '</textarea>' +
296
+ '<label>Carpeta-alma (dentro del proyecto)</label>' +
297
+ '<input id="cfg-dir" value="' + escHtml(ceo.getSoulDir()) + '">' +
298
+ '<label>Perfiles — cada uno piensa en paralelo, con el color de su estrella</label>' +
299
+ '<div id="cfg-profiles">' + profs.map(profRowHtml).join('') + '</div>' +
300
+ '<button id="cfg-add-prof" class="cfg-add">+ nuevo perfil</button>' +
301
+ '<div class="cfg-actions"><button id="cfg-think-now" class="cfg-ghost">Pensar ahora</button>' +
302
+ '<button id="cfg-save" class="cfg-save">Reprogramar</button></div>' +
303
+ '<div class="cfg-note">La elfa trabajará según esto cuando estés ocioso y volcará todo en esa carpeta.</div>';
304
+ cfg.querySelector('#cfg-x').onclick = () => cfg.classList.remove('show');
305
+ cfg.querySelector('#cfg-notif-ask')?.addEventListener('click', () => { Notification.requestPermission().then(draw); });
306
+ cfg.querySelector('#cfg-add-prof').onclick = () => {
307
+ cfg.querySelector('#cfg-profiles').insertAdjacentHTML('beforeend', profRowHtml({ color: '#ff9f45', name: '', focus: '' }));
308
+ wireRows();
309
+ };
310
+ cfg.querySelector('#cfg-think-now').onclick = () => { ceo.forceCycle(); cfg.classList.remove('show'); };
311
+ cfg.querySelector('#cfg-save').onclick = () => {
312
+ // preserva el id original de cada perfil (si no, cada guardado los
313
+ // regeneraría desde el nombre y los ciclos en curso perderían el hilo)
314
+ const rows = [...cfg.querySelectorAll('.cfg-prof')].map(r => ({
315
+ id: r.dataset.id || undefined,
316
+ color: r.querySelector('.cp-color').value,
317
+ name: r.querySelector('.cp-name').value.trim(),
318
+ focus: r.querySelector('.cp-focus').value.trim(),
319
+ }));
320
+ ceo.setMission(cfg.querySelector('#cfg-mission').value);
321
+ ceo.setSoulDir(cfg.querySelector('#cfg-dir').value);
322
+ ceo.setProfiles(rows);
323
+ if (!ceo.isEnabled()) ceo.enable();
324
+ cfg.classList.remove('show');
325
+ };
326
+ wireRows();
327
+ function wireRows() { cfg.querySelectorAll('.cp-del').forEach(b => { b.onclick = () => b.closest('.cfg-prof').remove(); }); }
328
+ };
329
+ btn.onclick = () => { if (cfg.classList.contains('show')) cfg.classList.remove('show'); else { draw(); cfg.classList.add('show'); } };
330
+ }
331
+ function wireHistory(root) {
332
+ const btn = root.querySelector('#mind-history'), panel = root.querySelector('#mind-log');
333
+ btn.onclick = () => panel.classList.toggle('show');
334
+ panel.querySelector('#ml-x').onclick = () => panel.classList.remove('show');
335
+ }
336
+
337
+ // play/stop del cerebro, visible dentro de la Mente. La elección la persiste
338
+ // ceo.js (misma clave que usa el clic en la elfa) → sobrevive a recargar.
339
+ function wirePlayStop(root) {
340
+ const btn = root.querySelector('#mind-playstop');
341
+ const paint = () => {
342
+ const on = ceo.isEnabled();
343
+ btn.textContent = on ? '⏸ pausar cerebro' : '▶ reanudar cerebro';
344
+ btn.classList.toggle('paused', !on);
345
+ };
346
+ btn.onclick = () => { if (ceo.isEnabled()) ceo.disable(); else ceo.enable(); paint(); };
347
+ paint();
348
+ }
349
+
350
+ // ══ ciudad de fondo (motor de la app anfitriona, por debajo del mundo) ══════
351
+ async function buildBackgroundCity(scene) {
352
+ if (!buildCityFn) return;
353
+ try {
354
+ const city = await buildCityFn(scene);
355
+ if (!city) return;
356
+ cityWrap = city.cityWrap; cityCity = city.cityCity; cityModel = city.cityModel;
357
+ } catch { /* sigue sin ciudad de fondo */ }
358
+ }
359
+ // haz de luz que baja hasta el fichero real en la ciudad + resalta su celda
360
+ function fileBeam(path, kind) {
361
+ if (!cityModel || !cityCity || !S) return;
362
+ const rel = String(path).replace(/^\.?\//, '');
363
+ const f = cityModel.fileByRel.get(rel);
364
+ if (!f) return;
365
+ const local = new THREE.Vector3(f.box.cx, f.box.cy, f.box.cz);
366
+ const world = cityWrap.localToWorld(local.clone());
367
+ const top = world.clone(); top.y += 50;
368
+ const geo = new THREE.BufferGeometry().setFromPoints([top, world]);
369
+ const mat = new THREE.LineBasicMaterial({ color: kind === 'write' ? 0xff4d8d : 0x49e8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
370
+ const line = new THREE.Line(geo, mat);
371
+ S.scene.add(line);
372
+ beams.push({ obj: line, born: elapsed, life: 1.4 });
373
+ fileActivity.push({ id: f.id, born: elapsed, life: 2.2 });
374
+ }
375
+
376
+ // ── mundo psicodélico ───────────────────────────────────────────────────────
377
+ function buildScene(canvas, W, H) {
378
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
379
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
380
+ renderer.setSize(W, H, false);
381
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
382
+ const scene = new THREE.Scene();
383
+ const camera = new THREE.PerspectiveCamera(60, W / H, 0.1, 2000);
384
+ camera.position.set(0, 40, 150);
385
+ const controls = new OrbitControls(camera, canvas);
386
+ controls.enableDamping = true; controls.dampingFactor = 0.06; controls.autoRotate = false;
387
+ controls.enablePan = false; controls.minDistance = 60; controls.maxDistance = 420;
388
+
389
+ const N = 14000;
390
+ const pos = new Float32Array(N * 3), rad = new Float32Array(N);
391
+ let s = 987654321; const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
392
+ for (let i = 0; i < N; i++) {
393
+ const arm = Math.floor(rnd() * 4);
394
+ const r = Math.pow(rnd(), 0.6) * 120 + 6;
395
+ const a = arm * (Math.PI / 2) + r * 0.03 + (rnd() - 0.5) * 0.5;
396
+ const y = (rnd() - 0.5) * (14 - r * 0.06);
397
+ pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = y; pos[i * 3 + 2] = Math.sin(a) * r; rad[i] = r;
398
+ }
399
+ const geo = new THREE.BufferGeometry();
400
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
401
+ geo.setAttribute('aRad', new THREE.BufferAttribute(rad, 1));
402
+ const mat = new THREE.ShaderMaterial({
403
+ uniforms: { uTime: { value: 0 }, uBass: { value: 0 }, uMid: { value: 0 } },
404
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
405
+ vertexShader: `uniform float uTime, uBass, uMid; attribute float aRad; varying vec3 vC;
406
+ vec3 hsv(float h){ vec3 k=vec3(1.,2./3.,1./3.); vec3 p=abs(fract(h+k)*6.-3.); return clamp(p-1.,0.,1.);}
407
+ float hash(vec3 p){ p=fract(p*0.3183099+0.1); p*=17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); }
408
+ float vnoise(vec3 x){ vec3 i=floor(x),f=fract(x); f=f*f*(3.0-2.0*f);
409
+ return mix(mix(mix(hash(i+vec3(0,0,0)),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y),
410
+ mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z); }
411
+ float fbm(vec3 p){ float a=0.5,s=0.0; for(int i=0;i<4;i++){ s+=a*vnoise(p); p*=2.02; a*=0.5;} return s; }
412
+ void main(){
413
+ vec3 pos=position;
414
+ float n=fbm(pos*0.03 + vec3(0.0,uTime*0.18,0.0));
415
+ pos += normalize(pos+0.001) * (n-0.5) * (5.0 + uMid*4.0 + uBass*7.0);
416
+ float sw = uTime*0.14 + aRad*0.015 + uBass*0.18;
417
+ float cs=cos(sw), sn=sin(sw);
418
+ pos.xz = mat2(cs,-sn,sn,cs) * pos.xz;
419
+ pos *= (1.0 + uBass*0.05);
420
+ float h=fract(aRad*0.004+uTime*0.03+n*0.12); vC=hsv(h)*(1.3-aRad*0.004)*(0.95+uMid*0.18+uBass*0.12);
421
+ vec4 mv=modelViewMatrix*vec4(pos,1.0); gl_PointSize=(2.2+60.0/-mv.z)*(1.0+uBass*0.18); gl_Position=projectionMatrix*mv; }`,
422
+ fragmentShader: `varying vec3 vC; void main(){ vec2 d=gl_PointCoord-0.5; float m=smoothstep(0.5,0.0,length(d)); gl_FragColor=vec4(vC*2.0,m); }`,
423
+ });
424
+ const galaxy = new THREE.Points(geo, mat); scene.add(galaxy);
425
+ const core = new THREE.LineSegments(new THREE.WireframeGeometry(new THREE.IcosahedronGeometry(18, 1)),
426
+ new THREE.LineBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }));
427
+ scene.add(core);
428
+ const rings = [];
429
+ for (let i = 0; i < 24; i++) {
430
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(22, 0.5, 8, 80),
431
+ new THREE.MeshBasicMaterial({ color: 0x7c5cff, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false }));
432
+ ring.rotation.x = Math.PI / 2; ring.position.y = -70 + i * 6; ring.userData.i = i;
433
+ scene.add(ring); rings.push(ring);
434
+ }
435
+ nodesGroup = new THREE.Group(); scene.add(nodesGroup);
436
+ starGroup = new THREE.Group(); scene.add(starGroup);
437
+ const composer = new EffectComposer(renderer);
438
+ composer.setSize(W, H); composer.addPass(new RenderPass(scene, camera));
439
+ const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), 1.1, 0.5, 0.2);
440
+ composer.addPass(bloom); composer.addPass(new OutputPass());
441
+ return { renderer, scene, camera, controls, composer, bloom, mat, galaxy, core, rings };
442
+ }
443
+
444
+ export async function openMind() {
445
+ if (open) return;
446
+ open = true;
447
+ if (built) { overlay.style.display = ''; try { widget?.play(); } catch {} startLoop(); return; }
448
+
449
+ overlay = document.createElement('div');
450
+ overlay.id = 'mind-overlay';
451
+ overlay.innerHTML =
452
+ '<canvas id="mind-canvas"></canvas>' +
453
+ '<div id="mind-title">MENTE DE ELFFUSS</div>' +
454
+ '<div id="mind-legend"></div>' +
455
+ '<button id="mind-playstop" title="pausar/reanudar el cerebro"></button>' +
456
+ '<button id="mind-config" title="reprogramar el cerebro">⚙ cerebro</button>' +
457
+ '<button id="mind-history" title="ver todo lo que ha llegado">≡ historial</button>' +
458
+ '<button id="mind-close" title="salir">✕ salir</button>' +
459
+ '<div id="mind-anchors"></div><div id="mind-panel"></div><div id="mind-cfg"></div>' +
460
+ '<div id="mind-log"><div class="ml-head">Historial completo<button id="ml-x">✕</button></div><div id="mind-log-body"></div></div>';
461
+ document.body.appendChild(overlay);
462
+ wireConfig(overlay); wireHistory(overlay); wirePlayStop(overlay);
463
+ mountMusic(overlay);
464
+ anchors.length = 0; thoughtNodes = []; stars = []; lineBuf.clear();
465
+ rebuildWorld();
466
+
467
+ const canvas = overlay.querySelector('#mind-canvas');
468
+ S = buildScene(canvas, window.innerWidth, window.innerHeight);
469
+ buildBackgroundCity(S.scene);
470
+ await loadExistingThoughts();
471
+ await loadPersistedLog(); // el historial de «≡ historial» sobrevive a recargas
472
+
473
+ const ray = new THREE.Raycaster(), ptr = new THREE.Vector2();
474
+ let downXY = null;
475
+ canvas.addEventListener('pointerdown', e => { downXY = [e.clientX, e.clientY]; });
476
+ canvas.addEventListener('pointerup', e => {
477
+ if (!downXY) return; const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null;
478
+ if (moved > 5 || !thoughtNodes.length) return;
479
+ ptr.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
480
+ ray.setFromCamera(ptr, S.camera);
481
+ const hit = ray.intersectObjects(thoughtNodes)[0];
482
+ if (hit) selectThoughtNode(hit.object);
483
+ });
484
+
485
+ const onResize = () => {
486
+ const w = window.innerWidth, h = window.innerHeight;
487
+ S.renderer.setSize(w, h, false); S.composer.setSize(w, h); S.bloom.resolution.set(w, h);
488
+ S.camera.aspect = w / h; S.camera.updateProjectionMatrix();
489
+ };
490
+ window.addEventListener('resize', onResize); S.onResize = onResize;
491
+ overlay.querySelector('#mind-close').addEventListener('click', closeMind);
492
+ S.onKey = e => { if (e.key === 'Escape') closeMind(); };
493
+ document.addEventListener('keydown', S.onKey);
494
+ built = true;
495
+ startLoop();
496
+ }
497
+
498
+ // Etiquetas de propuestas: se proyectan del mundo 3D a la pantalla, así que
499
+ // con muchas acumuladas acaban solapándose ilegibles según el ángulo de
500
+ // cámara. Aquí: cap a las más cercanas y reparto anti-solape (empuja hacia
501
+ // abajo la que choca con otra ya colocada), para que se apilen separadas.
502
+ const MAX_LABELS = 14, LABEL_W = 90, LABEL_H = 20;
503
+ function projectAnchors() {
504
+ const w = window.innerWidth, h = window.innerHeight, v = new THREE.Vector3();
505
+ const projected = [];
506
+ for (const a of anchors) {
507
+ v.copy(a.pos).project(S.camera);
508
+ if (v.z > 1) { a.el.style.display = 'none'; continue; } // detrás de la cámara
509
+ projected.push({ a, x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, depth: v.z });
510
+ }
511
+ projected.sort((p1, p2) => p1.depth - p2.depth); // las más cercanas a la cámara, prioridad
512
+ projected.forEach((p, i) => { p.a.el.style.display = i < MAX_LABELS ? '' : 'none'; });
513
+ const visible = projected.slice(0, MAX_LABELS).sort((p1, p2) => p1.x - p2.x || p1.y - p2.y);
514
+ const placed = [];
515
+ for (const p of visible) {
516
+ let y = p.y;
517
+ for (const q of placed) if (Math.abs(q.x - p.x) < LABEL_W && Math.abs(q.y - y) < LABEL_H) y = q.y + LABEL_H;
518
+ placed.push({ x: p.x, y });
519
+ p.a.el.style.left = p.x + 'px'; p.a.el.style.top = y + 'px';
520
+ p.a.el.style.transform = 'translate(-50%,-50%)';
521
+ }
522
+ }
523
+ const wob = (t, seed, freq = 0.1) => Math.sin(t * freq + seed) * 0.6 + Math.sin(t * freq * 0.43 + seed * 2.1) * 0.3 + Math.sin(t * freq * 0.19 + seed * 4.7) * 0.1;
524
+
525
+ let elapsed = 0, lastMs = 0, smBass = 0, smMid = 0;
526
+ function startLoop() {
527
+ if (raf) return;
528
+ const loop = (ms) => {
529
+ raf = requestAnimationFrame(loop);
530
+ const dt = lastMs ? (ms - lastMs) / 1000 : 0;
531
+ elapsed += dt; lastMs = ms;
532
+ const t = elapsed;
533
+ const beat = 60 / 138;
534
+ const bp = (t % beat) / beat;
535
+ const bassRaw = Math.pow(1 - bp, 3);
536
+ const midRaw = 0.5 + 0.5 * Math.sin(t * 6.0);
537
+ smBass += (bassRaw - smBass) * 0.05;
538
+ smMid += (midRaw - smMid) * 0.06;
539
+ S.mat.uniforms.uTime.value = t;
540
+ S.mat.uniforms.uBass.value = smBass;
541
+ S.mat.uniforms.uMid.value = smMid;
542
+ S.galaxy.rotation.y = t * 0.05;
543
+ S.galaxy.rotation.x = wob(t, 1.7, 0.05) * 0.15;
544
+ S.galaxy.rotation.z = wob(t, 5.2, 0.04) * 0.1;
545
+ S.galaxy.scale.setScalar(1 + smBass * 0.02);
546
+ const pulse = 1 + smBass * 0.05 + Math.sin(t * 0.9) * 0.03;
547
+ S.core.scale.setScalar(pulse); S.core.rotation.y = t * 0.16; S.core.rotation.x = t * 0.09;
548
+ S.core.position.x = wob(t, 3.1, 0.11) * 6; S.core.position.z = wob(t, 9.4, 0.09) * 6;
549
+ S.core.material.color.setHSL((t * 0.04) % 1, 0.9, 0.6);
550
+ for (const r of S.rings) {
551
+ r.position.y += 0.14 + smBass * 0.12;
552
+ if (r.position.y > 74) r.position.y -= 144;
553
+ r.position.x = wob(t, r.userData.i * 1.3 + 1, 0.08) * 3;
554
+ r.scale.setScalar(1 + smBass * 0.12 + Math.sin(t * 0.8 + r.userData.i) * 0.04);
555
+ r.material.opacity = 0.2 + smBass * 0.16;
556
+ r.material.color.setHSL((t * 0.05 + r.userData.i * 0.03) % 1, 0.85, 0.6);
557
+ }
558
+ for (let i = 0; i < thoughtNodes.length; i++) { const n = thoughtNodes[i]; n.rotation.y = t + i; n.scale.setScalar(1 + Math.sin(t * 2 + i) * 0.14); }
559
+ // estrellas: nacen (fade-in), viven, se apagan solas y se reciclan
560
+ for (let i = stars.length - 1; i >= 0; i--) {
561
+ const st = stars[i], age = t - st.born;
562
+ if (age > st.life) { starGroup.remove(st.obj); st.obj.material.map.dispose(); st.obj.material.dispose(); stars.splice(i, 1); continue; }
563
+ const fadeIn = Math.min(1, age / 0.5), fadeOut = Math.min(1, (st.life - age) / (st.life * 0.35));
564
+ st.obj.material.opacity = Math.min(fadeIn, fadeOut);
565
+ st.obj.position.y += st.up * dt;
566
+ st.obj.position.x += Math.sin(t * 0.6 + st.seed) * 0.3 * dt;
567
+ }
568
+ // haces sobre la ciudad + celdas de fichero resaltadas por actividad
569
+ for (let i = beams.length - 1; i >= 0; i--) {
570
+ const b = beams[i], age = t - b.born;
571
+ if (age > b.life) { S.scene.remove(b.obj); b.obj.geometry.dispose(); b.obj.material.dispose(); beams.splice(i, 1); continue; }
572
+ b.obj.material.opacity = 0.9 * Math.max(0, 1 - age / b.life);
573
+ }
574
+ if (cityCity) for (let i = fileActivity.length - 1; i >= 0; i--) {
575
+ const a = fileActivity[i], age = t - a.born;
576
+ if (age > a.life) { cityCity.paintFile(a.id, 0); fileActivity.splice(i, 1); continue; }
577
+ cityCity.paintFile(a.id, Math.max(0, 1 - age / a.life));
578
+ }
579
+ if (flyTo) {
580
+ const k = Math.min(1, (t - flyTo.t0) / flyTo.dur), e = easeInOut(k);
581
+ S.camera.position.lerpVectors(flyTo.fromPos, flyTo.toPos, e);
582
+ S.controls.target.lerpVectors(flyTo.fromTarget, flyTo.toTarget, e);
583
+ if (k >= 1) flyTo = null;
584
+ }
585
+ S.controls.update();
586
+ projectAnchors();
587
+ S.composer.render();
588
+ };
589
+ loop(performance.now());
590
+ }
591
+
592
+ export function closeMind() {
593
+ open = false;
594
+ if (raf) { cancelAnimationFrame(raf); raf = null; }
595
+ lastMs = 0;
596
+ try { widget?.pause(); } catch {}
597
+ if (overlay) overlay.style.display = 'none';
598
+ }
599
+
600
+ // para tests/depuración
601
+ export function _debug() {
602
+ return {
603
+ starCount: stars.length, hasCity: !!cityCity, profileCount: ceo.getProfiles().length,
604
+ beamCount: beams.length, fileActivityCount: fileActivity.length,
605
+ cameraPos: S ? [S.camera.position.x, S.camera.position.y, S.camera.position.z] : null,
606
+ flying: !!flyTo,
607
+ };
608
+ }
609
+ export function _debugFocusOn(id) { const p = anchorMap.get(id); if (p) focusOn(p, 46); }
js/model-cache.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Arranque de la caché de modelos: (1) pide almacenamiento PERSISTENTE para que
2
+ // el navegador no desaloje los pesos cacheados, y (2) registra el service worker
3
+ // (sw.js) y espera a que CONTROLE la página antes de descargar nada — así hasta
4
+ // la primera descarga queda cacheada. Idempotente y a prueba de fallos.
5
+ let started = null;
6
+
7
+ export function ensureModelCache() {
8
+ if (started) return started;
9
+ started = (async () => {
10
+ try {
11
+ if (navigator.storage && navigator.storage.persist) {
12
+ const persisted = navigator.storage.persisted ? await navigator.storage.persisted() : false;
13
+ const ok = persisted || await navigator.storage.persist();
14
+ console.log('[elffuss] almacenamiento persistente:', ok);
15
+ }
16
+ } catch { /* no soportado */ }
17
+
18
+ if (!('serviceWorker' in navigator)) return false;
19
+ try {
20
+ await navigator.serviceWorker.register('sw.js');
21
+ await navigator.serviceWorker.ready;
22
+ if (!navigator.serviceWorker.controller) {
23
+ await new Promise(res => {
24
+ const t = setTimeout(res, 1500);
25
+ navigator.serviceWorker.addEventListener('controllerchange', () => { clearTimeout(t); res(); }, { once: true });
26
+ });
27
+ }
28
+ console.log('[elffuss] caché de modelos activa (service worker)');
29
+ return true;
30
+ } catch (e) {
31
+ console.warn('[elffuss] service worker no disponible:', e.message);
32
+ return false;
33
+ }
34
+ })();
35
+ return started;
36
+ }
37
+
38
+ // Espacio ocupado por la caché (para mostrarlo en Ajustes).
39
+ export async function cacheEstimate() {
40
+ try {
41
+ const e = await navigator.storage.estimate();
42
+ return { usage: e.usage || 0, quota: e.quota || 0, persisted: navigator.storage.persisted ? await navigator.storage.persisted() : false };
43
+ } catch { return { usage: 0, quota: 0, persisted: false }; }
44
+ }
45
+
46
+ // Vaciar la caché de modelos (botón en Ajustes).
47
+ export async function clearModelCache() {
48
+ if (navigator.serviceWorker && navigator.serviceWorker.controller)
49
+ navigator.serviceWorker.controller.postMessage('clear-models');
50
+ try { await caches.delete('elffuss-models-v1'); } catch { /* */ }
51
+ }
js/model-config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Qué modelo carga el proveedor ONNX (transformers.js).
2
+ //
3
+ // Hoy: LFM2.5-1.2B-Instruct de Liquid (ago 850 MB q4, agentic-first) — ganador
4
+ // del análisis empírico de coordinacion/CANDIDATOS-MODELO.md. Requiere tf.js v4.
5
+ //
6
+ // Mañana («modelo propio»): exporta tu fine-tune a ONNX con optimum,
7
+ // sube las carpetas a web/models/<id>/ y pon selfHosted: true.
8
+ // Ver README § «Modelo propio».
9
+ export const MODEL = {
10
+ label: 'Elffuss LM (healed · LFM2.5-1.2B)',
11
+ id: 'KikoCis/Elffuss-LM-1.2B-ONNX', // nuestro heal agéntico de LFM2.5 (tool-calls + apps)
12
+ dtype: 'q4', // ¡NO q4f16! genera basura vía WebGPU (verificado en la bitácora)
13
+ selfHosted: false,
14
+ basePath: '/models/', // solo se usa con selfHosted: true
15
+ };
16
+ // NO subir a Qwen2.5-1.5B: su q4 (1.8 GB) revienta onnxruntime-web al crear la
17
+ // sesión (OOM del heap wasm de 4 GB, throw numérico ~3.3e9). Límite práctico
18
+ // medido: ≤~1 GB en disco → ver coordinacion/ERRORES.md E-005 y NECESIDADES N-002.
19
+
20
+ // Los modelos EXTERNOS (OpenAI, Anthropic, Ollama local y el servidor Ornith)
21
+ // son configuración avanzada opt-in → js/settings.js + js/providers/api.js.
22
+ // Ornith DEGENERA con temperatura baja (temp 1.0 / top_p 0.95 obligatorios) y
23
+ // en la CPU del servidor razona lento → thinking desactivado por defecto.
js/providers/api.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Proveedor genérico para APIs externas (configuración avanzada):
2
+ // - kind 'openai' → /chat/completions (OpenAI, Ollama, llama-server…)
3
+ // - kind 'anthropic' → /v1/messages (Claude)
4
+ // Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
5
+ // no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
6
+ import { packHistory } from '../context.js';
7
+
8
+ let cfg = null;
9
+ export let name = 'API';
10
+
11
+ export function configure(c) { cfg = c; name = c.label; }
12
+
13
+ export async function load() {
14
+ if (!cfg) throw new Error('proveedor sin configurar');
15
+ if (cfg.kind !== 'anthropic' && !cfg.apiKey && !cfg.baseURL.includes('localhost') && cfg.baseURL !== '/v1')
16
+ throw new Error('falta la clave de API (config avanzada)');
17
+ }
18
+
19
+ export async function chat(history, system, onToken = () => {}) {
20
+ return cfg.kind === 'anthropic'
21
+ ? anthropicChat(history, system, onToken)
22
+ : openaiChat(history, system, onToken);
23
+ }
24
+
25
+ // ---- OpenAI-compatible ----
26
+ async function openaiChat(history, system, onToken) {
27
+ const headers = { 'Content-Type': 'application/json' };
28
+ if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
29
+ const body = {
30
+ model: cfg.model,
31
+ messages: [{ role: 'system', content: system }, ...packHistory(history, 3000)],
32
+ stream: true,
33
+ max_tokens: cfg.maxTokens || 1024,
34
+ };
35
+ if (cfg.temperature != null) body.temperature = cfg.temperature;
36
+ if (cfg.top_p != null) body.top_p = cfg.top_p;
37
+ if (cfg.thinking != null) body.chat_template_kwargs = { enable_thinking: cfg.thinking };
38
+
39
+ const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/chat/completions', {
40
+ method: 'POST', headers, body: JSON.stringify(body),
41
+ });
42
+ if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
43
+
44
+ let out = '';
45
+ await readSSE(res.body, payload => {
46
+ if (payload === '[DONE]') return;
47
+ const d = JSON.parse(payload).choices?.[0]?.delta || {};
48
+ if (d.reasoning_content) onToken(d.reasoning_content);
49
+ if (d.content) { out += d.content; onToken(d.content); }
50
+ });
51
+ return out.trim();
52
+ }
53
+
54
+ // ---- Anthropic Messages ----
55
+ async function anthropicChat(history, system, onToken) {
56
+ const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ 'x-api-key': cfg.apiKey,
61
+ 'anthropic-version': '2023-06-01',
62
+ 'anthropic-dangerous-direct-browser-access': 'true',
63
+ },
64
+ body: JSON.stringify({
65
+ model: cfg.model,
66
+ max_tokens: cfg.maxTokens || 1024,
67
+ system,
68
+ stream: true,
69
+ messages: forAnthropic(packHistory(history, 3000)),
70
+ }),
71
+ });
72
+ if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
73
+
74
+ let out = '';
75
+ await readSSE(res.body, payload => {
76
+ const evt = JSON.parse(payload);
77
+ if (evt.type === 'content_block_delta' && evt.delta?.text) {
78
+ out += evt.delta.text; onToken(evt.delta.text);
79
+ }
80
+ });
81
+ return out.trim();
82
+ }
83
+
84
+ // Anthropic exige roles alternos empezando por user; fusiona consecutivos.
85
+ function forAnthropic(msgs) {
86
+ const merged = [];
87
+ for (const m of msgs) {
88
+ const role = m.role === 'assistant' ? 'assistant' : 'user';
89
+ const last = merged[merged.length - 1];
90
+ if (last && last.role === role) last.content += '\n' + m.content;
91
+ else merged.push({ role, content: m.content });
92
+ }
93
+ if (merged[0]?.role === 'assistant') merged.unshift({ role: 'user', content: '(continúa)' });
94
+ return merged;
95
+ }
96
+
97
+ // Lector SSE común: invoca fn con el texto tras cada 'data:'.
98
+ async function readSSE(stream, fn) {
99
+ const reader = stream.getReader();
100
+ const dec = new TextDecoder();
101
+ let buf = '';
102
+ for (;;) {
103
+ const { done, value } = await reader.read();
104
+ if (done) break;
105
+ buf += dec.decode(value, { stream: true });
106
+ let nl;
107
+ while ((nl = buf.indexOf('\n')) >= 0) {
108
+ const line = buf.slice(0, nl).trim();
109
+ buf = buf.slice(nl + 1);
110
+ if (!line.startsWith('data:')) continue;
111
+ const payload = line.slice(5).trim();
112
+ if (!payload) continue;
113
+ try { fn(payload); } catch { /* chunk parcial */ }
114
+ }
115
+ }
116
+ }
js/providers/litert.js ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Gemma-4 vía LiteRT-LM de Google (early preview, solo WebGPU).
2
+ // Patrón copiado de la demo verificada en agentic-install
3
+ // (lab/bitacora/posts/08-jspace-live.html).
4
+ // DECISIÓN 2026-07-14: cerebro = Gemma BASE (builds oficiales litert-community,
5
+ // formato artisan). NO fine-tune propio: `@litert-lm/core` exige empaquetado
6
+ // artisan y nuestras conversiones no lo producen (E-010). La agéntica va por el
7
+ // system prompt (agent.js), no por pesos.
8
+ export let name = 'Gemma · LiteRT-LM';
9
+
10
+ // Builds .litertlm elegibles. Los «-web» OFICIALES de Google (litert-community)
11
+ // están exportados en formato artisan → SÍ cargan en el navegador (son los que
12
+ // usaba la demo original). El healed de Elffuss es prefill_decode → hoy no carga
13
+ // (E-010), por eso está gateado en el selector.
14
+ export const MODELS = {
15
+ 'gemma-e2b': { url: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it-web.litertlm', label: 'Gemma-4 E2B', tag: '~2 GB · ligero' },
16
+ 'gemma-e4b': { url: 'https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.litertlm', label: 'Gemma-4 E4B', tag: '~4 GB · el mejor' },
17
+ 'elffuss-e4b': { url: 'https://huggingface.co/KikoCis/Elffuss-Gemma4-E4B-litert/resolve/main/model.litertlm', label: 'Elffuss E4B (healed)', tag: 'modelo propio' },
18
+ };
19
+
20
+ let MODEL_URL = MODELS['gemma-e2b'].url;
21
+ let curLabel = MODELS['gemma-e2b'].label;
22
+ export function configure(key) {
23
+ const m = MODELS[key] || MODELS['gemma-e2b'];
24
+ MODEL_URL = m.url; curLabel = m.label; name = 'Gemma · LiteRT-LM (' + m.label + ')';
25
+ }
26
+
27
+ let engine = null, conversation = null, sentCount = 0, sys = '';
28
+
29
+ // Contexto: probamos de mayor a menor hasta el máximo que acepten el bundle y
30
+ // la memoria GPU — así el contexto queda al tope permitido de serie.
31
+ const CTX_LADDER = [32768, 16384, 8192, 4096];
32
+ export let ctxTokens = 4096; // efectivo tras load() (la UI puede leerlo)
33
+
34
+ export async function load(onProgress = () => {}) {
35
+ if (!navigator.gpu) throw new Error('LiteRT-LM necesita WebGPU (Chrome/Edge modernos)');
36
+ // navigator.gpu puede existir como API sin adaptador real (algunos Linux/
37
+ // drivers, entornos sandboxed…) — comprobarlo YA evita bajar 2-4 GB para
38
+ // descubrir el fallo solo al crear el motor, al final de todo.
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
46
+ // que el service worker no intercepta → antes se re-descargaba SIEMPRE. Bajándolo
47
+ // aquí queda cacheado de verdad y damos progreso real en MB.
48
+ const model = await cachedModelBlob(MODEL_URL, onProgress);
49
+ onProgress('Preparando el modelo IA en la GPU…');
50
+ let lastErr = null;
51
+ for (const n of CTX_LADDER) {
52
+ try {
53
+ engine = await litertlm.Engine.create({ model, mainExecutorSettings: { maxNumTokens: n } });
54
+ ctxTokens = n;
55
+ lastErr = null;
56
+ break;
57
+ } catch (e) {
58
+ lastErr = e;
59
+ // Errores de formato/carga no dependen del contexto: no insistir con la escalera.
60
+ if (/not supported|tokenizer|format/i.test(String(e?.message))) throw e;
61
+ onProgress(`Contexto ${n} no cabe, probando ${n / 2}…`);
62
+ }
63
+ }
64
+ if (lastErr) throw lastErr;
65
+ }
66
+
67
+ const MODEL_CACHE = 'elffuss-models-v1';
68
+ // Devuelve el .litertlm como Blob desde Cache Storage; si no está, lo descarga
69
+ // con progreso real y lo cachea (persistente). Ante cualquier fallo, devuelve la
70
+ // URL para que LiteRT lo baje por su cuenta (nunca bloquea la carga del modelo).
71
+ export async function cachedModelBlob(url, onProgress = () => {}) {
72
+ if (!self.caches) return url;
73
+ try {
74
+ const cache = await caches.open(MODEL_CACHE);
75
+ const hit = await cache.match(url);
76
+ if (hit) { onProgress('Cargando el modelo IA desde caché (sin descargar)…'); return await hit.blob(); }
77
+ const net = await fetch(url);
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);
95
+ const secs = (performance.now() - t0) / 1000;
96
+ const spd = secs > 0 ? (loaded / 1048576 / secs).toFixed(1) : '0';
97
+ return total
98
+ ? `Descargando el modelo IA… ${mb(loaded)}/${mb(total)} MB (${spd} MB/s) · se cachea para la próxima vez`
99
+ : `Descargando el modelo IA… ${mb(loaded)} MB (${spd} MB/s)`;
100
+ }
101
+
102
+ // Liberar el modelo (vigilante de RAM).
103
+ export async function unload() {
104
+ try { engine?.close?.(); } catch { /* mejor esfuerzo */ }
105
+ engine = null; conversation = null; sentCount = 0;
106
+ }
107
+
108
+ export async function chat(history, system, onToken = () => {}) {
109
+ if (!engine) throw new Error('Modelo no cargado');
110
+ // Comparar solo la parte estática del prompt: el CONTEXTO AHORA va al final
111
+ // y cambia cada turno — recrear la conversación tiraría el KV-cache.
112
+ const sysKey = system.slice(0, 200);
113
+ if (!conversation || sysKey !== sys) {
114
+ sys = sysKey;
115
+ conversation = await engine.createConversation({
116
+ preface: { messages: [{ role: 'system', content: system }] },
117
+ // Exprimir el navegador: no persistir los tokens de canal (tool-call/thinking)
118
+ // del modelo en el KV-cache → libera KV → más contexto útil. Y prefill del
119
+ // system prompt al crear la conversación → primera respuesta más rápida.
120
+ filterChannelContentFromKvCache: true,
121
+ prefillPrefaceOnInit: true,
122
+ });
123
+ sentCount = 0;
124
+ }
125
+ // La conversación LiteRT mantiene su propio KV-cache: enviamos solo lo nuevo.
126
+ const fresh = history.slice(sentCount).filter(m => m.role === 'user');
127
+ sentCount = history.length;
128
+ const text = fresh.map(m => m.content).join('\n') || history.at(-1).content;
129
+
130
+ let out = '';
131
+ for await (const chunk of conversation.sendMessageStreaming(text)) {
132
+ for (const item of (chunk.content || []))
133
+ if (item.type === 'text') { out += item.text; onToken(item.text); }
134
+ }
135
+ return out.trim();
136
+ }
js/providers/onnx.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Modelo vía ONNX Runtime Web (transformers.js, WebGPU con fallback a wasm).
2
+ // Patrón copiado de la demo verificada en agentic-install
3
+ // (lab/bitacora/posts/08-jspace-live.html): dtype 'q4' obligatorio — q4f16
4
+ // genera basura vía WebGPU incluso con shader-f16.
5
+ import { MODEL } from '../model-config.js';
6
+ import { packHistory } from '../context.js';
7
+
8
+ export const name = MODEL.label;
9
+ let generator = null, TextStreamer = null;
10
+
11
+ export async function load(onProgress = () => {}) {
12
+ if (generator) return; // un solo modelo: nunca recargar/duplicar la sesión
13
+ const tf = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4');
14
+ TextStreamer = tf.TextStreamer;
15
+ if (MODEL.selfHosted) {
16
+ tf.env.allowRemoteModels = false;
17
+ tf.env.localModelPath = MODEL.basePath;
18
+ }
19
+ // navigator.gpu puede EXISTIR como API sin que haya un adaptador real
20
+ // (ciertos Linux/drivers, entornos sandboxed, navegadores headless…) — usar
21
+ // solo la presencia del objeto como señal hace que pipeline() intente
22
+ // WebGPU, falle con "Failed to get GPU adapter" DESPUÉS de descargar el
23
+ // modelo entero, y en preloadModel() eso encadena a probar Gemma (varios
24
+ // GB) para nada. Comprobar el adaptador de verdad antes de elegir.
25
+ let device = 'wasm';
26
+ if (navigator.gpu) {
27
+ try { device = (await navigator.gpu.requestAdapter()) ? 'webgpu' : 'wasm'; }
28
+ catch { device = 'wasm'; }
29
+ }
30
+ generator = await tf.pipeline('text-generation', MODEL.id, {
31
+ device,
32
+ dtype: MODEL.dtype,
33
+ progress_callback: onProgress,
34
+ });
35
+ }
36
+
37
+ // Liberar el modelo (vigilante de RAM): suelta los buffers wasm/WebGPU.
38
+ export async function unload() {
39
+ try { await generator?.dispose?.(); } catch { /* mejor esfuerzo */ }
40
+ generator = null;
41
+ }
42
+
43
+ export async function chat(history, system, onToken = () => {}) {
44
+ if (!generator) throw new Error('Modelo no cargado');
45
+ // ACE-lite: eviction por relevancia. Presupuesto amplio (LFM2.5 aguanta
46
+ // contexto largo); el tope POR MENSAJE (context.js) evita que un README
47
+ // gigante dispare «Too many tokens requested».
48
+ const messages = [{ role: 'system', content: system }, ...packHistory(history, 5000)];
49
+ const streamer = new TextStreamer(generator.tokenizer, {
50
+ skip_prompt: true,
51
+ skip_special_tokens: true,
52
+ callback_function: onToken,
53
+ });
54
+ const out = await generator(messages, {
55
+ max_new_tokens: 1024,
56
+ do_sample: false, // determinista: los tool calls JSON lo agradecen
57
+ repetition_penalty: 1.1,
58
+ return_full_text: false,
59
+ streamer,
60
+ });
61
+ const gen = out[0].generated_text;
62
+ return (typeof gen === 'string' ? gen : gen.at(-1).content).trim();
63
+ }
js/providers/rules.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Modo básico de Elffuss Code: órdenes deterministas sin modelo.
2
+ export const name = 'Básico (sin modelo)';
3
+ export async function load() {}
4
+
5
+ const call = obj => '```tool\n' + JSON.stringify(obj) + '\n```';
6
+
7
+ const HELP = `Estoy en modo básico (sin modelo). Entiendo:
8
+ • «árbol» / «qué hay en el proyecto»
9
+ • «lee <archivo>» · «abre <archivo>»
10
+ • «busca <texto>»
11
+ • «escribe <archivo>: <contenido>»
12
+ Para programar de verdad, carga el modelo local (selector 🧠) o configura uno en ⚙️.`;
13
+
14
+ export async function chat(history, systemPrompt) {
15
+ const last = history[history.length - 1];
16
+ const text = (last?.content || '').trim();
17
+
18
+ // 🎯 Modo Objetivo (goal.js) también funciona en modo básico: un plan fijo
19
+ // de 2 tareas (explorar + escribir), para poder probar/usar el planificador
20
+ // sin depender de un modelo real cargado.
21
+ if (/^ROL: PLANIFICADOR/.test(systemPrompt || '')) {
22
+ return JSON.stringify({
23
+ plan: 'Plan básico (sin modelo) para: ' + text.slice(0, 60),
24
+ tasks: [
25
+ { title: 'Explorar el proyecto', description: 'Mira el árbol del proyecto para entender qué hay.' },
26
+ { title: 'Escribir el resultado', description: 'escribe objetivo.txt: ' + text },
27
+ ],
28
+ });
29
+ }
30
+
31
+ if (text.startsWith('[resultado')) {
32
+ const body = text.slice(text.indexOf('\n') + 1).trim();
33
+ return body.startsWith('ERROR:') ? 'No pude: ' + body.slice(6).trim() : body;
34
+ }
35
+
36
+ const t = text.toLowerCase();
37
+ if (/(árbol|arbol|estructura|qué hay|que hay)/.test(t))
38
+ return call({ tool: 'code.tree', args: {} });
39
+ const mRead = text.match(/(?:lee|abre|muestra|cat)\s+([\w./-]+\.\w+)/i);
40
+ if (mRead) return call({ tool: 'code.read', args: { path: mRead[1] } });
41
+ const mSearch = text.match(/busca(?:r)?\s+(?:d[oó]nde\s+)?(?:se\s+\w+\s+)?["«']?([^"»']{2,60})["»']?$/i);
42
+ if (mSearch) return call({ tool: 'code.search', args: { query: mSearch[1].trim() } });
43
+ const mWrite = text.match(/escribe\s+([\w./-]+\.\w+)\s*[:=]\s*([\s\S]+)/i);
44
+ if (mWrite) return call({ tool: 'code.write', args: { path: mWrite[1], content: mWrite[2] } });
45
+
46
+ if (/^(hola|buenas|hey|hi)\b/.test(t)) return '¡Hola! ' + HELP;
47
+ return HELP;
48
+ }
js/settings.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Configuración avanzada de proveedores externos (opt-in). Elffuss corre en
2
+ // LOCAL por defecto; esto solo se usa si el usuario lo activa a mano.
3
+ // Las claves viven en localStorage de SU navegador y nunca salen de aquí
4
+ // (las llamadas van directas del navegador al proveedor).
5
+ const KEY = 'elffusscode.providers';
6
+
7
+ // Plantillas de proveedores externos. `kind` decide el dialecto de API.
8
+ const DEFAULTS = {
9
+ openai: {
10
+ kind: 'openai', label: 'OpenAI', enabled: false,
11
+ baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini', apiKey: '',
12
+ help: 'Tu clave sk-… Se queda en tu navegador. OpenAI permite llamadas directas (CORS).',
13
+ },
14
+ anthropic: {
15
+ kind: 'anthropic', label: 'Anthropic (Claude)', enabled: false,
16
+ baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-5', apiKey: '',
17
+ help: 'Tu clave sk-ant-… Se envía la cabecera de acceso directo desde navegador.',
18
+ },
19
+ ollama: {
20
+ kind: 'openai', label: 'Ollama (local)', enabled: false,
21
+ baseURL: 'http://localhost:11434/v1', model: 'llama3.2', apiKey: 'ollama',
22
+ help: 'Ollama en tu máquina. Arráncalo con OLLAMA_ORIGINS=* para permitir el navegador.',
23
+ },
24
+ server: {
25
+ kind: 'openai', label: 'Servidor Elffuss (Ornith 9B)', enabled: false,
26
+ baseURL: 'https://elffuss-claw.utopiaia.com/v1', model: 'ornith-9b', apiKey: '',
27
+ temperature: 1.0, top_p: 0.95, thinking: false,
28
+ help: 'El modelo grande en el servidor de UtopiaIA. No es local: los mensajes salen de tu máquina.',
29
+ },
30
+ };
31
+
32
+ function load() {
33
+ let saved = {};
34
+ try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { /* corrupto */ }
35
+ const out = {};
36
+ for (const [id, def] of Object.entries(DEFAULTS)) out[id] = { ...def, ...saved[id] };
37
+ return out;
38
+ }
39
+
40
+ export function configs() { return load(); }
41
+ export function get(id) { return load()[id]; }
42
+
43
+ export function update(id, patch) {
44
+ const all = load();
45
+ all[id] = { ...all[id], ...patch };
46
+ // no persistir los campos de ayuda estáticos
47
+ const slim = {};
48
+ for (const [k, v] of Object.entries(all))
49
+ slim[k] = { enabled: v.enabled, baseURL: v.baseURL, model: v.model, apiKey: v.apiKey };
50
+ localStorage.setItem(KEY, JSON.stringify(slim));
51
+ return all[id];
52
+ }
53
+
54
+ // Externos habilitados → opciones del selector, como ext:<id>.
55
+ export function enabledExternals() {
56
+ return Object.entries(load())
57
+ .filter(([, c]) => c.enabled)
58
+ .map(([id, c]) => ({ id: 'ext:' + id, label: c.label + (id === 'ollama' || c.baseURL.startsWith('http://localhost') ? ' · local' : ' · nube') }));
59
+ }
js/shell.js ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shell del proyecto: un intérprete POSIX-lite que opera SOBRE LOS FICHEROS
2
+ // REALES de la carpeta abierta (el FileSystemDirectoryHandle de code.js). No es
3
+ // una emulación: `ls`, `cat`, `grep`, `mkdir`, `rm`, `echo > fichero`… tocan el
4
+ // disco de verdad y el editor/árbol se refrescan. Es el mismo mundo que ve la
5
+ // elfa, así que también lo exponemos como herramienta `terminal.run`.
6
+ //
7
+ // Runtimes reales (node/npm/python): SOLO con un Bridge local conectado (ver
8
+ // bridge.js) se ejecutan de VERDAD en tu máquina — ver `capabilities()`.
9
+ import * as code from './tools/code.js';
10
+ import * as bridgeClient from './bridge.js';
11
+
12
+ let cwd = []; // directorio actual, segmentos relativos a la raíz
13
+ const history = []; // historial de comandos (para ↑/↓ y `history`)
14
+ let onFsChange = () => {}; // el IDE refresca árbol/pestañas tras mutaciones
15
+ let onOpenFile = () => {}; // `open <fichero>` lo abre en Monaco
16
+
17
+ export function setHooks({ fsChange, openFile } = {}) {
18
+ if (fsChange) onFsChange = fsChange;
19
+ if (openFile) onOpenFile = openFile;
20
+ }
21
+ export function reset() { cwd = []; }
22
+ export function cwdString() { return '/' + cwd.join('/'); }
23
+ export function getHistory() { return history.slice(); }
24
+
25
+ // ¿Podemos lanzar runtimes reales? Solo por Bridge local conectado de verdad
26
+ // (WebContainers requiere licencia comercial de StackBlitz en producción —
27
+ // descartado por ahora, ver bridge.js).
28
+ export function capabilities() {
29
+ const bridge = bridgeClient.isConnected();
30
+ return { crossOriginIsolated: !!self.crossOriginIsolated, webcontainers: false, bridge, realRuntime: bridge };
31
+ }
32
+
33
+ // ---- resolución de rutas ----
34
+ // Combina un argumento con el cwd y normaliza . y .. → segmentos desde la raíz.
35
+ function resolve(arg = '') {
36
+ const base = arg.startsWith('/') ? [] : [...cwd];
37
+ for (const seg of String(arg).split('/')) {
38
+ if (!seg || seg === '.') continue;
39
+ if (seg === '..') base.pop();
40
+ else base.push(seg);
41
+ }
42
+ return base;
43
+ }
44
+ const root = () => { const h = code.handle(); if (!h) throw new Error('no hay proyecto abierto'); return h; };
45
+
46
+ async function dirHandle(segs, { create = false } = {}) {
47
+ let dir = root();
48
+ for (const s of segs) dir = await dir.getDirectoryHandle(s, { create });
49
+ return dir;
50
+ }
51
+ async function parentAndName(segs) {
52
+ const s = [...segs]; const name = s.pop();
53
+ if (name == null) throw new Error('ruta vacía');
54
+ return { parent: await dirHandle(s), name };
55
+ }
56
+ async function fileHandle(segs, opts) {
57
+ const { parent, name } = await parentAndName(segs);
58
+ return parent.getFileHandle(name, opts);
59
+ }
60
+ async function readText(segs) { return (await (await fileHandle(segs)).getFile()).text(); }
61
+ async function writeText(segs, text) {
62
+ const fh = await fileHandle(segs, { create: true });
63
+ const w = await fh.createWritable(); await w.write(text); await w.close();
64
+ code.invalidateFileList?.(); onFsChange();
65
+ }
66
+ // ¿ruta es directorio? (para `ls fichero`, `grep dir`, `cd`)
67
+ async function kindOf(segs) {
68
+ if (!segs.length) return 'directory';
69
+ const { parent, name } = await parentAndName(segs);
70
+ try { await parent.getDirectoryHandle(name); return 'directory'; } catch {}
71
+ try { await parent.getFileHandle(name); return 'file'; } catch {}
72
+ return null;
73
+ }
74
+
75
+ const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
76
+ const CYAN = s => `\x1b[36m${s}\x1b[0m`, RED = s => `\x1b[31m${s}\x1b[0m`, DIM = s => `\x1b[90m${s}\x1b[0m`, GRN = s => `\x1b[32m${s}\x1b[0m`, YEL = s => `\x1b[33m${s}\x1b[0m`;
77
+ const globToRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
78
+ const human = n => n < 1024 ? n + 'B' : n < 1048576 ? (n / 1024).toFixed(1) + 'K' : (n / 1048576).toFixed(1) + 'M';
79
+
80
+ // ---- comandos: (args[], stdin) → stdout (string). Lanzan Error en fallo. ----
81
+ const CMDS = {
82
+ help() {
83
+ return 'Elffuss shell — opera sobre los ficheros REALES del proyecto:\n' +
84
+ ' ' + GRN('ls') + ' [-la] [ruta] ' + GRN('cd') + ' [ruta] ' + GRN('pwd') + ' ' + GRN('tree') + ' [ruta]\n' +
85
+ ' ' + GRN('cat') + ' <f...> ' + GRN('head') + '/' + GRN('tail') + ' [-n N] ' + GRN('wc') + ' [-l] ' + GRN('grep') + ' [-in] <patrón> [ruta]\n' +
86
+ ' ' + GRN('find') + ' [ruta] [-name g] ' + GRN('echo') + ' <txt> [> f] ' + GRN('mkdir') + '/' + GRN('touch') + '/' + GRN('rm') + ' [-r]/' + GRN('mv') + '/' + GRN('cp') + '\n' +
87
+ ' ' + GRN('open') + ' <f> (en el editor) ' + GRN('git') + ' status|log|branch ' + GRN('history') + ' ' + GRN('clear') + '\n' +
88
+ ' Soporta tuberías ' + YEL('|') + ' y redirección ' + YEL('>') + ' ' + YEL('>>') + '. ' +
89
+ (capabilities().bridge ? GRN('node/npm/python disponibles (Bridge local).') : DIM('node/npm/python: conecta el Bridge local (⚙ Ajustes).'));
90
+ },
91
+ pwd() { return cwdString(); },
92
+ async cd(args) {
93
+ const target = args.find(a => !a.startsWith('-'));
94
+ if (!target || target === '~') { cwd = []; return ''; }
95
+ const segs = resolve(target);
96
+ if (await kindOf(segs) !== 'directory') throw new Error(`cd: no existe el directorio: ${target}`);
97
+ cwd = segs; return '';
98
+ },
99
+ async ls(args) {
100
+ const long = args.some(a => /^-\w*l/.test(a)), all = args.some(a => /^-\w*a/.test(a));
101
+ const target = args.find(a => !a.startsWith('-')) || '';
102
+ const segs = resolve(target);
103
+ const k = await kindOf(segs);
104
+ if (k === 'file') return target; // ls de un fichero
105
+ if (k !== 'directory') throw new Error(`ls: no existe: ${target || '.'}`);
106
+ const dir = await dirHandle(segs);
107
+ const rows = [];
108
+ for await (const e of dir.values()) {
109
+ if (!all && (e.name.startsWith('.') || IGNORE.has(e.name))) continue;
110
+ rows.push(e);
111
+ }
112
+ rows.sort((a, b) => (a.kind !== b.kind ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name)));
113
+ if (!long) return rows.map(e => e.kind === 'directory' ? CYAN(e.name + '/') : e.name).join(' ');
114
+ const out = [];
115
+ for (const e of rows) {
116
+ if (e.kind === 'directory') out.push(`${DIM('dir ')} - ${CYAN(e.name + '/')}`);
117
+ else { const f = await e.getFile(); out.push(`${DIM('file')} ${human(f.size).padStart(6)} ${e.name}`); }
118
+ }
119
+ return out.join('\n');
120
+ },
121
+ async cat(args, stdin) {
122
+ const files = args.filter(a => !a.startsWith('-'));
123
+ if (!files.length) return stdin;
124
+ const parts = [];
125
+ for (const f of files) {
126
+ try { parts.push(await readText(resolve(f))); }
127
+ catch { throw new Error(`cat: ${f}: no existe`); }
128
+ }
129
+ return parts.join('');
130
+ },
131
+ async head(args, stdin) {
132
+ const n = +(args[args.indexOf('-n') + 1]) || 10;
133
+ const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a));
134
+ const text = f ? await readText(resolve(f)) : stdin;
135
+ return text.split('\n').slice(0, n).join('\n');
136
+ },
137
+ async tail(args, stdin) {
138
+ const n = +(args[args.indexOf('-n') + 1]) || 10;
139
+ const f = args.find(a => !a.startsWith('-') && !/^\d+$/.test(a));
140
+ const text = f ? await readText(resolve(f)) : stdin;
141
+ const lines = text.split('\n'); return lines.slice(Math.max(0, lines.length - n)).join('\n');
142
+ },
143
+ async wc(args, stdin) {
144
+ const onlyLines = args.includes('-l');
145
+ const f = args.find(a => !a.startsWith('-'));
146
+ const text = f ? await readText(resolve(f)) : stdin;
147
+ const lines = text === '' ? 0 : text.split('\n').length;
148
+ if (onlyLines) return String(lines);
149
+ const words = (text.match(/\S+/g) || []).length;
150
+ return `${lines}\t${words}\t${text.length}${f ? '\t' + f : ''}`;
151
+ },
152
+ async tree(args) {
153
+ const target = args.find(a => !a.startsWith('-')) || '';
154
+ return code.tree({ path: resolve(target).join('/'), depth: 3 });
155
+ },
156
+ async grep(args, stdin) {
157
+ const insensitive = args.some(a => /^-\w*i/.test(a)), numbers = args.some(a => /^-\w*n/.test(a));
158
+ const rest = args.filter(a => !a.startsWith('-'));
159
+ const pattern = rest.shift();
160
+ if (!pattern) throw new Error('grep: falta el patrón');
161
+ const re = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), insensitive ? 'i' : '');
162
+ const hit = (line, i, prefix = '') => (prefix ? CYAN(prefix) + ':' : '') + (numbers ? DIM(i + 1 + ':') : '') + line.replace(re, m => `\x1b[1;31m${m}\x1b[0m`);
163
+ if (!rest.length) { // desde stdin (tubería)
164
+ return stdin.split('\n').map((l, i) => re.test(l) ? hit(l, i) : null).filter(x => x != null).join('\n');
165
+ }
166
+ const out = []; let scanned = 0;
167
+ const scanText = (text, label) =>
168
+ text.split('\n').forEach((l, i) => { if (re.test(l) && out.length < 400) out.push(hit(l, i, label)); });
169
+ for (const target of rest) {
170
+ const segs = resolve(target);
171
+ const k = await kindOf(segs);
172
+ if (k === 'file') scanText(await readText(segs).catch(() => ''), target);
173
+ else if (k === 'directory') { // recursivo
174
+ const walk = async (dir, prefix) => {
175
+ for await (const e of dir.values()) {
176
+ if (IGNORE.has(e.name) || out.length >= 400 || scanned > 600) continue;
177
+ const p = prefix ? prefix + '/' + e.name : e.name;
178
+ if (e.kind === 'directory') { await walk(e, p); continue; }
179
+ const f = await e.getFile();
180
+ if (f.size < 400_000) { scanned++; scanText(await f.text(), (target === '.' ? '' : target.replace(/\/$/, '') + '/') + p); }
181
+ }
182
+ };
183
+ await walk(await dirHandle(segs), '');
184
+ } else throw new Error(`grep: ${target}: no existe`);
185
+ }
186
+ return out.join('\n') || DIM('(sin coincidencias)');
187
+ },
188
+ async find(args) {
189
+ const nameIdx = args.indexOf('-name');
190
+ const glob = nameIdx >= 0 ? globToRe(args[nameIdx + 1]) : null;
191
+ const start = args.find(a => !a.startsWith('-') && a !== (nameIdx >= 0 ? args[nameIdx + 1] : null)) || '.';
192
+ const out = []; let count = 0;
193
+ const walk = async (dir, prefix) => {
194
+ for await (const e of dir.values()) {
195
+ if (IGNORE.has(e.name) || count > 2000) continue;
196
+ const p = prefix ? prefix + '/' + e.name : e.name;
197
+ count++;
198
+ if (!glob || glob.test(e.name)) out.push(e.kind === 'directory' ? CYAN(p) : p);
199
+ if (e.kind === 'directory') await walk(e, p);
200
+ }
201
+ };
202
+ const segs = resolve(start === '.' ? '' : start);
203
+ if (await kindOf(segs) !== 'directory') throw new Error(`find: ${start}: no es un directorio`);
204
+ await walk(await dirHandle(segs), start === '.' ? '' : start);
205
+ return out.join('\n') || DIM('(nada)');
206
+ },
207
+ echo(args) { return args.join(' ').replace(/^["']|["']$/g, ''); },
208
+ async mkdir(args) {
209
+ const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('mkdir: falta el nombre');
210
+ await dirHandle(resolve(t), { create: true }); onFsChange(); return '';
211
+ },
212
+ async touch(args) {
213
+ const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('touch: falta el nombre');
214
+ const segs = resolve(t);
215
+ if (await kindOf(segs) == null) await writeText(segs, ''); else onFsChange();
216
+ return '';
217
+ },
218
+ // Soporta comodines (*, ?) y varios objetivos a la vez: `rm mejoras-*.md`.
219
+ // Sin esto, un patrón con * se buscaba como nombre LITERAL → «no existe»
220
+ // aunque hubiera decenas de coincidencias reales (bug real, visto en uso).
221
+ async rm(args) {
222
+ const recursive = args.some(a => /^-\w*r/.test(a));
223
+ const targets = args.filter(a => !a.startsWith('-'));
224
+ if (!targets.length) throw new Error('rm: falta la ruta');
225
+ const removed = [];
226
+ for (const t of targets) {
227
+ if (/[*?]/.test(t)) {
228
+ const parts = t.split('/'); const pattern = parts.pop();
229
+ const dirSegs = resolve(parts.join('/'));
230
+ let dir; try { dir = await dirHandle(dirSegs); } catch { throw new Error(`rm: ${t}: no existe el directorio`); }
231
+ const re = globToRe(pattern);
232
+ const matches = [];
233
+ for await (const e of dir.values()) if (re.test(e.name)) matches.push(e);
234
+ if (!matches.length) throw new Error(`rm: ${t}: sin coincidencias`);
235
+ for (const e of matches) {
236
+ if (e.kind === 'directory' && !recursive) throw new Error(`rm: ${e.name}: es un directorio (usa -r)`);
237
+ await dir.removeEntry(e.name, { recursive });
238
+ removed.push((dirSegs.length ? dirSegs.join('/') + '/' : '') + e.name);
239
+ }
240
+ } else {
241
+ const segs = resolve(t); const k = await kindOf(segs);
242
+ if (k == null) throw new Error(`rm: ${t}: no existe`);
243
+ if (k === 'directory' && !recursive) throw new Error(`rm: ${t}: es un directorio (usa -r)`);
244
+ const { parent, name } = await parentAndName(segs);
245
+ await parent.removeEntry(name, { recursive });
246
+ removed.push(t);
247
+ }
248
+ }
249
+ code.invalidateFileList?.(); onFsChange();
250
+ return removed.length > 1 ? `Eliminados ${removed.length}: ${removed.join(', ')}` : '';
251
+ },
252
+ async cp(args) {
253
+ const rest = args.filter(a => !a.startsWith('-'));
254
+ const [src, dst] = rest; if (!src || !dst) throw new Error('cp: uso: cp <origen> <destino>');
255
+ await copyPath(resolve(src), resolve(dst)); onFsChange(); return '';
256
+ },
257
+ async mv(args) {
258
+ const rest = args.filter(a => !a.startsWith('-'));
259
+ const [src, dst] = rest; if (!src || !dst) throw new Error('mv: uso: mv <origen> <destino>');
260
+ await copyPath(resolve(src), resolve(dst));
261
+ const { parent, name } = await parentAndName(resolve(src));
262
+ await parent.removeEntry(name, { recursive: true });
263
+ code.invalidateFileList?.(); onFsChange(); return '';
264
+ },
265
+ async open(args) {
266
+ const t = args.find(a => !a.startsWith('-')); if (!t) throw new Error('open: falta el fichero');
267
+ const segs = resolve(t);
268
+ if (await kindOf(segs) !== 'file') throw new Error(`open: ${t}: no es un fichero`);
269
+ onOpenFile(segs.join('/')); return DIM(`abriendo ${segs.join('/')} en el editor…`);
270
+ },
271
+ history() { return history.map((h, i) => `${DIM((i + 1) + '')} ${h}`).join('\n'); },
272
+ whoami() { return 'elffuss'; },
273
+ date() { return new Date().toString(); },
274
+ clear() { return '\f'; }, // la UI lo interpreta
275
+ async git(args) {
276
+ const sub = args[0];
277
+ const info = await code.gitInfo();
278
+ if (!info.isRepo) return DIM('no es un repositorio git (no hay .git)');
279
+ if (sub === 'branch' || !sub) return '* ' + GRN(info.branch);
280
+ if (sub === 'status') return `En la rama ${GRN(info.branch)}\n` + (info.lastCommit ? DIM(`último commit: ${info.lastCommit.msg}`) : DIM('sin commits todavía')) + '\n' + DIM('(git de solo lectura aquí; commit/push reales no disponibles todavía)');
281
+ if (sub === 'log') return info.lastCommit ? `${YEL('commit ' + info.branch)}\nAutor: ${info.lastCommit.author}\nFecha: ${new Date(info.lastCommit.when).toLocaleString()}\n\n ${info.lastCommit.msg}` : DIM('sin commits');
282
+ return DIM(`git ${sub}: de solo lectura aquí (branch/status/log).`);
283
+ },
284
+ };
285
+ // tokenize() ya quitó las comillas originales (p.ej. -e "console.log(1)" llega
286
+ // como el arg SUELTO console.log(1)) — para reenviarlo al bridge (que lo pasa
287
+ // a `sh -c`) hay que re-entrecomillarlo, si no un espacio o un paréntesis
288
+ // rompe el comando al otro lado (bug real: -e "console.log('x')" fallaba).
289
+ const shellQuote = s => `'${String(s).replace(/'/g, "'\\''")}'`;
290
+ // runtimes REALES solo si hay un Bridge local conectado (⚙ Ajustes) — se
291
+ // ejecutan en tu máquina de verdad, en la carpeta configurada del bridge (no
292
+ // en el cwd virtual del proyecto).
293
+ for (const r of ['node', 'npm', 'npx', 'python', 'python3', 'pip', 'yarn', 'pnpm', 'bun', 'deno', 'cargo', 'go'])
294
+ CMDS[r] = async (args) => {
295
+ if (!bridgeClient.isConnected()) {
296
+ throw new Error(`${r}: runtime real no disponible.\n${DIM('Descarga y arranca el Bridge local (⚙ Ajustes → 🔌 Bridge local) para ejecución real en tu máquina — se conecta solo en cuanto lo detecte. Mientras tanto, la elfa edita ficheros y tú navegas con los comandos de arriba (help).')}`);
297
+ }
298
+ const full = r + (args.length ? ' ' + args.map(shellQuote).join(' ') : '');
299
+ let out = '';
300
+ const code = await bridgeClient.exec(full, { onOut: t => { out += t; }, onErr: t => { out += RED(t); } });
301
+ if (code) out += `\n${DIM('(código de salida ' + code + ')')}`;
302
+ return out || DIM('(sin salida)');
303
+ };
304
+
305
+ // copia recursiva por handles (la File System API no tiene move nativo)
306
+ async function copyPath(srcSegs, dstSegs) {
307
+ const k = await kindOf(srcSegs);
308
+ if (k == null) throw new Error('origen no existe');
309
+ if (k === 'file') { await writeText(dstSegs, await readText(srcSegs)); return; }
310
+ const src = await dirHandle(srcSegs);
311
+ await dirHandle(dstSegs, { create: true });
312
+ for await (const e of src.values()) await copyPath([...srcSegs, e.name], [...dstSegs, e.name]);
313
+ }
314
+
315
+ // ---- parser: tuberías, redirección, comillas ----
316
+ function splitTop(line, sep) { // parte por `sep` respetando comillas
317
+ const parts = []; let cur = '', q = '';
318
+ for (const ch of line) {
319
+ if (q) { cur += ch; if (ch === q) q = ''; }
320
+ else if (ch === '"' || ch === "'") { q = ch; cur += ch; }
321
+ else if (ch === sep) { parts.push(cur); cur = ''; }
322
+ else cur += ch;
323
+ }
324
+ parts.push(cur); return parts;
325
+ }
326
+ function tokenize(seg) {
327
+ const toks = []; let cur = '', q = '';
328
+ for (const ch of seg.trim()) {
329
+ if (q) { if (ch === q) q = ''; else cur += ch; }
330
+ else if (ch === '"' || ch === "'") q = ch;
331
+ else if (/\s/.test(ch)) { if (cur) { toks.push(cur); cur = ''; } }
332
+ else cur += ch;
333
+ }
334
+ if (cur) toks.push(cur);
335
+ return toks;
336
+ }
337
+
338
+ // Ejecuta una línea completa (encadenado &&/;, tuberías, redirección).
339
+ // `\f` en el retorno = orden de limpiar pantalla (para la UI).
340
+ export async function exec(line, { record = true } = {}) {
341
+ line = line.trim();
342
+ if (!line) return '';
343
+ if (record) history.push(line);
344
+ const segments = tokenizeChain(line);
345
+ if (segments.length === 1) return runPipeline(segments[0].cmd);
346
+ // encadenado: A && B (B solo si A fue bien) · A ; B (B siempre)
347
+ const outs = []; let ok = true;
348
+ for (const { cmd, op } of segments) {
349
+ if (op === '&&' && !ok) continue;
350
+ try { outs.push(await runPipeline(cmd)); ok = true; }
351
+ catch (e) { ok = false; outs.push('\x1b[31m' + (e && e.message || e) + '\x1b[0m'); }
352
+ }
353
+ if (outs.every(o => o === '\f')) return '\f';
354
+ return outs.filter(o => o && o !== '\f').join('\n');
355
+ }
356
+
357
+ // Divide la línea en segmentos con su operador previo (&& / ; ), respetando comillas.
358
+ function tokenizeChain(line) {
359
+ const segs = []; let cur = '', q = '', op = null;
360
+ for (let i = 0; i < line.length; i++) {
361
+ const ch = line[i];
362
+ if (q) { cur += ch; if (ch === q) q = ''; continue; }
363
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
364
+ if (ch === '&' && line[i + 1] === '&') { segs.push({ cmd: cur.trim(), op }); op = '&&'; cur = ''; i++; continue; }
365
+ if (ch === ';') { segs.push({ cmd: cur.trim(), op }); op = ';'; cur = ''; continue; }
366
+ cur += ch;
367
+ }
368
+ if (cur.trim()) segs.push({ cmd: cur.trim(), op });
369
+ return segs.filter(s => s.cmd);
370
+ }
371
+
372
+ // Ejecuta un pipeline (una etapa o varias unidas por |, con redirección final).
373
+ async function runPipeline(line) {
374
+ const stages = splitTop(line, '|').map(s => s.trim()).filter(Boolean);
375
+ let data = '';
376
+ for (let i = 0; i < stages.length; i++) {
377
+ let seg = stages[i], redir = null;
378
+ if (i === stages.length - 1) {
379
+ const m = seg.match(/(>>?)\s*(\S+)\s*$/);
380
+ if (m) { redir = { append: m[1] === '>>', file: m[2] }; seg = seg.slice(0, m.index); }
381
+ }
382
+ const toks = tokenize(seg);
383
+ const name = toks.shift();
384
+ if (!name) continue;
385
+ const fn = CMDS[name];
386
+ if (!fn) throw new Error(`${name}: comando no encontrado (prueba ${GRN('help')})`);
387
+ data = (await fn(toks, data)) ?? '';
388
+ if (redir) {
389
+ const segs = resolve(redir.file);
390
+ const prev = redir.append ? await readText(segs).catch(() => '') : '';
391
+ await writeText(segs, prev + stripAnsi(data));
392
+ data = '';
393
+ }
394
+ }
395
+ return data;
396
+ }
397
+
398
+ export const stripAnsi = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
399
+
400
+ // Entradas de un directorio (para el completado con Tab del terminal).
401
+ export async function listDir(dirPart = '') {
402
+ const dir = await dirHandle(resolve(dirPart));
403
+ const out = [];
404
+ for await (const e of dir.values()) if (!IGNORE.has(e.name)) out.push({ name: e.name, kind: e.kind });
405
+ return out;
406
+ }
407
+
408
+ // Ejecuta una orden desde la elfa (tool terminal.run): devuelve texto plano.
409
+ export async function runForAgent(command) {
410
+ const out = await exec(command, { record: true });
411
+ return out === '\f' ? '(pantalla limpiada)' : (stripAnsi(out) || '(sin salida)');
412
+ }
js/skills.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Skills de Elffuss Code: instrucciones en markdown (formato SKILL.md de
2
+ // Claude Code) que el modelo sigue cuando aplican. Se instalan desde el
3
+ // catálogo grande OFICIAL (github.com/anthropics/skills), desde los plugins
4
+ // oficiales, o desde CUALQUIER repo público (marketplaces de la comunidad
5
+ // tipo OpenClaude/openclaw). Todo transparente: se ve el repo, la lista y el
6
+ // SKILL.md que se inyecta. Se guardan en IndexedDB (nada sale del navegador).
7
+ import * as db from './db.js';
8
+
9
+ const KEY = 'skills'; // skills instaladas
10
+ const SRC_KEY = 'skills.sources'; // repos personalizados añadidos por el usuario
11
+ const MAX_SKILL = 12_000; // caracteres del cuerpo que se inyectan al modelo
12
+
13
+ export const CATALOG_REPO = 'anthropics/skills';
14
+ export const DEFAULT_SOURCES = [
15
+ { repo: 'bbgnsurftech/claude-skills-collection', label: 'Comunidad · Mega-colección (1000+ skills)', official: true },
16
+ { repo: 'anthropics/skills', label: 'Anthropic · Agent Skills (oficial de Claude Code)', official: true },
17
+ { repo: 'anthropics/claude-plugins-official', label: 'Anthropic · Claude Code Plugins (oficial)', official: true },
18
+ ];
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);
35
+ await db.set('kv', KEY, cache);
36
+ return entry; // devuelve la skill completa (name, description…)
37
+ }
38
+
39
+ // Mensaje «cómo usarla» tras instalar: qué hace + un ejemplo de qué pedir.
40
+ export function usageMessage(skill) {
41
+ const desc = (skill.description || '').trim();
42
+ const ejemplo = firstExample(skill) || `algo relacionado con «${skill.name}»`;
43
+ return `✳ **Skill «${skill.name}» instalada.**\n\n` +
44
+ (desc ? desc + '\n\n' : '') +
45
+ `Ya la sigo en cada conversación. Para usarla, pídeme por ejemplo:\n\n> ${ejemplo}`;
46
+ }
47
+
48
+ // Intenta sacar un ejemplo de uso del cuerpo del SKILL.md (líneas de ejemplo/uso).
49
+ function firstExample(skill) {
50
+ const body = skill.content || '';
51
+ const m = body.match(/(?:ejemplo|example|uso|usage|prueba|try)[^\n:]*[::]\s*["“]?([^\n"”]{6,90})/i)
52
+ || body.match(/^[-*]\s+([A-ZÁÉÍÓÚ][^\n]{10,80})/m);
53
+ return m ? m[1].trim().replace(/[.*_`]+$/, '') : null;
54
+ }
55
+
56
+ export async function remove(nameOrPath) {
57
+ cache = cache.filter(s => s.name !== nameOrPath && s.path !== nameOrPath);
58
+ await db.set('kv', KEY, cache);
59
+ }
60
+
61
+ export async function get(name) {
62
+ return cache.find(s => s.name.toLowerCase() === String(name).toLowerCase()) || null;
63
+ }
64
+
65
+ // ---- fuentes (repos) ----
66
+ export async function sources() {
67
+ const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
68
+ return [...DEFAULT_SOURCES, ...custom];
69
+ }
70
+ export async function addSource(repoOrUrl, label) {
71
+ const repo = repoOrUrl.trim().replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '');
72
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('Usa owner/repo (o la URL de GitHub)');
73
+ const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
74
+ if (!custom.some(s => s.repo === repo) && !DEFAULT_SOURCES.some(s => s.repo === repo))
75
+ custom.push({ repo, label: label || repo });
76
+ await db.set('kv', SRC_KEY, custom);
77
+ return repo;
78
+ }
79
+ export async function removeSource(repo) {
80
+ const custom = (await db.get('kv', SRC_KEY).catch(() => null)) || [];
81
+ await db.set('kv', SRC_KEY, custom.filter(s => s.repo !== repo));
82
+ }
83
+
84
+ // ---- catálogo desde GitHub ----
85
+ // Una sola llamada al árbol git del repo → todos los SKILL.md.
86
+ export async function listFromRepo(repo) {
87
+ let tree, branch;
88
+ for (const b of ['main', 'master']) {
89
+ const r = await fetch(`https://api.github.com/repos/${repo}/git/trees/${b}?recursive=1`);
90
+ if (r.ok) { tree = await r.json(); branch = b; break; }
91
+ if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
92
+ }
93
+ if (!tree) throw new Error('No pude leer el repo (¿existe y es público?)');
94
+ return (tree.tree || [])
95
+ .filter(n => /(^|\/)SKILL\.md$/i.test(n.path))
96
+ .map(n => ({ repo, branch, path: n.path, dir: n.path.replace(/\/SKILL\.md$/i, ''), name: n.path.replace(/\/SKILL\.md$/i, '').split('/').pop() || repo }))
97
+ .sort((a, b) => a.dir.localeCompare(b.dir));
98
+ }
99
+
100
+ // Descarga el SKILL.md y lo instala (frontmatter YAML simple).
101
+ export async function installFromRepo(entry) {
102
+ let md = null;
103
+ for (const b of [entry.branch, 'main', 'master'].filter(Boolean)) {
104
+ const r = await fetch(`https://raw.githubusercontent.com/${entry.repo}/${b}/${entry.path}`);
105
+ if (r.ok) { md = await r.text(); break; }
106
+ }
107
+ if (md == null) throw new Error('No pude descargar el SKILL.md');
108
+ const skill = parseSkill(md, entry.name);
109
+ return install({ ...skill, repo: entry.repo, path: entry.path });
110
+ }
111
+
112
+ // SKILL.md → { name, description, content }
113
+ export function parseSkill(md, fallbackName = 'skill') {
114
+ const fm = md.match(/^---\n([\s\S]*?)\n---/);
115
+ const meta = fm?.[1] || '';
116
+ const name = meta.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
117
+ const description = (meta.match(/^description:\s*([\s\S]+?)(?:\n\w+:|$)/m)?.[1] || '').replace(/\s+/g, ' ').trim();
118
+ const content = md.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
119
+ return { name, description, content };
120
+ }
121
+
122
+ // Creador de skills: Elffuss fabrica una skill propia (SKILL.md) a partir de
123
+ // lo que el usuario quiere, y la instala al instante. Aparece en la pestaña
124
+ // Skills y se inyecta en el prompt de los siguientes turnos.
125
+ export async function createSkill({ name, description, instructions } = {}) {
126
+ if (!name || !instructions) throw new Error('Faltan name o instructions');
127
+ const skill = {
128
+ name: String(name).slice(0, 48),
129
+ description: (description || '').slice(0, 240),
130
+ content: String(instructions).slice(0, MAX_SKILL),
131
+ repo: 'creada por Elffuss',
132
+ path: 'local/' + Date.now(),
133
+ };
134
+ await install(skill);
135
+ return `Skill «${skill.name}» creada e instalada. Ya la sigo en cada conversación (mírala en la pestaña Skills).`;
136
+ }
137
+
138
+ // Bloque para el systemPrompt (síncrono, desde la caché).
139
+ export function skillsPromptBlock() {
140
+ if (!cache.length) return '';
141
+ const parts = cache.map(s =>
142
+ `### Skill «${s.name}»${s.repo ? ` (de ${s.repo})` : ''}\n${s.description || ''}\n${(s.content || '').slice(0, MAX_SKILL)}`);
143
+ return `\n\nSKILLS ACTIVAS (instrucciones especializadas; síguelas cuando la tarea encaje):\n${parts.join('\n\n')}`;
144
+ }
js/splash-gl.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Galaxia de partículas WebGL para el splash — sin librerías, autocontenida.
2
+ // Técnica: GL_POINTS + blending aditivo + rotación diferencial calculada por
3
+ // completo en el vertex shader desde una semilla (la CPU no toca nada por
4
+ // frame). ~24k partículas en espiral rosa→violeta con motas doradas, parallax
5
+ // de ratón y explosión al pulsar Entrar.
6
+ export function startGalaxy(container) {
7
+ const canvas = document.createElement('canvas');
8
+ canvas.className = 'gl';
9
+ container.prepend(canvas);
10
+ const gl = canvas.getContext('webgl', { alpha: true, antialias: false, depth: false });
11
+ if (!gl) { canvas.remove(); return null; } // fallback: se queda el gradiente CSS
12
+
13
+ const N = 24000;
14
+ const seeds = new Float32Array(N * 2);
15
+ for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random();
16
+
17
+ const VS = `
18
+ attribute vec2 seed;
19
+ uniform float t, aspect, burst, dpr;
20
+ uniform vec2 par;
21
+ varying vec3 vColor;
22
+ varying float vGlow;
23
+ float hash(float n){ return fract(sin(n) * 43758.5453); }
24
+ void main(){
25
+ float r = 0.08 + 1.35 * pow(seed.y, 0.65);
26
+ float arm = floor(hash(seed.x * 7.0 + seed.y) * 3.0);
27
+ float ang = seed.x * 0.9 + arm * 2.0944 + r * 2.6 + t * (0.12 + 0.25 / (r + 0.2));
28
+ float wob = (hash(seed.x * 13.1) - 0.5) * 0.22 * r;
29
+ vec2 p = vec2(cos(ang), sin(ang)) * (r + wob);
30
+ p.y *= 0.62;
31
+ p.y += (hash(seed.y * 17.7) - 0.5) * 0.14;
32
+ p += par * (0.03 + r * 0.06);
33
+ p *= 1.0 + burst * (0.4 + seed.y * 2.4);
34
+ gl_Position = vec4(p.x / aspect, p.y, 0.0, 1.0);
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
+ }`;
44
+ const FS = `
45
+ precision mediump float;
46
+ varying vec3 vColor;
47
+ varying float vGlow;
48
+ void main(){
49
+ float m = smoothstep(0.5, 0.0, length(gl_PointCoord - 0.5));
50
+ gl_FragColor = vec4(vColor * vGlow, m * vGlow);
51
+ }`;
52
+
53
+ const sh = (type, src) => {
54
+ const s = gl.createShader(type);
55
+ gl.shaderSource(s, src);
56
+ gl.compileShader(s);
57
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
58
+ return s;
59
+ };
60
+ const prog = gl.createProgram();
61
+ gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
62
+ gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
63
+ gl.linkProgram(prog);
64
+ gl.useProgram(prog);
65
+
66
+ const buf = gl.createBuffer();
67
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
68
+ gl.bufferData(gl.ARRAY_BUFFER, seeds, gl.STATIC_DRAW); // una vez; la GPU manda
69
+ const loc = gl.getAttribLocation(prog, 'seed');
70
+ gl.enableVertexAttribArray(loc);
71
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
72
+
73
+ const U = n => gl.getUniformLocation(prog, n);
74
+ const uT = U('t'), uAspect = U('aspect'), uBurst = U('burst'), uPar = U('par'), uDpr = U('dpr');
75
+
76
+ gl.enable(gl.BLEND);
77
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // aditivo = brillo
78
+ gl.clearColor(0, 0, 0, 0);
79
+
80
+ let burstAt = 0, alive = true;
81
+ const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
82
+ const onMove = e => {
83
+ mouse.tx = (e.clientX / innerWidth - 0.5) * 2;
84
+ mouse.ty = -(e.clientY / innerHeight - 0.5) * 2;
85
+ };
86
+ addEventListener('pointermove', onMove);
87
+
88
+ const dpr = Math.min(devicePixelRatio || 1, 2);
89
+ function frame(now) {
90
+ if (!alive) return;
91
+ const w = container.clientWidth, h = container.clientHeight;
92
+ if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
93
+ canvas.width = w * dpr;
94
+ canvas.height = h * dpr;
95
+ gl.viewport(0, 0, canvas.width, canvas.height);
96
+ }
97
+ mouse.x += (mouse.tx - mouse.x) * 0.05;
98
+ mouse.y += (mouse.ty - mouse.y) * 0.05;
99
+ const t = now / 1000;
100
+ const burst = burstAt ? Math.min((now - burstAt) / 900, 1) : 0;
101
+ gl.clear(gl.COLOR_BUFFER_BIT);
102
+ gl.uniform1f(uT, t);
103
+ gl.uniform1f(uAspect, w / h);
104
+ gl.uniform1f(uBurst, burst);
105
+ gl.uniform1f(uDpr, dpr);
106
+ gl.uniform2f(uPar, mouse.x, mouse.y);
107
+ gl.drawArrays(gl.POINTS, 0, N);
108
+ requestAnimationFrame(frame);
109
+ }
110
+ requestAnimationFrame(frame);
111
+
112
+ return {
113
+ burst() { burstAt = performance.now(); },
114
+ stop() { alive = false; removeEventListener('pointermove', onMove); canvas.remove(); },
115
+ };
116
+ }
js/telemetry.js ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Buzón opt-in de errores/feedback. Apagado por defecto — la promesa de
2
+ // «nada sale de tu máquina» sigue siendo cierta salvo que el usuario decida
3
+ // lo contrario en Ajustes. Cuando está activo:
4
+ // - errores no capturados (window.onerror / unhandledrejection) y los
5
+ // puntos concretos del código que llaman a reportError() se mandan a
6
+ // /proxy/report (mismo origen, vía el proxy compartido de Elffuss)
7
+ // - sendFeedback() manda texto libre que el usuario escriba a propósito
8
+ // Nunca se manda código ni contenido del proyecto — solo el mensaje técnico,
9
+ // la pila, la URL y el user-agent. Sin cola ni reintentos: si el envío
10
+ // falla, se descarta sin más (evita que un fallo de red se convierta en
11
+ // almacenamiento sin límite).
12
+ let appName = 'elffuss';
13
+ let enabled = false;
14
+ let hooked = false;
15
+
16
+ const storageKey = () => 'elffuss.telemetry.' + appName;
17
+
18
+ export function isEnabled() { return enabled; }
19
+
20
+ export function setEnabled(v) {
21
+ enabled = !!v;
22
+ try { localStorage.setItem(storageKey(), enabled ? '1' : '0'); } catch { /* localStorage lleno/bloqueado */ }
23
+ if (enabled) hookGlobalErrors();
24
+ }
25
+
26
+ async function post(kind, message, opts = {}) {
27
+ if (!enabled) return;
28
+ try {
29
+ await fetch('/proxy/report', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({
33
+ app: appName, kind, message: String(message ?? '').slice(0, 2000),
34
+ stack: String(opts.stack || '').slice(0, 4000),
35
+ url: location.href, userAgent: navigator.userAgent,
36
+ extra: String(opts.extra || '').slice(0, 2000),
37
+ }),
38
+ });
39
+ } catch { /* sin conexión o proxy caído: se descarta, no hay reintento */ }
40
+ }
41
+
42
+ export function reportError(message, opts = {}) { return post('error', message, opts); }
43
+ export function sendFeedback(text) { return post('feedback', text); }
44
+
45
+ function hookGlobalErrors() {
46
+ if (hooked) return;
47
+ hooked = true;
48
+ window.addEventListener('error', e => {
49
+ reportError(e.message || String(e.error) || 'error', { stack: e.error?.stack || '' });
50
+ });
51
+ window.addEventListener('unhandledrejection', e => {
52
+ const r = e.reason;
53
+ reportError('unhandledrejection: ' + (r?.message || r), { stack: r?.stack || '' });
54
+ });
55
+ }
56
+
57
+ // Llamar UNA vez al arrancar, con el nombre de la app (para distinguir
58
+ // elffuss-code de elffuss-claw en los informes) — lee la preferencia guardada.
59
+ export function init(name) {
60
+ appName = name || appName;
61
+ try { enabled = localStorage.getItem(storageKey()) === '1'; } catch { enabled = false; }
62
+ if (enabled) hookGlobalErrors();
63
+ }
js/terminal.js ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Terminal de Elffuss Code: xterm.js (VT100 real) + un readline propio que
2
+ // ejecuta contra shell.js (los ficheros REALES del proyecto). xterm no trae
3
+ // shell: nosotros gestionamos la línea (edición, historial, cursor) y delegamos
4
+ // la ejecución en shell.exec. La misma orden la puede lanzar la elfa vía el
5
+ // tool `terminal.run`, y su salida aparece aquí.
6
+ import * as shell from './shell.js';
7
+
8
+ let term = null, fit = null, ready = false;
9
+ let Terminal = null, FitAddon = null;
10
+ let line = '', pos = 0, histIdx = -1, busy = false;
11
+ const XT = '@xterm/xterm@5.5.0', FIT = '@xterm/addon-fit@0.10.0';
12
+
13
+ // IMPORTANTE: cargamos xterm como ESM, no UMD. Monaco trae un loader AMD
14
+ // (define/require) y la build UMD de xterm se registra como «define anónimo»
15
+ // en vez de exponer window.Terminal → «Can only have one anonymous define».
16
+ // El import ESM esquiva por completo el define de Monaco.
17
+ async function loadXterm() {
18
+ if (Terminal) return;
19
+ if (!document.getElementById('xterm-css')) {
20
+ const l = document.createElement('link');
21
+ l.id = 'xterm-css'; l.rel = 'stylesheet'; l.href = `https://cdn.jsdelivr.net/npm/${XT}/css/xterm.min.css`;
22
+ document.head.appendChild(l);
23
+ }
24
+ const [xt, ft] = await Promise.all([
25
+ import(`https://cdn.jsdelivr.net/npm/${XT}/+esm`),
26
+ import(`https://cdn.jsdelivr.net/npm/${FIT}/+esm`),
27
+ ]);
28
+ Terminal = xt.Terminal || (xt.default && xt.default.Terminal);
29
+ FitAddon = ft.FitAddon || (ft.default && ft.default.FitAddon) || ft.default;
30
+ }
31
+
32
+ const prompt = () => `\x1b[36m${shell.cwdString()}\x1b[0m \x1b[35m❯\x1b[0m `;
33
+ const writePrompt = () => term.write('\r\n' + prompt());
34
+ // Redibuja la línea de entrada (prompt + buffer) y coloca el cursor en `pos`.
35
+ function redraw() {
36
+ term.write('\r\x1b[K' + prompt() + line);
37
+ const back = line.length - pos;
38
+ if (back > 0) term.write(`\x1b[${back}D`);
39
+ }
40
+
41
+ async function run(raw) {
42
+ const cmd = raw.trim();
43
+ if (!cmd) { writePrompt(); return; }
44
+ busy = true;
45
+ try {
46
+ const out = await shell.exec(cmd);
47
+ if (out === '\f') term.clear();
48
+ else if (out) term.write('\r\n' + out.replace(/\n/g, '\r\n'));
49
+ } catch (e) {
50
+ term.write('\r\n\x1b[31m' + String(e && e.message || e).replace(/\n/g, '\r\n') + '\x1b[0m');
51
+ }
52
+ busy = false;
53
+ writePrompt();
54
+ }
55
+
56
+ function onKey(data) {
57
+ if (busy) return;
58
+ switch (data) {
59
+ case '\r': // Enter
60
+ { const cmd = line; line = ''; pos = 0; histIdx = -1; run(cmd); return; }
61
+ case '\x7f': // Retroceso
62
+ if (pos > 0) { line = line.slice(0, pos - 1) + line.slice(pos); pos--; redraw(); } return;
63
+ case '\x1b[3~': // Supr
64
+ if (pos < line.length) { line = line.slice(0, pos) + line.slice(pos + 1); redraw(); } return;
65
+ case '\x03': // Ctrl+C
66
+ term.write('^C'); line = ''; pos = 0; histIdx = -1; writePrompt(); return;
67
+ case '\x0c': // Ctrl+L
68
+ term.clear(); redraw(); return;
69
+ case '\x1b[A': case '\x1b[B': { // arriba / abajo: historial
70
+ const h = shell.getHistory(); if (!h.length) return;
71
+ if (histIdx === -1) histIdx = h.length;
72
+ histIdx = Math.max(0, Math.min(h.length, histIdx + (data === '\x1b[A' ? -1 : 1)));
73
+ line = h[histIdx] || ''; pos = line.length; redraw(); return;
74
+ }
75
+ case '\x1b[C': if (pos < line.length) { pos++; term.write('\x1b[C'); } return; // derecha
76
+ case '\x1b[D': if (pos > 0) { pos--; term.write('\x1b[D'); } return; // izquierda
77
+ case '\x1b[H': case '\x01': pos = 0; redraw(); return; // Inicio / Ctrl+A
78
+ case '\x1b[F': case '\x05': pos = line.length; redraw(); return; // Fin / Ctrl+E
79
+ case '\t': complete(); return; // Tab: completar rutas
80
+ }
81
+ if (data.length !== 1 || data.charCodeAt(0) < 32) return; // control/escape: ignorar
82
+ line = line.slice(0, pos) + data + line.slice(pos); pos += data.length; redraw();
83
+ }
84
+
85
+ // Completado de rutas simple: mira el último token contra el directorio actual.
86
+ async function complete() {
87
+ const frag = (line.slice(0, pos).split(/\s+/).pop()) || '';
88
+ const slash = frag.lastIndexOf('/');
89
+ const dirPart = slash >= 0 ? frag.slice(0, slash + 1) : '';
90
+ const base = slash >= 0 ? frag.slice(slash + 1) : frag;
91
+ let names;
92
+ try { names = await shell.listDir(dirPart); } catch { return; }
93
+ const matches = names.filter(n => n.name.startsWith(base));
94
+ if (matches.length === 1) {
95
+ const add = matches[0].name.slice(base.length) + (matches[0].kind === 'directory' ? '/' : ' ');
96
+ line = line.slice(0, pos) + add + line.slice(pos); pos += add.length; redraw();
97
+ } else if (matches.length > 1) {
98
+ term.write('\r\n' + matches.map(m => m.kind === 'directory' ? '\x1b[36m' + m.name + '/\x1b[0m' : m.name).join(' '));
99
+ writePrompt(); term.write(line); if (line.length - pos > 0) term.write(`\x1b[${line.length - pos}D`);
100
+ }
101
+ }
102
+
103
+ // Monta el terminal en `host` (una vez). Idempotente; refoca si ya existe.
104
+ export async function mount(host) {
105
+ if (ready) { setTimeout(() => { fit && fit.fit(); term && term.focus(); }, 0); return; }
106
+ await loadXterm();
107
+ term = new Terminal({
108
+ fontSize: 12.5, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
109
+ cursorBlink: true, convertEol: false, scrollback: 2000,
110
+ theme: { background: '#0b0d12', foreground: '#e8ebf4', cursor: '#ff4d8d', selectionBackground: '#7c5cff55',
111
+ brightBlack: '#6b7280', red: '#ff6b8b', green: '#7ee787', yellow: '#ffd479', blue: '#7c5cff', magenta: '#ff4d8d', cyan: '#56d4dd' },
112
+ });
113
+ fit = new FitAddon();
114
+ term.loadAddon(fit);
115
+ term.open(host);
116
+ fit.fit();
117
+ host.addEventListener('mousedown', () => setTimeout(() => term.focus(), 0));
118
+ term.onData(onKey);
119
+ const cap = shell.capabilities();
120
+ term.write('\x1b[1;35mElffuss shell\x1b[0m \x1b[90m— ficheros reales del proyecto, en tu navegador.\x1b[0m');
121
+ const rtHint = cap.bridge ? '🔌 Bridge local conectado: node/npm/python REALES en tu máquina.'
122
+ : 'Escribe \x1b[0m\x1b[32mhelp\x1b[0m\x1b[90m. node/npm/python reales → conecta el Bridge local (⚙ Ajustes).';
123
+ term.write('\r\n\x1b[90m' + rtHint + '\x1b[0m');
124
+ writePrompt();
125
+ ready = true;
126
+ setTimeout(() => term.focus(), 0);
127
+ }
128
+
129
+ export function refit() { if (ready) setTimeout(() => fit && fit.fit(), 0); }
130
+ export function focus() { if (ready) term.focus(); }
131
+ export function isReady() { return ready; }
132
+ export function clearScreen() { if (ready) { term.clear(); line = ''; pos = 0; term.write('\r\x1b[K' + prompt()); term.focus(); } }
133
+
134
+ // Eco de una orden lanzada por la elfa (para que el usuario la vea en el terminal).
135
+ export function echoAgent(cmd, out) {
136
+ if (!ready) return;
137
+ term.write('\r\x1b[K' + prompt() + '\x1b[90m' + cmd + ' (elfa)\x1b[0m');
138
+ if (out) term.write('\r\n' + shell.stripAnsi(out).replace(/\n/g, '\r\n'));
139
+ writePrompt(); term.write(line);
140
+ }
js/tools/clone.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Descarga un repo PÚBLICO (GitHub/Bitbucket) en una carpeta real elegida por
2
+ // el usuario, para abrirlo como proyecto sin necesitar git instalado. Mismo
3
+ // patrón que skills.js: árbol de Git vía API + contenidos por raw CDN (sin
4
+ // tope de la API, solo la llamada al árbol cuenta contra el límite 60/h).
5
+ //
6
+ // RESUMIBLE por diseño: listRepo() (el árbol) y downloadFiles() (los ficheros)
7
+ // están separados a propósito — quien llama (main.js) persiste la lista y qué
8
+ // rutas ya se descargaron, así una descarga interrumpida (recarga, pestaña
9
+ // cerrada) continúa donde se quedó en vez de volver a empezar.
10
+ const MAX_FILES = 4000;
11
+ const MAX_FILE_BYTES = 4 * 1024 * 1024; // ficheros más grandes se saltan (binarios enormes)
12
+ const CONCURRENCY = 10;
13
+
14
+ export function parseRepoUrl(raw) {
15
+ const url = String(raw || '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
16
+ let m = url.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)(?:\/tree\/([\w./-]+))?/i);
17
+ if (m) return { host: 'github', owner: m[1], repo: m[2], branch: m[3] || null };
18
+ m = url.match(/^(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([\w.-]+)\/([\w.-]+)(?:\/src\/([\w./-]+))?/i);
19
+ if (m) return { host: 'bitbucket', owner: m[1], repo: m[2], branch: m[3] || null };
20
+ throw new Error('URL no reconocida. Usa github.com/usuario/repo o bitbucket.org/usuario/repo');
21
+ }
22
+
23
+ // escribe un fichero (texto o binario) en `root`, creando subcarpetas
24
+ async function writeInto(root, path, blob) {
25
+ const parts = path.split('/'); const name = parts.pop();
26
+ let dir = root;
27
+ for (const seg of parts) dir = await dir.getDirectoryHandle(seg, { create: true });
28
+ const fh = await dir.getFileHandle(name, { create: true });
29
+ const w = await fh.createWritable();
30
+ await w.write(blob);
31
+ await w.close();
32
+ }
33
+
34
+ // pool de concurrencia simple: corre `items` a través de `worker`, máx N a la vez
35
+ async function pool(items, n, worker) {
36
+ let i = 0;
37
+ const runners = Array.from({ length: Math.min(n, items.length) }, async () => {
38
+ while (i < items.length) await worker(items[i++]);
39
+ });
40
+ await Promise.all(runners);
41
+ }
42
+
43
+ // ── listar (una sola vez, resultado persistible) ─────────────────────────
44
+ async function listGithubFiles({ owner, repo, branch }) {
45
+ if (!branch) {
46
+ const r = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
47
+ if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
48
+ if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`);
49
+ branch = (await r.json()).default_branch || 'main';
50
+ }
51
+ let tree = null;
52
+ for (const b of [branch, 'main', 'master']) {
53
+ const r = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${b}?recursive=1`);
54
+ if (r.ok) { tree = (await r.json()).tree || []; branch = b; break; }
55
+ if (r.status === 403) throw new Error('GitHub limitó las peticiones (60/h sin login). Reintenta en unos minutos.');
56
+ }
57
+ if (!tree) throw new Error('No pude leer el árbol del repo');
58
+ const blobs = tree.filter(n => n.type === 'blob');
59
+ const files = blobs.filter(n => !n.size || n.size <= MAX_FILE_BYTES).slice(0, MAX_FILES).map(n => ({ path: n.path, size: n.size || 0 }));
60
+ return { branch, files, skipped: blobs.length - files.length };
61
+ }
62
+ // Bitbucket no tiene un único endpoint de árbol recursivo: se camina por directorios.
63
+ async function bitbucketWalk(owner, repo, branch, path, files) {
64
+ if (files.length >= MAX_FILES) return;
65
+ let url = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${path}?pagelen=100`;
66
+ while (url && files.length < MAX_FILES) {
67
+ const r = await fetch(url);
68
+ if (!r.ok) throw new Error(`Bitbucket: HTTP ${r.status} (¿repo privado o rama incorrecta?)`);
69
+ const data = await r.json();
70
+ for (const e of data.values || []) {
71
+ if (files.length >= MAX_FILES) break;
72
+ if (e.type === 'commit_directory') await bitbucketWalk(owner, repo, branch, e.path, files);
73
+ else if (e.type === 'commit_file' && (!e.size || e.size <= MAX_FILE_BYTES)) files.push({ path: e.path, size: e.size || 0 });
74
+ }
75
+ url = data.next || null;
76
+ }
77
+ }
78
+ async function listBitbucketFiles({ owner, repo, branch }) {
79
+ if (!branch) {
80
+ const r = await fetch(`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}`);
81
+ if (!r.ok) throw new Error(`Repo no encontrado o privado (HTTP ${r.status})`);
82
+ branch = (await r.json()).mainbranch?.name || 'main';
83
+ }
84
+ const files = [];
85
+ await bitbucketWalk(owner, repo, branch, '', files);
86
+ return { branch, files, skipped: 0 };
87
+ }
88
+
89
+ // Lista el repo completo UNA vez. Devuelve todo lo necesario para persistir
90
+ // el trabajo: { host, owner, repo, branch, files:[{path,size}], skipped }.
91
+ export async function listRepo(url) {
92
+ const info = parseRepoUrl(url);
93
+ const { branch, files, skipped } = info.host === 'github' ? await listGithubFiles(info) : await listBitbucketFiles(info);
94
+ if (!files.length) throw new Error('El repo está vacío (o no pude listarlo)');
95
+ return { host: info.host, owner: info.owner, repo: info.repo, branch, files, skipped };
96
+ }
97
+
98
+ // Descarga los ficheros de `job.files` que NO estén en `doneSet` (Set de
99
+ // rutas). Llama a onFileDone(path) tras cada escritura para que quien invoque
100
+ // persista el progreso — así una interrupción se retoma sin re-descargar nada.
101
+ export async function downloadFiles(job, targetHandle, doneSet, onProgress, onFileDone) {
102
+ const { host, owner, repo, branch, files } = job;
103
+ const pending = files.filter(f => !doneSet.has(f.path));
104
+ let done = files.length - pending.length;
105
+ onProgress?.({ text: `${done}/${files.length}`, done, total: files.length });
106
+ await pool(pending, CONCURRENCY, async f => {
107
+ onProgress?.({ text: `Descargando ${f.path}…`, done, total: files.length });
108
+ const url = host === 'github'
109
+ ? `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${f.path}`
110
+ : `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/${branch}/${f.path}`;
111
+ const r = await fetch(url);
112
+ if (r.ok) await writeInto(targetHandle, f.path, await r.blob());
113
+ done++;
114
+ onFileDone?.(f.path);
115
+ onProgress?.({ text: `${done}/${files.length}`, done, total: files.length });
116
+ });
117
+ }
118
+
119
+ // Conveniencia: listar + descargar todo de una vez (sin resumibilidad; para
120
+ // quien no necesite persistir progreso entre sesiones).
121
+ export async function cloneToHandle(url, targetHandle, onProgress) {
122
+ const job = await listRepo(url);
123
+ await downloadFiles(job, targetHandle, new Set(), onProgress);
124
+ return { count: job.files.length, skipped: job.skipped };
125
+ }
js/tools/code.js ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Herramientas de proyecto: el único mundo que toca Elffuss Code es la
2
+ // carpeta de código que el usuario abre con el picker nativo.
3
+ import * as db from '../db.js';
4
+
5
+ let projectHandle = null;
6
+ let projectName = '';
7
+ let currentFile = null; // ruta abierta en el editor
8
+ let onFileWritten = () => {}; // el IDE refresca pestañas/árbol
9
+
10
+ export function setOnFileWritten(fn) { onFileWritten = fn; }
11
+ export function setCurrentFile(path) { currentFile = path; }
12
+ export function current() { return { projectName, currentFile }; }
13
+ export function handle() { return projectHandle; }
14
+
15
+ // Lee un archivo de texto dentro de un dir handle por ruta (soporta subdirs).
16
+ async function readIn(dir, path) {
17
+ const parts = path.split('/'); const name = parts.pop();
18
+ for (const p of parts) dir = await dir.getDirectoryHandle(p);
19
+ return (await (await dir.getFileHandle(name)).getFile()).text();
20
+ }
21
+
22
+ // Integración git SIN dependencias: parsea el .git directamente (rama + último
23
+ // commit). Suficiente para orientar; commitear se le pide al agente/terminal.
24
+ export async function gitInfo() {
25
+ if (!projectHandle) return { isRepo: false };
26
+ let git;
27
+ try { git = await projectHandle.getDirectoryHandle('.git'); }
28
+ catch { return { isRepo: false }; }
29
+ const out = { isRepo: true, branch: '(detached)', lastCommit: null };
30
+ try {
31
+ const head = (await readIn(git, 'HEAD')).trim();
32
+ out.branch = head.match(/ref:\s*refs\/heads\/(.+)/)?.[1] || head.slice(0, 7);
33
+ } catch { /* sin HEAD */ }
34
+ try {
35
+ const last = (await readIn(git, 'logs/HEAD')).trim().split('\n').pop();
36
+ const m = last.match(/^\S+ \S+ (.+?) <[^>]*> (\d+)[^\t]*\t(.+)$/);
37
+ if (m) out.lastCommit = { author: m[1], when: new Date(+m[2] * 1000), msg: m[3] };
38
+ } catch { /* sin logs (repo recién creado) */ }
39
+ return out;
40
+ }
41
+
42
+ // Lista plana de archivos (para el command palette). Cacheada por proyecto.
43
+ let fileListCache = null;
44
+ export function invalidateFileList() { fileListCache = null; }
45
+ export async function fileList() {
46
+ if (fileListCache) return fileListCache;
47
+ if (!projectHandle) return [];
48
+ const files = [];
49
+ async function walk(dir, prefix, depth) {
50
+ if (depth > 8 || files.length > 4000) return;
51
+ for await (const e of dir.values()) {
52
+ if (IGNORE.has(e.name)) continue;
53
+ const p = prefix ? prefix + '/' + e.name : e.name;
54
+ if (e.kind === 'directory') await walk(e, p, depth + 1);
55
+ else files.push(p);
56
+ }
57
+ }
58
+ await walk(projectHandle, '', 0);
59
+ fileListCache = files;
60
+ return files;
61
+ }
62
+
63
+ export async function openProject(handle) {
64
+ projectHandle = handle;
65
+ projectName = handle.name || 'proyecto';
66
+ await db.set('kv', 'project', handle).catch(() => {});
67
+ return projectName;
68
+ }
69
+
70
+ // Reabrir el último proyecto (el navegador puede exigir un gesto para re-conceder).
71
+ export async function restoreProject() {
72
+ const h = await db.get('kv', 'project').catch(() => null);
73
+ if (!h) return null;
74
+ const q = h.queryPermission ? await h.queryPermission({ mode: 'readwrite' }) : 'granted';
75
+ if (q === 'granted') {
76
+ projectHandle = h;
77
+ projectName = h.name || 'proyecto';
78
+ return { name: projectName, ready: true };
79
+ }
80
+ return { name: h.name, ready: false, handle: h };
81
+ }
82
+
83
+ export async function regrant(h) {
84
+ if (await h.requestPermission({ mode: 'readwrite' }) !== 'granted')
85
+ throw new Error('permiso denegado');
86
+ return openProject(h);
87
+ }
88
+
89
+ const IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'target', '__pycache__', '.next', 'venv', '.venv', '.DS_Store']);
90
+ const MAX_READ = 60_000;
91
+
92
+ // Normaliza rutas del modelo: quita ./ y / iniciales y segmentos '.'
93
+ const normalize = path => (path || '').split('/').filter(p => p && p !== '.');
94
+
95
+ async function dirOf(path, { create = false } = {}) {
96
+ if (!projectHandle) throw new Error('No hay proyecto abierto');
97
+ const parts = normalize(path);
98
+ const name = parts.pop();
99
+ let dir = projectHandle;
100
+ for (const p of parts) dir = await dir.getDirectoryHandle(p, { create });
101
+ return { dir, name };
102
+ }
103
+
104
+ // Buscar archivos por nombre (para sugerir cuando el modelo inventa rutas, o
105
+ // para resolver un enlace del chat que solo mencionaba el nombre del fichero).
106
+ export async function findByName(basename, limit = 5) {
107
+ const hits = [];
108
+ let visited = 0;
109
+ async function walk(dir, prefix) {
110
+ if (hits.length >= limit || visited > 500) return;
111
+ for await (const e of dir.values()) {
112
+ if (IGNORE.has(e.name)) continue;
113
+ visited++;
114
+ const p = prefix ? prefix + '/' + e.name : e.name;
115
+ if (e.kind === 'directory') await walk(e, p);
116
+ else if (e.name.toLowerCase() === basename.toLowerCase()) hits.push(p);
117
+ if (hits.length >= limit || visited > 500) return;
118
+ }
119
+ }
120
+ await walk(projectHandle, '');
121
+ return hits;
122
+ }
123
+
124
+ // Árbol de texto (para el modelo y el CONTEXTO). Ignora dependencias/binarios.
125
+ export async function tree({ path = '', depth = 3 } = {}) {
126
+ if (!projectHandle) throw new Error('No hay proyecto abierto');
127
+ let root = projectHandle;
128
+ for (const p of normalize(path)) root = await root.getDirectoryHandle(p);
129
+ const out = [];
130
+ let count = 0;
131
+ async function walk(dir, prefix, d) {
132
+ if (d > depth || count > 350) return;
133
+ const entries = [];
134
+ for await (const e of dir.values()) entries.push(e);
135
+ entries.sort((a, b) => (a.kind !== b.kind) ? (a.kind === 'directory' ? -1 : 1) : a.name.localeCompare(b.name));
136
+ for (const e of entries) {
137
+ if (IGNORE.has(e.name)) continue;
138
+ if (++count > 350) { out.push(prefix + '…'); return; }
139
+ out.push(prefix + (e.kind === 'directory' ? '📁 ' : '') + e.name);
140
+ if (e.kind === 'directory') await walk(e, prefix + ' ', d + 1);
141
+ }
142
+ }
143
+ await walk(root, '', 1);
144
+ return out.join('\n') || '(vacío)';
145
+ }
146
+
147
+ const PAGE_SIZE = 100, PAGE_MAX = 500;
148
+
149
+ // Lectura completa (comportamiento de siempre) O por páginas de líneas — para
150
+ // ficheros grandes, o para plantarse justo en el número de línea que dio
151
+ // code.search y traer contexto antes/después sin volcar el fichero entero.
152
+ // offset: línea 1-based donde empezar (con limit, PAGE_SIZE por defecto)
153
+ // around: centra la página en esa línea (offset = around - limit/2)
154
+ // Sin offset/limit/around → fichero completo tal cual (no rompe nada que ya
155
+ // dependa de leerlo entero, p.ej. code.edit).
156
+ export async function read({ path, offset, limit, around } = {}) {
157
+ if (!path) throw new Error('Falta path');
158
+ let file;
159
+ try {
160
+ const { dir, name } = await dirOf(path);
161
+ file = await (await dir.getFileHandle(name)).getFile();
162
+ } catch {
163
+ // error que ENSEÑA: el modelo puede auto-corregirse en el siguiente paso
164
+ const base = normalize(path).pop() || path;
165
+ const hits = await findByName(base).catch(() => []);
166
+ throw new Error(`no existe «${path}» en este proyecto.` +
167
+ (hits.length ? ` ¿Quizá: ${hits.join(' · ')}?` : '') +
168
+ ' Consulta el árbol (code.tree) o busca (code.search) antes de leer.');
169
+ }
170
+ const full = await file.text();
171
+
172
+ if (offset == null && limit == null && around == null) {
173
+ return full.length > MAX_READ ? full.slice(0, MAX_READ) + `\n… (recortado, ${file.size} bytes)` : full;
174
+ }
175
+
176
+ const lines = full.split('\n');
177
+ const total = lines.length;
178
+ const size = Math.max(1, Math.min(Math.round(limit) || PAGE_SIZE, PAGE_MAX));
179
+ const start = around != null
180
+ ? Math.max(1, Math.round(around) - Math.floor(size / 2))
181
+ : Math.max(1, Math.round(offset) || 1);
182
+ const startIdx = start - 1;
183
+ const slice = lines.slice(startIdx, startIdx + size);
184
+ const endLine = startIdx + slice.length;
185
+ const numbered = slice.map((l, i) => `${start + i}→${l}`).join('\n');
186
+ const more = endLine < total ? `\n… quedan líneas ${endLine + 1}-${total} — pide code.read con offset:${endLine + 1} para seguir` : '';
187
+ return `${path}: líneas ${start}-${endLine} de ${total}\n${numbered}${more}`;
188
+ }
189
+
190
+ // Aprobación de escritura: si «Auto» está apagado, el IDE pide confirmación
191
+ // antes de que la elfa toque archivos (como «Edit automatically» del plugin).
192
+ let approveWrite = async () => true;
193
+ export function setWriteApprover(fn) { approveWrite = fn; }
194
+
195
+ export async function write({ path, content = '' } = {}) {
196
+ if (!path) throw new Error('Falta path');
197
+ if (!await approveWrite(path, content)) return `Cambio en ${path} rechazado por el usuario`;
198
+ const { dir, name } = await dirOf(path, { create: true });
199
+ const fh = await dir.getFileHandle(name, { create: true });
200
+ const w = await fh.createWritable();
201
+ await w.write(content);
202
+ await w.close();
203
+ invalidateFileList();
204
+ onFileWritten(path, content);
205
+ return `Escrito ${path} (${content.split('\n').length} líneas)`;
206
+ }
207
+
208
+ // Edición PARCIAL (no hace falta reescribir el fichero entero): sustituye
209
+ // `search` por `replace`. Primero intenta coincidencia EXACTA (rápida, sin
210
+ // ambigüedad); si no la encuentra tal cual —el modelo puede recordar mal un
211
+ // espacio o un salto de línea—, cae a un parcheado difuso (diff-match-patch,
212
+ // la misma librería que usan herramientas como aider) que localiza el punto
213
+ // más parecido dentro del fichero real. Si ni así hay confianza suficiente,
214
+ // falla con un mensaje claro para que el agente reintente con más contexto
215
+ // — nunca escribe una coincidencia dudosa.
216
+ export async function edit({ path, search, replace } = {}) {
217
+ if (!path) throw new Error('Falta path');
218
+ if (search == null || replace == null) throw new Error('Faltan search y replace');
219
+ const current = await read({ path });
220
+
221
+ const first = current.indexOf(search);
222
+ if (first !== -1) {
223
+ if (current.indexOf(search, first + 1) !== -1) {
224
+ throw new Error(`«search» aparece más de una vez en ${path} — añade más líneas de contexto alrededor para que sea inequívoco.`);
225
+ }
226
+ return write({ path, content: current.slice(0, first) + replace + current.slice(first + search.length) });
227
+ }
228
+
229
+ // fallback difuso: el texto no aparece tal cual, prueba con la misma
230
+ // tolerancia con la que un desarrollador reconocería "es este trozo"
231
+ const { diff_match_patch } = await import('https://esm.sh/diff-match-patch@1.0.5');
232
+ const dmp = new diff_match_patch();
233
+ const patches = dmp.patch_make(search, replace);
234
+ const [next, results] = dmp.patch_apply(patches, current);
235
+ if (!results.length || !results.every(Boolean)) {
236
+ throw new Error(`No encontré con suficiente confianza el punto exacto a editar en ${path} (ni exacto ni aproximado). ` +
237
+ `Vuelve a leerlo (code.read) y copia «search» literal de esas líneas, más corto si hace falta.`);
238
+ }
239
+ return write({ path, content: next });
240
+ }
241
+
242
+ // grep-lite por el proyecto (texto, con límites para no arrasar).
243
+ export async function search({ query, ext = '' } = {}) {
244
+ if (!query) throw new Error('Falta query');
245
+ if (!projectHandle) throw new Error('No hay proyecto abierto');
246
+ const results = [];
247
+ let checked = 0;
248
+ const q = query.toLowerCase();
249
+ async function walk(dir, prefix) {
250
+ if (results.length > 80 || checked > 400) return;
251
+ for await (const e of dir.values()) {
252
+ if (IGNORE.has(e.name)) continue;
253
+ const p = prefix ? prefix + '/' + e.name : e.name;
254
+ if (e.kind === 'directory') { await walk(e, p); continue; }
255
+ if (ext && !e.name.endsWith(ext)) continue;
256
+ const f = await e.getFile();
257
+ if (f.size > 200_000) continue;
258
+ checked++;
259
+ const lines = (await f.text()).split('\n');
260
+ lines.forEach((l, i) => {
261
+ if (results.length <= 80 && l.toLowerCase().includes(q))
262
+ results.push(`${p}:${i + 1}: ${l.trim().slice(0, 140)}`);
263
+ });
264
+ if (results.length > 80 || checked > 400) return;
265
+ }
266
+ }
267
+ await walk(projectHandle, '');
268
+ return results.length ? results.join('\n') : `Sin resultados para «${query}»`;
269
+ }
js/tools/index.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Registro de herramientas de Elffuss Code: proyecto abierto + shell + web.
2
+ import * as code from './code.js';
3
+ import * as shell from '../shell.js';
4
+ import * as web from './web.js';
5
+
6
+ export { code };
7
+
8
+ // El terminal (UI) se engancha aquí para reflejar lo que ejecuta la elfa.
9
+ let onTerminalEcho = () => {};
10
+ export function setTerminalEcho(fn) { onTerminalEcho = fn; }
11
+
12
+ export const TOOLS = {
13
+ 'code.tree': { desc: 'Ver el árbol de archivos del proyecto', params: { path: 'subcarpeta (opcional)', depth: 'niveles (3)' }, run: a => code.tree(a) },
14
+ 'code.read': { desc: 'Leer un archivo del proyecto. Sin más parámetros, lo lee ENTERO. Para ficheros grandes, pagina de 100 en 100 líneas con offset (offset:101 = siguiente página tras la primera). Para ir directo a donde dio un resultado de code.search (que ya incluye número de línea), usa around:N — trae ~100 líneas centradas en la línea N, con cada línea numerada (N→contenido) para poder citarla o editarla con precisión', params: { path: 'ruta relativa', offset: 'línea 1-based donde empezar (opcional)', limit: 'líneas a devolver, 100 por defecto (opcional)', around: 'centra la página en esta línea, p.ej. la que dio code.search (opcional)' }, run: a => code.read(a) },
15
+ 'code.write': { desc: 'Escribir/crear un archivo (contenido COMPLETO; se refleja al instante en el editor). Para cambios PEQUEÑOS en un fichero YA existente, usa mejor code.edit', params: { path: 'ruta', content: 'contenido íntegro' }, run: a => code.write(a) },
16
+ 'code.edit': { desc: 'Editar PARTE de un fichero existente sin reescribirlo entero: sustituye "search" (copia literal de unas pocas líneas, con contexto suficiente para ser única en el fichero) por "replace". Preferible a code.write para cambios pequeños/medianos — más rápido y sin riesgo de perder el resto del fichero. Si falla (no encuentra el punto con confianza), relee el fichero y reintenta con un search más ajustado', params: { path: 'ruta', search: 'texto exacto a sustituir', replace: 'texto nuevo' }, run: a => code.edit(a) },
17
+ 'code.search': { desc: 'Buscar texto en el proyecto (grep)', params: { query: 'texto', ext: 'filtro extensión (opcional)' }, run: a => code.search(a) },
18
+ 'terminal.run': { desc: 'Ejecutar un comando de shell sobre los ficheros del proyecto (ls, cat, grep, find, mkdir, echo>fichero, git status…). node/npm/python REALES si el usuario tiene un Bridge local conectado (⚙ Ajustes → 🔌 Bridge local); si no está conectado, el propio resultado lo indica', params: { command: 'la línea de comando' }, run: async a => { const out = await shell.runForAgent(a.command || ''); onTerminalEcho(a.command || '', out); return out; } },
19
+ 'web.search': { desc: 'Buscar en internet (docs, errores, APIs) — devuelve títulos, URLs y fragmentos', params: { query: 'qué buscar' }, run: a => web.search(a) },
20
+ 'web.fetch': { desc: 'Leer el contenido de texto de una URL (documentación, referencia)', params: { url: 'https://…' }, run: a => web.fetchUrl(a) },
21
+ };
22
+
23
+ export function toolHelp() {
24
+ return Object.entries(TOOLS).map(([n, t]) =>
25
+ `- ${n}(${Object.keys(t.params).join(', ')}): ${t.desc}`).join('\n');
26
+ }
27
+
28
+ export async function runTool(name, args) {
29
+ const tool = TOOLS[name];
30
+ if (!tool) throw new Error(`Herramienta desconocida: ${name}`);
31
+ return tool.run(args || {});
32
+ }
33
+
34
+ // CONTEXTO AHORA del IDE: proyecto, archivo abierto y árbol resumido.
35
+ export async function snapshot() {
36
+ const { projectName, currentFile } = code.current();
37
+ const parts = [
38
+ 'Fecha y hora: ' + new Date().toLocaleString(),
39
+ 'Proyecto abierto: ' + (projectName || 'ninguno'),
40
+ 'Archivo abierto en el editor: ' + (currentFile || 'ninguno'),
41
+ ];
42
+ try { parts.push('Árbol del proyecto (resumen):\n' + await code.tree({ depth: 2 })); }
43
+ catch { /* aún sin proyecto */ }
44
+ // GROUNDING: incluye el contenido REAL de los archivos clave (README, config)
45
+ // para que el modelo NO alucine sobre el proyecto — el heal no domina las
46
+ // herramientas code.* así que le damos el material masticado.
47
+ for (const key of ['README.md', 'readme.md', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']) {
48
+ try { const body = await code.read({ path: key }); parts.push(`Contenido REAL de ${key}:\n${body.slice(0, 1400)}`); break; } catch { /* siguiente */ }
49
+ }
50
+ if (currentFile) {
51
+ try {
52
+ const body = await code.read({ path: currentFile });
53
+ parts.push(`Contenido REAL del archivo abierto ${currentFile}:\n` + body.slice(0, 2000));
54
+ } catch { /* borrado */ }
55
+ }
56
+ return parts.join('\n');
57
+ }
js/tools/web.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Búsqueda y lectura web para la elfa de Code (docs, errores, APIs). fetch
2
+ // directo si el sitio permite CORS; si no, cae al proxy del servidor
3
+ // (/proxy?url=… → mismo backend que Elffuss Claw). Sin gate de permisos: un
4
+ // agente de código necesita consultar internet de serie.
5
+ const MAX = 8000;
6
+
7
+ function toText(html) {
8
+ const doc = new DOMParser().parseFromString(html, 'text/html');
9
+ doc.querySelectorAll('script,style,noscript,svg,iframe').forEach(n => n.remove());
10
+ return ((doc.body?.innerText || doc.body?.textContent || '').replace(/\n{3,}/g, '\n\n').trim()) || html.slice(0, MAX);
11
+ }
12
+
13
+ async function proxyGet(url) {
14
+ let lastErr;
15
+ for (const target of [url, '/proxy?url=' + encodeURIComponent(url)]) {
16
+ try { const r = await fetch(target); if (!r.ok) throw new Error('HTTP ' + r.status); return await r.text(); }
17
+ catch (e) { lastErr = e; }
18
+ }
19
+ throw lastErr || new Error('sin respuesta');
20
+ }
21
+
22
+ export async function fetchUrl({ url } = {}) {
23
+ if (!/^https?:\/\//.test(url || '')) throw new Error('URL inválida (usa https://…)');
24
+ const text = toText(await proxyGet(url));
25
+ return `[${url}]\n` + (text.length > MAX ? text.slice(0, MAX) + '\n… (recortado)' : text);
26
+ }
27
+
28
+ // Búsqueda REAL en DuckDuckGo. Parseo tolerante: cada resultado es un enlace
29
+ // con ?uddg=<url real>. Independiente de clases (robusto a cambios de HTML).
30
+ export async function search({ query } = {}) {
31
+ if (!query) throw new Error('Falta query');
32
+ let html = '';
33
+ for (const ep of ['https://html.duckduckgo.com/html/?q=', 'https://lite.duckduckgo.com/lite/?q=']) {
34
+ try { html = await proxyGet(ep + encodeURIComponent(query)); if (html.includes('uddg=') || html.includes('result')) break; }
35
+ catch { /* siguiente endpoint */ }
36
+ }
37
+ const doc = new DOMParser().parseFromString(html, 'text/html');
38
+ const seen = new Set(), out = [];
39
+ for (const a of doc.querySelectorAll('a[href*="uddg="]')) {
40
+ const m = a.getAttribute('href').match(/uddg=([^&]+)/);
41
+ if (!m) continue;
42
+ const href = decodeURIComponent(m[1]);
43
+ const title = a.textContent.trim();
44
+ if (!title || title.length < 3 || seen.has(href)) continue;
45
+ seen.add(href);
46
+ const block = a.closest('tr, div, article') || a.parentElement;
47
+ const snip = (block?.textContent || '').replace(title, '').replace(/\s+/g, ' ').trim().slice(0, 160);
48
+ out.push(`• ${title}\n ${href}${snip ? '\n ' + snip : ''}`);
49
+ if (out.length >= 8) break;
50
+ }
51
+ return out.length ? `Resultados para «${query}»:\n\n` + out.join('\n\n') : `Sin resultados para «${query}».`;
52
+ }
js/vcc/NOTICE.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Vendorizado: VibeCodeViewer
2
+
3
+ `model.js` y `builder.js` son copia VERBATIM de
4
+ [Sendery/VibeCodeViewer](https://github.com/Sendery/VibeCodeViewer) (licencia
5
+ **Apache-2.0**), el motor de la ciudad 3D. No se altera su lógica; solo se les
6
+ alimenta el árbol del proyecto abierto (adaptador en `../city.js`) y `three` se
7
+ resuelve por el import map de `index.html`. `ownership.js` es un stub mínimo
8
+ (no usamos metadatos de propiedad) y `theme.js` es el tema «hacker» original.
js/vcc/builder.js ADDED
@@ -0,0 +1,668 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Construcción de la escena: ciudad de cristal nítida.
2
+ // Receta anti-difuminado: aristas finas con colores HDR (el bloom solo se
3
+ // dispara en lo resaltado), cristal con fresnel de opacidad mínima y nada
4
+ // de volúmenes aditivos permanentes.
5
+ // Toda la paleta viene del tema activo (src/themes.js); los temas claros
6
+ // usan blending normal en lugar de aditivo.
7
+ import * as THREE from 'three';
8
+
9
+ function lerp3(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
10
+ function configureCanvasTexture(tx) {
11
+ tx.colorSpace = THREE.SRGBColorSpace;
12
+ tx.anisotropy = 8;
13
+ // Las texturas son texto fino sobre fondo transparente. Sin mipmaps, los
14
+ // píxeles transparentes no se mezclan con negro al orbitar y solaparse.
15
+ tx.generateMipmaps = false;
16
+ tx.minFilter = THREE.LinearFilter;
17
+ tx.magFilter = THREE.LinearFilter;
18
+ return tx;
19
+ }
20
+
21
+ // 12 aristas de una caja → 24 vértices
22
+ const EDGE_PAIRS = [
23
+ [0, 1], [1, 3], [3, 2], [2, 0],
24
+ [4, 5], [5, 7], [7, 6], [6, 4],
25
+ [0, 4], [1, 5], [2, 6], [3, 7],
26
+ ];
27
+ function corners(b) {
28
+ const x0 = b.cx - b.sx / 2, x1 = b.cx + b.sx / 2;
29
+ const y0 = b.cy - b.sy / 2, y1 = b.cy + b.sy / 2;
30
+ const z0 = b.cz - b.sz / 2, z1 = b.cz + b.sz / 2;
31
+ return [
32
+ [x0, y0, z0], [x1, y0, z0], [x0, y0, z1], [x1, y0, z1],
33
+ [x0, y1, z0], [x1, y1, z0], [x0, y1, z1], [x1, y1, z1],
34
+ ];
35
+ }
36
+
37
+ // ── suelo: placa de circuito impreso ──────────────────────────────────────
38
+ function circuitTexture(theme) {
39
+ const T = theme.ground;
40
+ const S = 1024;
41
+ const cv = document.createElement('canvas');
42
+ cv.width = cv.height = S;
43
+ const g = cv.getContext('2d');
44
+ g.fillStyle = T.bg;
45
+ g.fillRect(0, 0, S, S);
46
+ g.strokeStyle = T.grid;
47
+ g.lineWidth = 1;
48
+ for (let i = 0; i <= S; i += 64) {
49
+ g.beginPath(); g.moveTo(i, 0); g.lineTo(i, S); g.stroke();
50
+ g.beginPath(); g.moveTo(0, i); g.lineTo(S, i); g.stroke();
51
+ }
52
+ const rnd = (() => { let s = 1234567; return () => (s = (s * 16807) % 2147483647) / 2147483647; })();
53
+ for (let i = 0; i < 260; i++) {
54
+ const isA = rnd() < 0.58;
55
+ const a = 0.18 + rnd() * 0.5;
56
+ const [tr, tg, tb] = isA ? T.traceA : T.traceB;
57
+ g.strokeStyle = `rgba(${tr}, ${tg}, ${tb}, ${isA ? a : a * 0.9})`;
58
+ g.lineWidth = rnd() < 0.12 ? 3 : 1.4;
59
+ let x = rnd() * S, y = rnd() * S;
60
+ g.beginPath(); g.moveTo(x, y);
61
+ const segs = 2 + Math.floor(rnd() * 4);
62
+ for (let sg = 0; sg < segs; sg++) {
63
+ const len = 24 + rnd() * 150;
64
+ if (rnd() < 0.5) x += rnd() < 0.5 ? len : -len; else y += rnd() < 0.5 ? len : -len;
65
+ g.lineTo(x, y);
66
+ }
67
+ g.stroke();
68
+ const [pr, pg, pb] = isA ? T.padA : T.padB;
69
+ g.fillStyle = `rgba(${pr}, ${pg}, ${pb}, ${a + 0.2})`;
70
+ const ps = 2 + rnd() * 3;
71
+ g.fillRect(x - ps / 2, y - ps / 2, ps, ps);
72
+ }
73
+ const tx = new THREE.CanvasTexture(cv);
74
+ tx.wrapS = tx.wrapT = THREE.RepeatWrapping;
75
+ tx.colorSpace = THREE.SRGBColorSpace;
76
+ tx.anisotropy = 8;
77
+ return tx;
78
+ }
79
+
80
+ // ── cristal con fresnel (bordes algo más vivos, centro casi invisible) ────
81
+ function glassMaterial(theme, dim = 1, solid = 0) {
82
+ return new THREE.ShaderMaterial({
83
+ uniforms: {
84
+ uColor: { value: new THREE.Color(...theme.glass) },
85
+ uHi: { value: new THREE.Color(...theme.glassHi) },
86
+ uGlow: { value: 0 },
87
+ uDim: { value: dim },
88
+ uSolid: { value: solid },
89
+ },
90
+ vertexShader: /* glsl */`
91
+ varying vec3 vN, vV;
92
+ void main() {
93
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
94
+ vN = normalMatrix * normal;
95
+ vV = -mv.xyz;
96
+ gl_Position = projectionMatrix * mv;
97
+ }`,
98
+ fragmentShader: /* glsl */`
99
+ uniform vec3 uColor;
100
+ uniform vec3 uHi;
101
+ uniform float uGlow;
102
+ uniform float uDim;
103
+ uniform float uSolid;
104
+ varying vec3 vN, vV;
105
+ void main() {
106
+ float fr = pow(1.0 - abs(dot(normalize(vN), normalize(vV))), 2.6);
107
+ vec3 col = mix(uColor, uHi, uGlow);
108
+ float alpha = (0.022 + fr * 0.12 + uSolid * 0.12) * uDim + uGlow * 0.05;
109
+ gl_FragColor = vec4(col * ((0.5 + fr * 1.9) * uDim + uGlow), alpha);
110
+ }`,
111
+ transparent: true,
112
+ depthWrite: false,
113
+ side: THREE.DoubleSide,
114
+ });
115
+ }
116
+
117
+ // ── fachada: árbol de ficheros impreso sobre el cristal ───────────────────
118
+ const FACADE_MAX_LINES = 150;
119
+
120
+ function facadeLines(building) {
121
+ const lines = [];
122
+ let overflow = false;
123
+ (function walk(node, depth) {
124
+ if (lines.length > FACADE_MAX_LINES) { overflow = true; return; }
125
+ lines.push({ text: node.name + '/', depth, key: 'n' + node.id });
126
+ for (const c of node.children) walk(c, depth + 1);
127
+ for (const fid of node.fileIds) {
128
+ if (lines.length > FACADE_MAX_LINES) { overflow = true; return; }
129
+ const f = building.modelFiles[fid];
130
+ lines.push({ text: f.name, depth: depth + 1, key: 'f' + f.id });
131
+ }
132
+ })(building, 0);
133
+ if (overflow) lines.push({ text: '· · ·', depth: 1, key: '' });
134
+ return lines;
135
+ }
136
+
137
+ function drawFacade(building, highlight, theme) {
138
+ const cv = building.facadeCanvas;
139
+ if (!cv) return; // edificio sin fachada (recorte en ciudades enormes)
140
+ const g = cv.getContext('2d');
141
+ const W = cv.width, H = cv.height;
142
+ g.clearRect(0, 0, W, H);
143
+ const lines = building.facadeLineCache;
144
+ const fs = Math.max(9, Math.min(30, (H - 60) / (lines.length + 1)));
145
+ g.font = `${fs}px "JetBrains Mono", "Cascadia Code", monospace`;
146
+ g.textBaseline = 'top';
147
+ let y = 26;
148
+ for (const ln of lines) {
149
+ const x = 22 + ln.depth * fs * 1.1;
150
+ const hot = highlight && highlight.has(ln.key);
151
+ if (hot) {
152
+ g.shadowColor = theme.facade.hotGlow;
153
+ g.shadowBlur = 14;
154
+ g.fillStyle = theme.facade.hotBg;
155
+ g.fillRect(x - 6, y - 3, g.measureText(ln.text).width + 12, fs + 6);
156
+ g.fillStyle = theme.facade.hot;
157
+ } else {
158
+ g.shadowBlur = 0;
159
+ g.fillStyle = theme.facade.text;
160
+ }
161
+ g.fillText(ln.text, x, y);
162
+ y += fs * 1.18;
163
+ if (y > H - fs) break;
164
+ }
165
+ g.shadowBlur = 0;
166
+ building.facadeTexture.needsUpdate = true;
167
+ }
168
+
169
+ function buildFacade(building, model, group, theme) {
170
+ building.modelFiles = model.files;
171
+ building.facadeLineCache = facadeLines(building);
172
+ const b = building.box;
173
+ const H = 1024;
174
+ const W = Math.round(Math.min(2048, Math.max(256, H * (b.sx / b.sy))));
175
+ const cv = document.createElement('canvas');
176
+ cv.width = W; cv.height = H;
177
+ building.facadeCanvas = cv;
178
+ const tx = configureCanvasTexture(new THREE.CanvasTexture(cv));
179
+ building.facadeTexture = tx;
180
+ drawFacade(building, null, theme);
181
+ const mat = new THREE.MeshBasicMaterial({
182
+ map: tx, transparent: true, alphaTest: 0.025, depthWrite: false, side: THREE.FrontSide,
183
+ blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending,
184
+ });
185
+ mat.userData.vccKind = 'facade';
186
+ const E = 0.14;
187
+ const faces = [
188
+ { w: b.sx, pos: [b.cx, b.cy, b.cz + b.sz / 2 + E], rot: 0 },
189
+ { w: b.sx, pos: [b.cx, b.cy, b.cz - b.sz / 2 - E], rot: Math.PI },
190
+ { w: b.sz, pos: [b.cx + b.sx / 2 + E, b.cy, b.cz], rot: Math.PI / 2 },
191
+ { w: b.sz, pos: [b.cx - b.sx / 2 - E, b.cy, b.cz], rot: -Math.PI / 2 },
192
+ ];
193
+ for (const f of faces) {
194
+ const plane = new THREE.Mesh(new THREE.PlaneGeometry(f.w, b.sy), mat);
195
+ plane.position.set(...f.pos);
196
+ plane.rotation.y = f.rot;
197
+ plane.renderOrder = 3;
198
+ group.add(plane);
199
+ }
200
+ }
201
+
202
+ // ── leyenda: nombres serigrafiados (suelo de distrito y tejado) ───────────
203
+ function labelTexture(text, color) {
204
+ const fs = 96, pad = 40;
205
+ const probe = document.createElement('canvas').getContext('2d');
206
+ probe.font = `bold ${fs}px "JetBrains Mono", monospace`;
207
+ const tw = probe.measureText(text).width;
208
+ const cv = document.createElement('canvas');
209
+ cv.width = Math.ceil(tw + pad * 2 + 220);
210
+ cv.height = 190;
211
+ const g = cv.getContext('2d');
212
+ g.font = `bold ${fs}px "JetBrains Mono", monospace`;
213
+ g.textBaseline = 'middle';
214
+ // pista de circuito: línea con pads a ambos lados del texto
215
+ g.strokeStyle = color; g.globalAlpha = 0.65; g.lineWidth = 5;
216
+ const y = cv.height / 2;
217
+ g.beginPath(); g.moveTo(14, y); g.lineTo(pad + 60, y); g.stroke();
218
+ g.beginPath(); g.moveTo(pad + 100 + tw, y); g.lineTo(cv.width - 14, y); g.stroke();
219
+ g.fillStyle = color;
220
+ for (const px of [14, pad + 60, pad + 100 + tw, cv.width - 26]) g.fillRect(px - 7, y - 7, 14, 14);
221
+ g.globalAlpha = 0.92;
222
+ g.shadowColor = color; g.shadowBlur = 16;
223
+ g.fillText(text, pad + 80, y);
224
+ const tx = configureCanvasTexture(new THREE.CanvasTexture(cv));
225
+ return { tx, aspect: cv.width / cv.height };
226
+ }
227
+
228
+ const LEGEND_MAX_BUILDINGS = 400;
229
+
230
+ function buildLegend(model, labeled, theme) {
231
+ const legend = new THREE.Group();
232
+ const add = (text, color, width, pos, flat) => {
233
+ const { tx, aspect } = labelTexture(text, color);
234
+ const w = Math.min(width, aspect * 8);
235
+ const mesh = new THREE.Mesh(
236
+ new THREE.PlaneGeometry(w, w / aspect),
237
+ new THREE.MeshBasicMaterial({
238
+ map: tx, transparent: true, alphaTest: 0.025, depthWrite: false,
239
+ blending: theme.light ? THREE.NormalBlending : THREE.AdditiveBlending,
240
+ }),
241
+ );
242
+ mesh.material.userData.vccKind = 'label';
243
+ if (flat) mesh.rotation.x = -Math.PI / 2;
244
+ mesh.position.set(...pos);
245
+ mesh.renderOrder = 4;
246
+ legend.add(mesh);
247
+ };
248
+ for (const di of model.districts) {
249
+ add(di.name, theme.labels.district, di.box.sx * 0.85,
250
+ [di.box.cx, 0.3, di.box.cz + di.box.sz / 2 - 2], true);
251
+ }
252
+ // urbanizaciones: mismo serigrafiado de suelo que los distritos, en tono
253
+ // intermedio; en monorepos se etiquetan las de mayor superficie
254
+ const urbs = [...model.urbs].sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz);
255
+ for (const u of urbs.slice(0, LEGEND_MAX_BUILDINGS)) {
256
+ add(u.name, theme.labels.urb, u.box.sx * 0.8,
257
+ [u.box.cx, 0.32, u.box.cz + u.box.sz / 2 - 1.4], true);
258
+ }
259
+ for (const b of labeled.slice(0, LEGEND_MAX_BUILDINGS)) {
260
+ if (b.isBasement) continue; // su tejado es la calle: sin etiqueta
261
+ add(b.name, theme.labels.building, b.box.sx * 0.95,
262
+ [b.box.cx, b.box.cy + b.box.sy / 2 + 0.12, b.box.cz], true);
263
+ }
264
+ return legend;
265
+ }
266
+
267
+ // ── ciudad completa ───────────────────────────────────────────────────────
268
+ export function buildCity(scene, model, theme) {
269
+ const group = new THREE.Group();
270
+ scene.add(group);
271
+ // paleta del tema; en temas claros el blending aditivo no funciona
272
+ const ROLE_COLOR = theme.roleColor;
273
+ const HI = theme.hi;
274
+ const FILE_BASE = theme.fileBase;
275
+ const FILE_HI = theme.fileHi;
276
+ const BLEND = theme.light ? THREE.NormalBlending : THREE.AdditiveBlending;
277
+
278
+ // suelo
279
+ const extent = Math.max(model.city.box.sx, model.city.box.sz);
280
+ const ground = new THREE.Mesh(
281
+ new THREE.PlaneGeometry(extent * 7, extent * 7),
282
+ // semitransparente y a doble cara: los sótanos (ficheros ignorados)
283
+ // se intuyen a través del plano de circuitos. depthWrite: false — era el
284
+ // único material que escribía profundidad y, con el rango near/far de la
285
+ // ciudad, las plotas y serigrafías a ras de suelo hacían z-fighting
286
+ // (parpadeo en franjas) al mover la cámara
287
+ new THREE.MeshBasicMaterial({
288
+ map: circuitTexture(theme), transparent: true, opacity: 0.8, side: THREE.DoubleSide,
289
+ depthWrite: false,
290
+ // separa el suelo en el z-buffer de plotas y serigrafías a ras de
291
+ // suelo (evita el z-fighting cuando el modo opaco activa depthWrite)
292
+ polygonOffset: true, polygonOffsetFactor: 2, polygonOffsetUnits: 2,
293
+ }),
294
+ );
295
+ ground.material.map.repeat.set(7, 7);
296
+ ground.rotation.x = -Math.PI / 2;
297
+ ground.position.y = -0.05;
298
+ group.add(ground);
299
+
300
+ // aristas de las cajas en un único buffer con rangos por nodo.
301
+ // LOD por presupuesto para monorepos: decenas de miles de wireframes
302
+ // aditivos se acumulan en blanco. Distritos y edificios siempre dibujan;
303
+ // del interior se conservan las cajas más grandes y menos profundas.
304
+ const LINE_BUDGET = 12000;
305
+ const ROLE_DEPTH = { distrito: 0, urbanizacion: 0, edificio: 0, planta: 1, apartamento: 2, habitacion: 3, archivador: 4, cajon: 5 };
306
+ const candidates = model.nodes.filter(n => n.role !== 'ciudad' && n.box);
307
+ let boxNodes = candidates;
308
+ if (candidates.length > LINE_BUDGET) {
309
+ const score = (n) => (n.role === 'distrito' || n.role === 'urbanizacion' || n.role === 'edificio')
310
+ ? Infinity
311
+ : Math.max(n.box.sx, n.box.sy, n.box.sz) - ROLE_DEPTH[n.role] * 3;
312
+ boxNodes = candidates.sort((a, b) => score(b) - score(a)).slice(0, LINE_BUDGET);
313
+ }
314
+ const positions = new Float32Array(boxNodes.length * 24 * 3);
315
+ const colors = new Float32Array(boxNodes.length * 24 * 3);
316
+ // brillo de arista normalizado por edificio: el interior de un edificio
317
+ // reparte un presupuesto de luz constante (mismo principio que las celdas)
318
+ const boxesPerBuilding = new Map();
319
+ for (const n of boxNodes) {
320
+ if (n.building && n.building !== n) {
321
+ boxesPerBuilding.set(n.building.id, (boxesPerBuilding.get(n.building.id) ?? 0) + 1);
322
+ }
323
+ }
324
+ // …y una atenuación global suave en ciudades con miles de edificios:
325
+ // uniforme (todos los edificios conservan el mismo aspecto entre sí),
326
+ // solo evita que la acumulación aditiva a vista de pájaro se queme
327
+ const cityDim = Math.min(1, Math.max(0.45, Math.sqrt(900 / Math.max(model.buildings.length, 1))));
328
+ for (const n of boxNodes) {
329
+ const local = (!n.building || n.building === n)
330
+ ? 1
331
+ : Math.min(1, Math.max(0.12, 30 / (boxesPerBuilding.get(n.building.id) ?? 1)));
332
+ // las plotas (distrito y urbanización) son solo perímetro: siempre nítidas
333
+ n.lineDim = (n.role === 'distrito' || n.role === 'urbanizacion') ? 1 : local * cityDim;
334
+ }
335
+
336
+ let v = 0;
337
+ for (const node of boxNodes) {
338
+ node.lineStart = v;
339
+ const cs = corners(node.box);
340
+ const base = (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon).map(c => c * node.lineDim);
341
+ node.baseColor = base;
342
+ for (const [a, b] of EDGE_PAIRS) {
343
+ for (const p of [cs[a], cs[b]]) {
344
+ positions[v * 3] = p[0]; positions[v * 3 + 1] = p[1]; positions[v * 3 + 2] = p[2];
345
+ colors[v * 3] = base[0]; colors[v * 3 + 1] = base[1]; colors[v * 3 + 2] = base[2];
346
+ v++;
347
+ }
348
+ }
349
+ }
350
+ const lineGeo = new THREE.BufferGeometry();
351
+ lineGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
352
+ lineGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
353
+ lineGeo.computeBoundingSphere();
354
+ // opacidad adaptativa: cuantas más cajas, más tenue cada arista
355
+ const lineOpacity = Math.min(0.95, Math.max(0.4, Math.sqrt(4000 / Math.max(boxNodes.length, 1))));
356
+ const lineBaseOpacity = lineOpacity;
357
+ const lines = new THREE.LineSegments(lineGeo, new THREE.LineBasicMaterial({
358
+ vertexColors: true, transparent: true, opacity: lineOpacity,
359
+ blending: BLEND, depthWrite: false,
360
+ }));
361
+ lines.renderOrder = 2;
362
+ group.add(lines);
363
+
364
+ // carcasas de cristal por edificio (también sirven para el raycast).
365
+ // En ciudades enormes las fachadas con texto se reservan a los edificios
366
+ // más grandes (una textura canvas por edificio no escala a miles).
367
+ const byArea = [...model.buildings].sort(
368
+ (a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz);
369
+ const facadeSet = new Set(byArea.slice(0, 250));
370
+ const shells = [];
371
+ for (const b of model.buildings) {
372
+ const mat = glassMaterial(theme, cityDim, b.onlyBasement ? 1 : 0);
373
+ mat.userData.baseDim = cityDim;
374
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(b.box.sx, b.box.sy, b.box.sz), mat);
375
+ mesh.position.set(b.box.cx, b.box.cy, b.box.cz);
376
+ mesh.renderOrder = 1;
377
+ mesh.userData.building = b;
378
+ b.shell = mesh;
379
+ shells.push(mesh);
380
+ group.add(mesh);
381
+ if (facadeSet.has(b)) buildFacade(b, model, group, theme);
382
+ }
383
+
384
+ // forjados de las plantas (losa fina, casi invisible); con tope en
385
+ // monorepos, priorizando las plantas de mayor superficie
386
+ let slabNodes = model.nodes.filter(n => n.role === 'planta');
387
+ if (slabNodes.length > 4000) {
388
+ slabNodes = slabNodes
389
+ .sort((a, b) => b.box.sx * b.box.sz - a.box.sx * a.box.sz)
390
+ .slice(0, 4000);
391
+ }
392
+ if (slabNodes.length) {
393
+ const slabGeo = new THREE.BoxGeometry(1, 1, 1);
394
+ const slabMat = new THREE.MeshBasicMaterial({
395
+ color: new THREE.Color(...theme.slab), transparent: true,
396
+ opacity: Math.min(0.045, 0.045 * Math.sqrt(1500 / slabNodes.length)),
397
+ depthWrite: false, blending: BLEND,
398
+ });
399
+ const slabs = new THREE.InstancedMesh(slabGeo, slabMat, slabNodes.length);
400
+ const m = new THREE.Matrix4();
401
+ slabNodes.forEach((n, i) => {
402
+ m.makeScale(n.box.sx, 0.12, n.box.sz);
403
+ m.setPosition(n.box.cx, n.box.cy - n.box.sy / 2, n.box.cz);
404
+ slabs.setMatrixAt(i, m);
405
+ });
406
+ slabs.computeBoundingSphere();
407
+ slabs.renderOrder = 1;
408
+ group.add(slabs);
409
+ }
410
+
411
+ // celdas de fichero (instanciadas)
412
+ const fileGeo = new THREE.BoxGeometry(1, 1, 1);
413
+ const fileMat = new THREE.MeshBasicMaterial({
414
+ transparent: true, opacity: 0.9, depthWrite: false, blending: BLEND,
415
+ });
416
+ const fileBaseOpacity = fileMat.opacity;
417
+ const filesMesh = new THREE.InstancedMesh(fileGeo, fileMat, model.files.length);
418
+ {
419
+ const m = new THREE.Matrix4();
420
+ const c = new THREE.Color();
421
+ for (const f of model.files) {
422
+ m.makeScale(f.box.sx, f.box.sy, f.box.sz);
423
+ m.setPosition(f.box.cx, f.box.cy, f.box.cz);
424
+ filesMesh.setMatrixAt(f.id, m);
425
+ filesMesh.setColorAt(f.id, c.setRGB(...FILE_BASE));
426
+ }
427
+ filesMesh.instanceColor.needsUpdate = true;
428
+ filesMesh.computeBoundingSphere();
429
+ }
430
+ filesMesh.renderOrder = 2;
431
+ group.add(filesMesh);
432
+
433
+ // cajas de relleno para los niveles encendidos (pool reutilizable)
434
+ const fillPool = [];
435
+ for (let i = 0; i < 10; i++) {
436
+ const mesh = new THREE.Mesh(
437
+ new THREE.BoxGeometry(1, 1, 1),
438
+ new THREE.MeshBasicMaterial({
439
+ color: new THREE.Color(...theme.fill), transparent: true, opacity: 0,
440
+ depthWrite: false, blending: BLEND,
441
+ }),
442
+ );
443
+ mesh.visible = false;
444
+ mesh.renderOrder = 2;
445
+ group.add(mesh);
446
+ fillPool.push(mesh);
447
+ }
448
+
449
+ // ── API de iluminación ──────────────────────────────────────────────
450
+ const touchedNodes = new Set();
451
+ const touchedFiles = new Set();
452
+ const colorAttr = lineGeo.getAttribute('color');
453
+ const tmpColor = new THREE.Color();
454
+ // atenuación normalizada por edificio: cada edificio reparte un presupuesto
455
+ // de luz constante entre sus celdas — así un rascacielos con 50k ficheros
456
+ // no se quema y uno con 20 sigue viéndose igual que antes
457
+ const filesPerBuilding = new Map();
458
+ for (const f of model.files) {
459
+ const b = f.node.building;
460
+ if (b) filesPerBuilding.set(b.id, (filesPerBuilding.get(b.id) ?? 0) + 1);
461
+ }
462
+ const dimOf = (f) => {
463
+ const n = filesPerBuilding.get(f.node.building?.id) ?? 1;
464
+ return Math.min(1, Math.max(0.05, 350 / n)) * cityDim;
465
+ };
466
+ // color base por fichero, reescribible por los modos de coloreado
467
+ const fileBase = model.files.map(f => FILE_BASE.map(c => c * dimOf(f)));
468
+
469
+ function paintNode(node, glow, hi = HI) {
470
+ // los nodos diminutos no tienen aristas (LOD): solo carcasa/relleno
471
+ if (node.lineStart !== undefined) {
472
+ const col = lerp3(node.baseColor, hi, Math.pow(glow, 1.35));
473
+ for (let i = node.lineStart; i < node.lineStart + 24; i++) {
474
+ colorAttr.array[i * 3] = col[0];
475
+ colorAttr.array[i * 3 + 1] = col[1];
476
+ colorAttr.array[i * 3 + 2] = col[2];
477
+ }
478
+ // subida parcial: en monorepos el buffer completo pesa decenas de MB
479
+ colorAttr.addUpdateRange(node.lineStart * 3, 72);
480
+ colorAttr.needsUpdate = true;
481
+ }
482
+ if (node.shell) node.shell.material.uniforms.uGlow.value = glow * 0.3;
483
+ if (glow > 0.01) touchedNodes.add(node); else touchedNodes.delete(node);
484
+ }
485
+
486
+ function paintFile(fileId, glow, hi = FILE_HI) {
487
+ const col = lerp3(fileBase[fileId], hi, glow);
488
+ filesMesh.setColorAt(fileId, tmpColor.setRGB(...col));
489
+ filesMesh.instanceColor.addUpdateRange(fileId * 3, 3);
490
+ filesMesh.instanceColor.needsUpdate = true;
491
+ if (glow > 0.01) touchedFiles.add(fileId); else touchedFiles.delete(fileId);
492
+ }
493
+
494
+ function clearGlows() {
495
+ for (const n of [...touchedNodes]) paintNode(n, 0);
496
+ for (const f of [...touchedFiles]) paintFile(f, 0);
497
+ for (const mesh of fillPool) mesh.visible = false;
498
+ }
499
+
500
+ // haces del modo Blast: del fichero seleccionado a sus relacionados,
501
+ // con pulsos viajando por la línea como paquetes de datos
502
+ let blastBeams = null;
503
+ function setBlastBeams(pairs) {
504
+ if (blastBeams) {
505
+ group.remove(blastBeams);
506
+ blastBeams.geometry.dispose();
507
+ blastBeams.material.dispose();
508
+ blastBeams = null;
509
+ }
510
+ if (!pairs?.length) return;
511
+ // pairs: { a, b, impact } — impact=true: b depende del seleccionado
512
+ // (consecuencias de un cambio); el pulso siempre viaja en el sentido de
513
+ // la propagación: hacia fuera en impactos, hacia dentro en dependencias
514
+ const pos = new Float32Array(pairs.length * 6);
515
+ const dist = new Float32Array(pairs.length * 2);
516
+ const dir = new Float32Array(pairs.length * 2);
517
+ pairs.forEach(({ a, b, impact }, i) => {
518
+ pos.set(a, i * 6);
519
+ pos.set(b, i * 6 + 3);
520
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
521
+ dist[i * 2] = impact ? 0 : len;
522
+ dist[i * 2 + 1] = impact ? len : 0;
523
+ dir[i * 2] = dir[i * 2 + 1] = impact ? 1 : 0;
524
+ });
525
+ const geo = new THREE.BufferGeometry();
526
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
527
+ geo.setAttribute('aDist', new THREE.BufferAttribute(dist, 1));
528
+ geo.setAttribute('aDir', new THREE.BufferAttribute(dir, 1));
529
+ blastBeams = new THREE.LineSegments(geo, new THREE.ShaderMaterial({
530
+ uniforms: {
531
+ uTime: { value: 0 },
532
+ uColor: { value: new THREE.Color(...theme.beam) },
533
+ uColorIn: { value: new THREE.Color(...(theme.beamIn ?? theme.beam)) },
534
+ },
535
+ vertexShader: /* glsl */`
536
+ attribute float aDist;
537
+ attribute float aDir;
538
+ varying float vD;
539
+ varying float vDir;
540
+ void main() {
541
+ vD = aDist;
542
+ vDir = aDir;
543
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
544
+ }`,
545
+ fragmentShader: /* glsl */`
546
+ uniform float uTime;
547
+ uniform vec3 uColor;
548
+ uniform vec3 uColorIn;
549
+ varying float vD;
550
+ varying float vDir;
551
+ void main() {
552
+ // tren de pulsos avanzando por la línea (cada ~8 unidades); la
553
+ // línea base se mantiene bien visible entre pulsos
554
+ float w = fract(vD * 0.125 - uTime * 1.4);
555
+ float pulse = exp(-pow((w - 0.2) * 6.0, 2.0));
556
+ vec3 col = mix(uColor, uColorIn, vDir);
557
+ gl_FragColor = vec4(col * (0.6 + pulse * 2.4), 0.5 + pulse * 0.5);
558
+ }`,
559
+ transparent: true,
560
+ blending: BLEND,
561
+ depthWrite: false,
562
+ }));
563
+ blastBeams.renderOrder = 3;
564
+ group.add(blastBeams);
565
+ }
566
+
567
+ function tickBeams(timeMs) {
568
+ if (blastBeams) blastBeams.material.uniforms.uTime.value = timeMs / 1000;
569
+ }
570
+
571
+ // modos de coloreado: reescribe los colores base de nodos y ficheros
572
+ // (fileColorFn/nodeColorFn → [r,g,b] o null para el color por defecto)
573
+ function setModeColors(fileColorFn, nodeColorFn) {
574
+ for (const node of boxNodes) {
575
+ const col = nodeColorFn?.(node) ?? (ROLE_COLOR[node.role] ?? ROLE_COLOR.cajon);
576
+ node.baseColor = col.map(c => c * (node.lineDim ?? 1));
577
+ paintNode(node, 0);
578
+ }
579
+ for (const f of model.files) {
580
+ const dim = dimOf(f);
581
+ const col = fileColorFn?.(f) ?? FILE_BASE;
582
+ fileBase[f.id] = col.map(c => c * dim);
583
+ paintFile(f.id, 0);
584
+ }
585
+ // se ha tocado todo: mejor una única subida completa que miles de rangos
586
+ colorAttr.clearUpdateRanges();
587
+ filesMesh.instanceColor.clearUpdateRanges();
588
+ }
589
+
590
+ function fillBoxes(boxesWithGlow) {
591
+ fillPool.forEach((mesh, i) => {
592
+ const entry = boxesWithGlow[i];
593
+ if (!entry) { mesh.visible = false; return; }
594
+ const { box, glow } = entry;
595
+ mesh.scale.set(box.sx, box.sy, box.sz);
596
+ mesh.position.set(box.cx, box.cy, box.cz);
597
+ mesh.material.opacity = 0.008 + glow * 0.045;
598
+ mesh.visible = true;
599
+ });
600
+ }
601
+
602
+ // leyenda serigrafiada (conmutable)
603
+ const legend = buildLegend(model, byArea, theme);
604
+ group.add(legend);
605
+
606
+ let groundOpaque = false;
607
+ const groundBaseOpacity = 0.8;
608
+ let visualSettings = {};
609
+ function setVisuals(v = {}) {
610
+ visualSettings = { ...visualSettings, ...v };
611
+ const linesMul = visualSettings.lines ?? 1;
612
+ const filesMul = visualSettings.files ?? 1;
613
+ const glassMul = visualSettings.glass ?? 1;
614
+ const facadeMul = visualSettings.facades ?? 1;
615
+ const labelMul = visualSettings.labels ?? 1;
616
+ const groundMul = visualSettings.ground ?? 1;
617
+
618
+ lines.material.opacity = Math.min(1, lineBaseOpacity * linesMul);
619
+ fileMat.opacity = Math.min(1, fileBaseOpacity * filesMul);
620
+ if (!groundOpaque) ground.material.opacity = Math.min(1, groundBaseOpacity * groundMul);
621
+
622
+ for (const shell of shells) {
623
+ const mat = shell.material;
624
+ mat.uniforms.uDim.value = (mat.userData.baseDim ?? cityDim) * glassMul;
625
+ }
626
+ group.traverse((o) => {
627
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
628
+ for (const mat of mats) {
629
+ if (mat?.userData?.vccKind === 'facade') mat.opacity = Math.min(1, facadeMul);
630
+ if (mat?.userData?.vccKind === 'label') mat.opacity = Math.min(1, labelMul);
631
+ }
632
+ });
633
+ }
634
+
635
+ function dispose() {
636
+ scene.remove(group);
637
+ const geometries = new Set();
638
+ const materials = new Set();
639
+ group.traverse((o) => {
640
+ if (o.geometry) geometries.add(o.geometry);
641
+ for (const m of Array.isArray(o.material) ? o.material : [o.material]) {
642
+ if (m) materials.add(m);
643
+ }
644
+ });
645
+ for (const g of geometries) g.dispose();
646
+ for (const m of materials) {
647
+ m.map?.dispose?.();
648
+ m.dispose();
649
+ }
650
+ }
651
+
652
+ return {
653
+ group, lines, shells, filesMesh, ground, legend,
654
+ paintNode, paintFile, clearGlows, fillBoxes, dispose, setModeColors, setBlastBeams, tickBeams, setVisuals,
655
+ setLegend: (v) => { legend.visible = v; },
656
+ // suelo opaco/translúcido. Opaco = escribe profundidad y se dibuja en la
657
+ // pasada de opacos: oculta de verdad los sótanos (ficheros ignorados)
658
+ setGroundOpaque: (v) => {
659
+ groundOpaque = v;
660
+ const m = ground.material;
661
+ m.opacity = v ? 1 : Math.min(1, groundBaseOpacity * (visualSettings.ground ?? 1));
662
+ m.transparent = !v;
663
+ m.depthWrite = v;
664
+ m.needsUpdate = true;
665
+ },
666
+ redrawFacade: (building, highlightKeys) => drawFacade(building, highlightKeys, theme),
667
+ };
668
+ }
js/vcc/model.js ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Modelo: convierte el árbol del repo en una ciudad con cajas anidadas.
2
+ // Jerarquía: ciudad → distrito → edificio → planta → apartamento → habitación
3
+ // → archivador → cajón → fichero (los ficheros pueden vivir en
4
+ // cualquier nivel: se les reserva un hueco en su contenedor).
5
+ // Cuando un subárbol es demasiado profundo/poblado para un solo edificio,
6
+ // se interpone una «urbanización»: una plota dentro del distrito que agrupa
7
+ // procedualmente a sus subcarpetas como edificios (recursivo), de modo que
8
+ // la profundidad sobrante se despliega por la ciudad en vez de comprimirse.
9
+ // Los ficheros ignorados (.gitignore y equivalentes) se separan en un
10
+ // sótano espejado bajo el plano de circuitos.
11
+ import { makeOwnerResolver } from './ownership.js';
12
+
13
+ export const ROLES = ['ciudad', 'distrito', 'edificio', 'planta', 'apartamento', 'habitacion', 'archivador', 'cajon'];
14
+ const ROLE_AXIS = { planta: 'y', apartamento: 'x', habitacion: 'z', archivador: 'x', cajon: 'y' };
15
+
16
+ const STREET = 14; // separación entre distritos
17
+ const ALLEY = 4; // separación entre edificios
18
+ const URB_PAD = 3; // margen interior de una urbanización
19
+ // un edificio absorbe 5 niveles (planta→…→cajón); más allá, urbanización
20
+ const URB_DEPTH = 5;
21
+ const URB_MIN_FILES = 200;
22
+ const URB_MAX_FILES = 2000; // aun siendo poco profundo, esto no cabe en uno
23
+ const GROUND_Y = 0;
24
+
25
+ let nextNode = 0;
26
+ let nextFile = 0;
27
+
28
+ function hash01(str) {
29
+ let h = 2166136261;
30
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
31
+ return ((h >>> 0) % 1000) / 1000;
32
+ }
33
+
34
+ function unpack(packed) {
35
+ const [name, dirs, leaf] = packed;
36
+ const node = { name, dirs: dirs.map(unpack), leaf: leaf.map(([n, s, ig]) => ({ name: n, size: s, ignored: !!ig })) };
37
+ node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
38
+ node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
39
+ return node;
40
+ }
41
+
42
+ function recount(node) {
43
+ node.files = node.leaf.length + node.dirs.reduce((a, d) => a + d.files, 0);
44
+ node.size = node.leaf.reduce((a, f) => a + f.size, 0) + node.dirs.reduce((a, d) => a + d.size, 0);
45
+ return node;
46
+ }
47
+
48
+ // separa un subárbol en parte visible y parte ignorada (sótano)
49
+ function splitSrc(src) {
50
+ const kept = { name: src.name, dirs: [], leaf: src.leaf.filter(f => !f.ignored) };
51
+ const ign = { name: src.name, dirs: [], leaf: src.leaf.filter(f => f.ignored) };
52
+ for (const d of src.dirs) {
53
+ const s = splitSrc(d);
54
+ if (s.kept.files) kept.dirs.push(s.kept);
55
+ if (s.ign.files) ign.dirs.push(s.ign);
56
+ }
57
+ return { kept: recount(kept), ign: recount(ign) };
58
+ }
59
+
60
+ function makeNode(model, name, role, parent, { virtual = false } = {}) {
61
+ const node = {
62
+ id: nextNode++, name, role, parent, children: [], fileIds: [],
63
+ virtual, box: null, building: null,
64
+ path: parent ? (virtual ? parent.path : `${parent.path}/${name}`) : name,
65
+ };
66
+ if (parent) parent.children.push(node);
67
+ model.nodes.push(node);
68
+ return node;
69
+ }
70
+
71
+ function addFiles(model, node, leaf) {
72
+ for (const f of leaf) {
73
+ const file = {
74
+ id: nextFile++, name: f.name, size: f.size, node,
75
+ path: `${node.path}/${f.name}`, box: null,
76
+ };
77
+ node.fileIds.push(file.id);
78
+ model.files.push(file);
79
+ }
80
+ }
81
+
82
+ // ── dimensiones de edificio ───────────────────────────────────────────────
83
+ function buildingDims(src) {
84
+ const kb = src.size / 1024;
85
+ const side = Math.min(22, Math.max(6, 4 + Math.log2(1 + kb) * 1.5));
86
+ const j = hash01(src.name);
87
+ const w = side * (0.85 + 0.3 * j);
88
+ const d = side * (0.85 + 0.3 * (1 - j));
89
+ const floors = Math.max(1, src.dirs.length + (src.leaf.length ? 1 : 0));
90
+ const h = Math.max(6, Math.max(floors * 3.0, 6 + src.files * 1.8));
91
+ return { w, d, h: Math.min(h, 80) };
92
+ }
93
+
94
+ // ── reparto de un eje en franjas ponderadas ───────────────────────────────
95
+ function splitAxis(box, axis, weights, inset) {
96
+ const total = weights.reduce((a, b) => a + b, 0);
97
+ const span = box['s' + axis];
98
+ const start = box['c' + axis] - span / 2;
99
+ const gap = Math.min(inset, span * 0.04);
100
+ const usable = span - gap * (weights.length + 1);
101
+ const out = [];
102
+ let cursor = start + gap;
103
+ for (const w of weights) {
104
+ const s = usable * (w / total);
105
+ out.push({ ...box, ['c' + axis]: cursor + s / 2, ['s' + axis]: s });
106
+ cursor += s + gap;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function shrink(box, f) {
112
+ return { ...box, sx: box.sx * f, sy: box.sy * f, sz: box.sz * f };
113
+ }
114
+
115
+ // ── celdas de fichero dentro de su contenedor ─────────────────────────────
116
+ function placeFiles(model, node, box, files) {
117
+ const n = files.length;
118
+ if (!n) return;
119
+ if (node.role === 'cajon') {
120
+ // carpetas colgantes: láminas verticales en fila
121
+ const slabs = splitAxis({ ...box, sy: box.sy * 0.72, cy: box.cy - box.sy * 0.1 }, 'x',
122
+ files.map(() => 1), 0.05);
123
+ files.forEach((fid, i) => {
124
+ const s = slabs[i];
125
+ model.files[fid].box = { ...s, sx: Math.min(s.sx * 0.55, 0.5), sz: s.sz * 0.78 };
126
+ });
127
+ return;
128
+ }
129
+ // rejilla de cubos apoyada en el suelo del contenedor
130
+ const aspect = box.sx / box.sz || 1;
131
+ const cols = Math.max(1, Math.round(Math.sqrt(n * aspect)));
132
+ const rows = Math.ceil(n / cols);
133
+ const cw = box.sx / cols, cd = box.sz / rows;
134
+ const base = Math.min(cw, cd, box.sy) * 0.5;
135
+ files.forEach((fid, i) => {
136
+ const f = model.files[fid];
137
+ const col = i % cols, row = Math.floor(i / cols);
138
+ const k = 0.65 + 0.45 * Math.min(1, Math.log2(2 + f.size / 1024) / 8);
139
+ const s = Math.max(0.25, base * k);
140
+ f.box = {
141
+ cx: box.cx - box.sx / 2 + cw * (col + 0.5),
142
+ cz: box.cz - box.sz / 2 + cd * (row + 0.5),
143
+ cy: box.cy - box.sy / 2 + s / 2 + 0.05,
144
+ sx: s, sy: s, sz: s,
145
+ };
146
+ });
147
+ }
148
+
149
+ // ── subdivisión recursiva del interior del edificio ───────────────────────
150
+ // flipY: en los sótanos las plantas crecen hacia abajo (dirección inversa)
151
+ function layoutInterior(model, node, src, box, role, flipY = false) {
152
+ node.box = box;
153
+ const dirs = src.dirs;
154
+ const hasFiles = src.leaf.length > 0;
155
+ addFiles(model, node, src.leaf);
156
+
157
+ if (!dirs.length) {
158
+ placeFiles(model, node, shrink(box, 0.9), node.fileIds);
159
+ return;
160
+ }
161
+ const childRole = ROLES[Math.min(ROLES.indexOf(role) + 1, ROLES.length - 1)];
162
+ const axis = ROLE_AXIS[childRole] ?? 'y';
163
+ const weights = dirs.map(d => 1 + Math.sqrt(d.files));
164
+ if (hasFiles) weights.push(0.8 + Math.sqrt(src.leaf.length) * 0.7);
165
+ const slices = splitAxis(box, axis, weights, 0.5);
166
+ if (flipY && axis === 'y') slices.reverse();
167
+ dirs.forEach((d, i) => {
168
+ const child = makeNode(model, d.name, childRole, node);
169
+ child.building = node.building;
170
+ layoutInterior(model, child, d, shrink(slices[i], 0.94), childRole, flipY);
171
+ });
172
+ if (hasFiles) placeFiles(model, node, shrink(slices[slices.length - 1], 0.9), node.fileIds);
173
+ }
174
+
175
+ // ── empaquetado en rejilla (edificios en distrito, distritos en ciudad) ───
176
+ function packGrid(items, gap) {
177
+ // items: { w, d } → coloca en filas; devuelve posiciones y tamaño del plot
178
+ const area = items.reduce((a, it) => a + (it.w + gap) * (it.d + gap), 0);
179
+ const targetW = Math.max(Math.sqrt(area) * 1.15, ...items.map(it => it.w + gap));
180
+ let x = 0, z = 0, rowD = 0, plotW = 0;
181
+ const pos = [];
182
+ for (const it of items) {
183
+ if (x > 0 && x + it.w > targetW) { x = 0; z += rowD + gap; rowD = 0; }
184
+ pos.push({ x: x + it.w / 2, z: z + it.d / 2 });
185
+ x += it.w + gap;
186
+ rowD = Math.max(rowD, it.d);
187
+ plotW = Math.max(plotW, x - gap);
188
+ }
189
+ return { pos, w: plotW, d: z + rowD };
190
+ }
191
+
192
+ function subtreeDepth(src) {
193
+ let d = 0;
194
+ for (const s of src.dirs) d = Math.max(d, 1 + subtreeDepth(s));
195
+ return d;
196
+ }
197
+
198
+ // ¿demasiado profundo y poblado para un solo edificio? → urbanización
199
+ function needsUrb(src) {
200
+ if (src.virtual || !src.dirs.length) return false;
201
+ if (src.files > URB_MAX_FILES) return true;
202
+ return src.files > URB_MIN_FILES && subtreeDepth(src) > URB_DEPTH;
203
+ }
204
+
205
+ function fileBlockSrc(leaf) {
206
+ return { name: '(ficheros)', dirs: [], leaf, files: leaf.length, size: leaf.reduce((a, f) => a + f.size, 0), virtual: true };
207
+ }
208
+
209
+ // un «bloque» es lo que ocupa una parcela: un edificio o una urbanización.
210
+ // Devuelve { w, d, place(cx, cz) } para el empaquetado en rejilla.
211
+ function buildBlock(model, parent, src) {
212
+ if (needsUrb(src)) return buildUrb(model, parent, src);
213
+ const split = splitSrc(src);
214
+ const b = makeNode(model, src.name, 'edificio', parent, { virtual: !!src.virtual });
215
+ b.building = b;
216
+ b.dims = buildingDims(split.kept.files ? split.kept : src);
217
+ // sin nada en superficie y todo en el sótano: búnker bajo y más opaco
218
+ // que señala que la información está bajo el suelo
219
+ if (!split.kept.files && split.ign.files) {
220
+ b.onlyBasement = true;
221
+ b.dims.h = 3;
222
+ }
223
+ b.srcKept = split.kept;
224
+ b.srcIgn = split.ign;
225
+ model.buildings.push(b);
226
+ return { w: b.dims.w, d: b.dims.d, place: (cx, cz) => placeBuilding(model, b, cx, cz) };
227
+ }
228
+
229
+ function buildUrb(model, parent, src) {
230
+ const urb = makeNode(model, src.name, 'urbanizacion', parent);
231
+ const childSrcs = [...src.dirs];
232
+ if (src.leaf.length) childSrcs.push(fileBlockSrc(src.leaf));
233
+ const blocks = childSrcs.map(s => buildBlock(model, urb, s));
234
+ const grid = packGrid(blocks, ALLEY);
235
+ const w = grid.w + URB_PAD * 2, d = grid.d + URB_PAD * 2;
236
+ model.urbs.push(urb);
237
+ return {
238
+ w, d,
239
+ place: (cx, cz) => {
240
+ urb.box = { cx, cy: GROUND_Y + 0.26, cz, sx: w, sy: 0.14, sz: d };
241
+ const ox = cx - w / 2 + URB_PAD, oz = cz - d / 2 + URB_PAD;
242
+ blocks.forEach((blk, i) => blk.place(ox + grid.pos[i].x, oz + grid.pos[i].z));
243
+ },
244
+ };
245
+ }
246
+
247
+ function placeBuilding(model, b, cx, cz) {
248
+ const { w, d, h } = b.dims;
249
+ layoutInterior(model, b, b.srcKept, { cx, cz, cy: GROUND_Y + h / 2, sx: w, sy: h, sz: d }, 'edificio');
250
+ // sótano: los ficheros ignorados crecen hacia abajo, mismo patrón invertido
251
+ if (b.srcIgn.files) {
252
+ const hb = Math.min(60, Math.max(5, Math.max(
253
+ (b.srcIgn.dirs.length + (b.srcIgn.leaf.length ? 1 : 0)) * 3.0,
254
+ 5 + b.srcIgn.files * 1.6,
255
+ )));
256
+ const bb = makeNode(model, b.name, 'edificio', b.parent, { virtual: b.virtual });
257
+ bb.building = bb;
258
+ bb.isBasement = true;
259
+ model.buildings.push(bb);
260
+ layoutInterior(model, bb, b.srcIgn,
261
+ { cx, cz, cy: GROUND_Y - 0.7 - hb / 2, sx: w, sy: hb, sz: d }, 'edificio', true);
262
+ }
263
+ delete b.srcKept; delete b.srcIgn;
264
+ }
265
+
266
+ function buildDistrict(model, city, srcDir, files) {
267
+ const district = makeNode(model, srcDir ? srcDir.name : '(raíz)', 'distrito', city, { virtual: !srcDir });
268
+ const blockSrcs = [];
269
+ if (srcDir) {
270
+ for (const d of srcDir.dirs) blockSrcs.push(d);
271
+ if (srcDir.leaf.length) blockSrcs.push(fileBlockSrc(srcDir.leaf));
272
+ } else {
273
+ blockSrcs.push(fileBlockSrc(files));
274
+ }
275
+ const blocks = blockSrcs.map(s => buildBlock(model, district, s));
276
+ const grid = packGrid(blocks, ALLEY);
277
+ district.plot = { w: grid.w + ALLEY * 2, d: grid.d + ALLEY * 2 };
278
+ district.blocks = blocks;
279
+ district.grid = grid;
280
+ model.districts.push(district);
281
+ return district;
282
+ }
283
+
284
+ export function buildModel(packedTree, meta = null) {
285
+ nextNode = 0; nextFile = 0;
286
+ const model = { nodes: [], files: [], buildings: [], districts: [], urbs: [], city: null };
287
+ const root = unpack(packedTree);
288
+ const city = makeNode(model, root.name, 'ciudad', null);
289
+ model.city = city;
290
+
291
+ for (const d of root.dirs) buildDistrict(model, city, d, []);
292
+ if (root.leaf.length) buildDistrict(model, city, null, root.leaf);
293
+
294
+ // colocar distritos en la ciudad
295
+ const plots = model.districts.map(di => ({ w: di.plot.w, d: di.plot.d }));
296
+ const cityGrid = packGrid(plots, STREET);
297
+ const ox = -cityGrid.w / 2, oz = -cityGrid.d / 2;
298
+ model.districts.forEach((di, i) => {
299
+ const p = cityGrid.pos[i];
300
+ const px = ox + p.x, pz = oz + p.z;
301
+ di.box = { cx: px, cy: GROUND_Y + 0.12, cz: pz, sx: di.plot.w, sy: 0.24, sz: di.plot.d };
302
+ const bx = px - di.plot.w / 2 + ALLEY, bz = pz - di.plot.d / 2 + ALLEY;
303
+ di.blocks.forEach((blk, j) => blk.place(bx + di.grid.pos[j].x, bz + di.grid.pos[j].z));
304
+ delete di.blocks; delete di.grid;
305
+ });
306
+ city.box = { cx: 0, cy: 0, cz: 0, sx: cityGrid.w + STREET * 2, sy: 1, sz: cityGrid.d + STREET * 2 };
307
+
308
+ // cadena de ancestros por fichero (para la iluminación jerárquica)
309
+ // + metadatos git y code ownership por fichero
310
+ const rootPrefix = city.name + '/';
311
+ const resolveOwner = makeOwnerResolver(meta?.owners);
312
+ const ownerIndex = new Map();
313
+ for (const f of model.files) {
314
+ const chain = [];
315
+ for (let n = f.node; n; n = n.parent) chain.push(n);
316
+ f.chain = chain; // del contenedor inmediato hacia la ciudad
317
+ f.rel = f.path.startsWith(rootPrefix) ? f.path.slice(rootPrefix.length) : f.path;
318
+ const g = meta?.git?.[f.rel];
319
+ f.git = g ? { st: g[0], a: g[1], d: g[2] } : null;
320
+ const owner = resolveOwner(f.rel);
321
+ if (owner != null) {
322
+ if (!ownerIndex.has(owner)) ownerIndex.set(owner, ownerIndex.size);
323
+ f.owner = ownerIndex.get(owner);
324
+ } else {
325
+ f.owner = null;
326
+ }
327
+ }
328
+ model.owners = [...ownerIndex.keys()];
329
+ model.hasGit = !!meta?.git;
330
+
331
+ applyCodeMeta(model, meta?.links, meta?.codeIndex);
332
+ model.fileByRel = new Map(model.files.map(f => [f.rel, f]));
333
+ model.dirByRel = new Map();
334
+ for (const n of model.nodes) {
335
+ if (n.role === 'ciudad' || n.virtual || n.isBasement || n.building?.isBasement) continue;
336
+ const rel = n.path.startsWith(rootPrefix) ? n.path.slice(rootPrefix.length) : n.path;
337
+ if (!model.dirByRel.has(rel)) model.dirByRel.set(rel, n);
338
+ }
339
+ return model;
340
+ }
341
+
342
+ // enlaces de código (modo Blast): adyacencia saliente y entrante. Es una
343
+ // función aparte porque el análisis perezoso los refresca por lotes sobre
344
+ // el modelo ya construido, sin reconstruir la ciudad.
345
+ export function applyCodeMeta(model, links, codeIndex) {
346
+ model.linksOut = links ?? {};
347
+ model.linksIn = {};
348
+ for (const [src, toks] of Object.entries(model.linksOut)) {
349
+ for (const tok of toks) {
350
+ if (tok.startsWith('F:')) (model.linksIn[tok.slice(2)] ??= []).push(src);
351
+ }
352
+ }
353
+ // índice de símbolos (funciones/clases/tipos): aristas "src usa sym de target"
354
+ model.defs = codeIndex?.defs ?? {};
355
+ model.symOut = codeIndex?.symLinks ?? {};
356
+ model.symIn = {};
357
+ for (const [src, edges] of Object.entries(model.symOut)) {
358
+ for (const [target, sym] of edges) (model.symIn[target] ??= []).push([src, sym]);
359
+ }
360
+ }
js/vcc/ownership.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ // Stub de code-ownership: Elffuss Code no alimenta metadatos de propiedad, así
2
+ // que makeOwnerResolver devuelve siempre null (file.owner = null en model.js).
3
+ // El original (Sendery/VibeCodeViewer, Apache-2.0) parsea CODEOWNERS/package
4
+ // meta; aquí no hace falta para dibujar la ciudad.
5
+ export function makeOwnerResolver() { return () => null; }
js/vcc/theme.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tema «hacker» de VibeCodeViewer (Sendery/VibeCodeViewer, Apache-2.0),
2
+ // copiado de src/themes.js. Es la paleta que consume builder.js.
3
+ export const THEME = {
4
+ label: 'Hacker',
5
+ light: false,
6
+ background: 0x020309, fog: 0x030510,
7
+ bloom: { strength: 0.65, threshold: 0.85 },
8
+ roleColor: {
9
+ distrito: [0.16, 0.07, 0.30], urbanizacion: [0.13, 0.09, 0.30],
10
+ edificio: [0.10, 0.30, 0.36], planta: [0.06, 0.20, 0.26],
11
+ apartamento: [0.05, 0.16, 0.21], habitacion: [0.045, 0.13, 0.18],
12
+ archivador: [0.04, 0.11, 0.15], cajon: [0.035, 0.10, 0.13],
13
+ },
14
+ hi: [0.28, 3.0, 3.6],
15
+ fileBase: [0.05, 0.22, 0.28], fileHi: [1.0, 5.0, 6.0],
16
+ glass: [0.12, 0.55, 0.65], glassHi: [0.3, 2.4, 2.8],
17
+ ground: {
18
+ bg: '#04050b', grid: 'rgba(50, 80, 140, 0.07)',
19
+ traceA: [112, 60, 235], traceB: [40, 200, 235],
20
+ padA: [170, 120, 255], padB: [120, 240, 255],
21
+ },
22
+ facade: {
23
+ text: 'rgba(140, 232, 250, 0.72)', hot: '#c8ffff',
24
+ hotGlow: 'rgba(0,255,255,0.9)', hotBg: 'rgba(0,255,255,0.16)',
25
+ },
26
+ labels: { district: '#a86cff', urb: '#8a7bff', building: '#49e8ff' },
27
+ beam: [2.6, 1.1, 0.25], beamIn: [3.4, 0.5, 0.8], fill: [0.08, 0.75, 0.95], slab: [0.1, 0.5, 0.6],
28
+ };
og.png ADDED

Git LFS Details

  • SHA256: fb478cb6cd10682655a9617dbb489a9f47d1a77b80bb816256753b13fb45c908
  • Pointer size: 131 Bytes
  • Size of remote file: 365 kB
package.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "elffuss-code-web",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "Elffuss Code: IDE web con agente élfico. Sin build: ES modules vanilla."
6
+ }
sw.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Service worker INERTE (a propósito).
2
+ //
3
+ // Antes cacheaba ficheros de modelo interceptando las peticiones a HuggingFace,
4
+ // pero eso ROMPÍA la carga del ONNX: el fichero de pesos externos
5
+ // `model_q4.onnx_data` fallaba con ERR_FAILED al re-fetchearlo → el modelo no
6
+ // cargaba. Resultó que el SW era INNECESARIO: el cacheo ya lo hacen
7
+ // - transformers.js con su propia Cache Storage (modelo ONNX «healed»), y
8
+ // - litert.js con blob cache-first en Cache Storage (Gemma .litertlm),
9
+ // y `navigator.storage.persist()` evita que el navegador los desaloje.
10
+ //
11
+ // Así que este SW ya NO intercepta nada (el navegador gestiona todas las
12
+ // peticiones de forma nativa). Se mantiene registrado solo para REEMPLAZAR
13
+ // limpiamente al SW anterior que sí interceptaba y rompía la carga.
14
+ self.addEventListener('install', () => self.skipWaiting());
15
+ self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
16
+
17
+ // Permite vaciar la caché de modelos desde Ajustes (sin tocar el blob de Gemma
18
+ // salvo que se pida). NO hay handler de 'fetch' → cero interceptación.
19
+ self.addEventListener('message', e => {
20
+ if (e.data === 'clear-models') {
21
+ caches.delete('elffuss-models-v1').then(() => e.source && e.source.postMessage('models-cleared'));
22
+ }
23
+ });
24
+
25
+ // Botón «▶ Ejecutar» en la notificación de mejoras del cerebro CEO: las
26
+ // notificaciones normales (new Notification()) no soportan acciones, las que
27
+ // pasan por el SW (registration.showNotification) sí. Al pulsar, avisamos a
28
+ // la pestaña (postMessage) para que mande la propuesta a la cola real.
29
+ self.addEventListener('notificationclick', event => {
30
+ const action = event.action; // 'execute' | 'open' | '' (clic en el cuerpo)
31
+ const data = event.notification.data || {};
32
+ event.notification.close();
33
+ event.waitUntil((async () => {
34
+ const clientsList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
35
+ const client = clientsList[0];
36
+ if (client) {
37
+ client.postMessage({ type: 'notif-action', action, md: data.md });
38
+ client.focus?.();
39
+ } else if (self.clients.openWindow) {
40
+ await self.clients.openWindow('/');
41
+ }
42
+ })());
43
+ });