// Vista «Mente de Elffuss» — el pensamiento subyacente del cerebro CEO en un
// mundo 3D de fantasía psicodélica trance, con la CIUDAD real del proyecto
// (motor VibeCodeViewer) flotando por debajo.
// · cada línea/tool-call que llega nace como una ESTRELLA de texto (color por
// PERFIL, tamaño/tipografía según lo que ocurre) que se desvanece sola.
// · los perfiles (nombre, foco, color) se editan desde ⚙ — el usuario los crea
// a su gusto.
// · «≡ historial»: panel con TODO lo que ha llegado, sin recortar.
// · cuando lee/escribe un fichero real, un haz baja hasta él en la ciudad.
// · overlay PERSISTENTE (música/animación no reinician al salir y volver).
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
import * as ceo from './ceo.js';
import * as db from './db.js';
import { renderMarkdown } from './md.js';
import { t } from './i18n.js';
const SOUNDCLOUD = 'https://soundcloud.com/dekel-official/dekel-baoba-festival-2023';
const CEO_COLOR = '#ff4d8d';
let open = false, built = false, raf = null;
let overlay, S, widget, onOpenFile = () => {}, onExecute = () => {};
// Ciudad de fondo (vista real del workspace) y semilla de pensamientos previos:
// AMBOS opcionales e inyectados por la app anfitriona — Code tiene un motor
// VibeCodeViewer sobre su proyecto único; Claw (v1) no lo ofrece y la Mente
// funciona igual de bien sin ciudad de fondo.
let loadThoughts = async () => []; // () => [{path, md}]
let buildCityFn = null; // (scene) => {cityWrap, cityCity, cityModel} | null
export function init({ loadThoughts: lt, buildCity: bc } = {}) {
if (lt) loadThoughts = lt;
if (bc) buildCityFn = bc;
}
let anchorMap = new Map(); // id de perfil (+ 'ceo') → Vector3 (anclas del mundo)
const anchors = []; // { el, pos } — solo etiquetas de propuestas forjadas
let nodesGroup, thoughtNodes = [];
let starGroup, stars = []; // estrellas de pensamiento efímeras
const lineBuf = new Map(); // canal → texto acumulado hasta la línea completa
const streamed = new Map(); // canal → ¿llegaron tokens? (evita duplicar el texto final)
let cityWrap = null, cityCity = null, cityModel = null;
let beams = [], fileActivity = [];
export function isOpen() { return open; }
export function setOpenFile(fn) { onOpenFile = fn; }
export function setExecuteProposal(fn) { onExecute = fn; }
const escHtml = s => String(s).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c]));
const profileOf = channel => channel === 'ceo'
? { id: 'ceo', name: 'CEO', color: CEO_COLOR }
: ceo.getProfiles().find(p => p.id === channel) || { id: channel, name: channel, color: '#7c5cff' };
// ── música con Widget API (persistente: pausa al salir, reanuda al volver) ──
function mountMusic(root) {
const wrap = document.createElement('div');
wrap.className = 'mind-music';
const url = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(SOUNDCLOUD) +
'&auto_play=true&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=false&color=%23ff4d8d';
wrap.innerHTML = '' +
'
▶ Dekel · Baoba Festival — modo trance
';
root.appendChild(wrap);
const iframe = wrap.querySelector('#mind-sc');
const initWidget = () => { try { widget = window.SC.Widget(iframe); } catch { /* aún no */ } };
if (window.SC && window.SC.Widget) initWidget();
else { const s = document.createElement('script'); s.src = 'https://w.soundcloud.com/player/api.js'; s.onload = initWidget; document.head.appendChild(s); }
}
// ══ ESTRELLAS de pensamiento: cada línea/tool-call nace, brilla y se apaga ══
function makeTextSprite(text, color, { fontSize = 16, bold = false, glow = 10 } = {}) {
const pad = 8;
const probe = document.createElement('canvas').getContext('2d');
probe.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
const w = Math.ceil(probe.measureText(text).width) + pad * 2;
const h = fontSize + pad * 2;
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
const g = cv.getContext('2d');
g.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`;
g.textBaseline = 'middle';
g.shadowColor = color; g.shadowBlur = glow;
g.fillStyle = color;
g.fillText(text, pad, h / 2 + 1);
const tex = new THREE.CanvasTexture(cv);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
const sprite = new THREE.Sprite(mat);
const scale = 0.09;
sprite.scale.set(w * scale, h * scale, 1);
return sprite;
}
// kind: 'line' (charla normal) · 'tool' (tool-call) · 'event' (ciclo/estado) · 'built' (propuesta forjada)
const KIND_STYLE = {
line: { fontSize: 13, bold: false, glow: 8, life: 6, jitter: 15 },
tool: { fontSize: 17, bold: true, glow: 16, life: 9, jitter: 9 },
event: { fontSize: 20, bold: true, glow: 14, life: 7, jitter: 6 },
built: { fontSize: 26, bold: true, glow: 22, life: 13, jitter: 4 },
};
function spawnStar(channel, text, kind = 'line') {
if (!starGroup || !text) return;
const prof = profileOf(channel);
const st = KIND_STYLE[kind] || KIND_STYLE.line;
const anchor = anchorMap.get(prof.id) || new THREE.Vector3(0, 20, 0);
const j = st.jitter;
const label = kind === 'tool' ? '⟐ ' + text : kind === 'event' ? '★ ' + text : kind === 'built' ? '✦ ' + text : text;
const sprite = makeTextSprite(label.slice(0, kind === 'line' ? 52 : 72), prof.color, st);
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));
sprite.material.opacity = 0;
starGroup.add(sprite);
stars.push({ obj: sprite, born: elapsed, life: st.life, seed: Math.random() * 1000, up: kind === 'built' ? 0.5 : 0.22 });
if (stars.length > 160) { const old = stars.shift(); starGroup.remove(old.obj); old.obj.material.map.dispose(); old.obj.material.dispose(); }
}
// ── historial COMPLETO (todo lo que llega, sin recortar) — PERSISTENTE: antes
// vivía solo en el DOM/memoria y una recarga de página lo borraba entero.
const LOG_CAP = 500;
let logHistory = []; // [{channel, text}] — se guarda en IndexedDB
let logSaveTimer = null;
function saveLogSoon() {
clearTimeout(logSaveTimer);
logSaveTimer = setTimeout(() => db.set('kv', 'mindLog', logHistory).catch(() => {}), 400);
}
function renderLogRow(channel, text) {
const body = document.getElementById('mind-log-body');
if (!body) return;
const prof = profileOf(channel);
const row = document.createElement('div');
row.className = 'ml-row';
row.innerHTML = `${escHtml(prof.name)}${escHtml(text)}`;
body.appendChild(row);
while (body.children.length > LOG_CAP) body.removeChild(body.firstChild);
body.scrollTop = body.scrollHeight;
}
function logLine(channel, text) {
logHistory.push({ channel, text });
if (logHistory.length > LOG_CAP) logHistory = logHistory.slice(-LOG_CAP);
saveLogSoon();
renderLogRow(channel, text);
}
async function loadPersistedLog() {
try {
const saved = await db.get('kv', 'mindLog');
if (!Array.isArray(saved) || !saved.length) return;
logHistory = saved;
for (const { channel, text } of saved) renderLogRow(channel, text);
} catch { /* aún no hay historial guardado */ }
}
function flushLine(channel, force = false) {
const buf = (lineBuf.get(channel) || '').trim();
if (!buf && !force) return;
if (buf) { spawnStar(channel, buf, 'line'); logLine(channel, buf); }
lineBuf.set(channel, '');
}
// alimenta estrellas + historial + haces sobre la ciudad (ceo.js → aquí)
export function pushThought(channel, ev) {
if (!open) return;
if (channel === 'sys') { logLine('ceo', ev.text); return; }
if (channel === 'ceo') {
if (ev.type === 'cycle') { logLine('ceo', '● ' + ev.text); spawnStar('ceo', ev.text, 'event'); }
else if (ev.type === 'survey') { logLine('ceo', ev.text); }
else if (ev.type === 'reprogram') { logLine('ceo', '⚙ ' + ev.text); spawnStar('ceo', ev.text, 'event'); if (ev.profiles) rebuildWorld(); }
else if (ev.type === 'paused') { logLine('ceo', ev.text); spawnStar('ceo', ev.text, 'event'); }
else if (ev.type === 'built') { logLine('ceo', '✦ ' + ev.text); spawnStar('ceo', ev.text, 'built'); addThoughtNode(ev); }
else logLine('ceo', ev.text || '');
return;
}
if (ev.type === 'open') { lineBuf.set(channel, ''); streamed.set(channel, false); }
else if (ev.type === 'token') {
streamed.set(channel, true);
lineBuf.set(channel, (lineBuf.get(channel) || '') + ev.text);
const buf = lineBuf.get(channel);
if (/\n/.test(ev.text) || buf.length > 90) flushLine(channel);
} else if (ev.type === 'tool') {
flushLine(channel);
spawnStar(channel, ev.text, 'tool');
logLine(channel, '⟐ ' + ev.text);
if (ev.path) fileBeam(ev.path, /escrib/i.test(ev.text) ? 'write' : 'read');
} else if (ev.type === 'tool_result') {
// el RESULTADO real de la tool (lo que de verdad se leyó/escribió/ejecutó),
// enlazado justo debajo de su tool-call — no solo el nombre de la acción.
const t = '→ ' + (ev.text || '(sin salida)');
spawnStar(channel, t, 'line');
logLine(channel, t);
} else if (ev.type === 'done') {
flushLine(channel, true);
// el proveedor puede devolver el texto final SIN pasar por tokens (sin
// streaming) → si no vimos ningún token, esto es lo único que lo muestra.
if (ev.text && !streamed.get(channel)) { spawnStar(channel, ev.text, 'line'); logLine(channel, ev.text); }
}
}
// ── nodos de pensamiento clicables (cada .md forjado = un punto brillante) ──
function addThoughtNode({ path, md, text }) {
if (!nodesGroup) return;
const i = thoughtNodes.length;
const a = i * 2.399963; // ángulo áureo
const r = 30 + i * 5;
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(3.2, 0),
new THREE.MeshBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false }));
mesh.position.set(Math.cos(a) * r, 6 + Math.sin(i * 0.7) * 10, Math.sin(a) * r);
mesh.userData = { path, md, text };
nodesGroup.add(mesh);
thoughtNodes.push(mesh);
const lab = document.createElement('div');
lab.className = 'mind-node-label';
lab.textContent = (path || 'pensamiento').split('/').pop();
// clicable ella misma — antes SOLO el raycast contra la diminuta malla 3D
// abría el panel, fácil de fallar; la etiqueta es el blanco visible real.
lab.addEventListener('click', () => selectThoughtNode(mesh));
document.getElementById('mind-anchors').appendChild(lab);
anchors.push({ el: lab, pos: mesh.position });
mesh.userData.label = lab;
}
async function loadExistingThoughts() {
try { for (const it of await loadThoughts()) addThoughtNode(it); } catch { /* aún no hay */ }
}
// selección de un nodo de propuesta — desde la malla 3D (raycast) o su etiqueta
function selectThoughtNode(mesh) { showThoughtPanel(mesh); focusOn(mesh.position, 26); }
function showThoughtPanel(node) {
const root = document.getElementById('mind-panel');
const md = node.userData.md || node.userData.text || '(sin contenido)';
const path = node.userData.path || 'pensamiento';
root.innerHTML = `
${escHtml(path)}
` +
`
${renderMarkdown(md)}
` +
`
` +
`` +
(node.userData.path ? `` : '') +
`
`;
root.classList.add('show');
root.querySelector('#mp-x').onclick = () => root.classList.remove('show');
root.querySelector('#mp-exec').onclick = () => { onExecute(md); root.classList.remove('show'); };
const openBtn = root.querySelector('#mp-open');
if (openBtn) openBtn.onclick = () => { onOpenFile(node.userData.path); root.classList.remove('show'); };
}
// ══ perfiles: anclas 3D + leyenda (recalculadas al abrir o al reprogramar) ══
function computeAnchors() {
const map = new Map();
map.set('ceo', new THREE.Vector3(0, 46, 0));
const profs = ceo.getProfiles();
const n = Math.max(profs.length, 1);
profs.forEach((p, i) => {
const a = (i / n) * Math.PI * 2;
map.set(p.id, new THREE.Vector3(Math.cos(a) * 70, 8 + (i % 2) * 20, Math.sin(a) * 70));
});
return map;
}
function renderLegend() {
const el = document.getElementById('mind-legend');
if (!el) return;
const profs = ceo.getProfiles();
el.innerHTML = `
CEO
` +
profs.map(p => `
${escHtml(p.name)}
`).join('');
el.querySelectorAll('.ml-item').forEach(row => {
row.onclick = () => { const pos = anchorMap.get(row.dataset.id); if (pos) focusOn(pos, 46); };
});
}
// vuelo suave de la cámara hacia un punto — así «clic en algo → la cámara se centra»
let flyTo = null;
function focusOn(pos, distance = 40) {
if (!S) return;
const dir = new THREE.Vector3().subVectors(S.camera.position, S.controls.target);
if (dir.lengthSq() < 0.001) dir.set(0, 0.3, 1);
dir.normalize().multiplyScalar(distance);
flyTo = { fromPos: S.camera.position.clone(), toPos: pos.clone().add(dir), fromTarget: S.controls.target.clone(), toTarget: pos.clone(), t0: elapsed, dur: 1.1 };
}
const easeInOut = k => k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
function rebuildWorld() { anchorMap = computeAnchors(); renderLegend(); }
// ── panel ⚙: reprograma misión, carpeta-alma y PERFILES (editables) ──────
function wireConfig(root) {
const btn = root.querySelector('#mind-config');
const cfg = root.querySelector('#mind-cfg');
const profRowHtml = p => `
`;
const notifState = () => (!('Notification' in window)) ? { txt: 'no soportadas por este navegador', can: false }
: Notification.permission === 'granted' ? { txt: '✓ concedidas — avisaré cuando encuentre algo bueno', can: false }
: Notification.permission === 'denied' ? { txt: '✕ bloqueadas — actívalas en los ajustes del sitio del navegador', can: false }
: { txt: 'aún no pedidas', can: true };
const draw = () => {
const profs = ceo.getProfiles();
const ns = notifState();
cfg.innerHTML =
'