diff --git a/app.js b/app.js
index 4b51de8..5a2bcaa 100644
--- a/app.js
+++ b/app.js
@@ -45,6 +45,18 @@ let chatOpen = true;
let loadProgress = 0;
let performanceTier = 'high';
+// ═══ HERMES WS STATE ═══
+let hermesWs = null;
+let wsReconnectTimer = null;
+let wsConnected = false;
+let recentToolOutputs = [];
+let workshopPanelCtx = null;
+let workshopPanelTexture = null;
+let workshopPanelCanvas = null;
+let workshopScanMat = null;
+let workshopPanelRefreshTimer = 0;
+let lastFocusedPortal = null;
+
// ═══ NAVIGATION SYSTEM ═══
const NAV_MODES = ['walk', 'orbit', 'fly'];
let navModeIdx = 0;
@@ -124,8 +136,15 @@ async function init() {
createThoughtStream();
createHarnessPulse();
createSessionPowerMeter();
+ createWorkshopTerminal();
+ createAshStorm();
updateLoad(90);
+ loadSession();
+ connectHermes();
+ fetchGiteaData();
+ setInterval(fetchGiteaData, 30000); // Refresh every 30s
+
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
@@ -341,17 +360,103 @@ function createBatcaveTerminal() {
{ title: 'NEXUS COMMAND', color: NEXUS.colors.primary, rot: -0.4, x: -6, y: 3, lines: ['> STATUS: NOMINAL', '> UPTIME: 142.4h', '> HARNESS: STABLE', '> MODE: SOVEREIGN'] },
{ title: 'DEV QUEUE', color: NEXUS.colors.gold, rot: -0.2, x: -3, y: 3, lines: ['> ISSUE #4: CORE', '> ISSUE #5: PORTAL', '> ISSUE #6: TERMINAL', '> ISSUE #7: TIMMY'] },
{ title: 'METRICS', color: NEXUS.colors.secondary, rot: 0, x: 0, y: 3, lines: ['> CPU: 12% [||....]', '> MEM: 4.2GB', '> COMMITS: 842', '> ACTIVE LOOPS: 5'] },
- { title: 'THOUGHTS', color: NEXUS.colors.primary, rot: 0.2, x: 3, y: 3, lines: ['> ANALYZING WORLD...', '> SYNCING MEMORY...', '> WAITING FOR INPUT', '> SOUL ON BITCOIN'] },
- { title: 'AGENT STATUS', color: NEXUS.colors.gold, rot: 0.4, x: 6, y: 3, lines: ['> TIMMY: ● RUNNING', '> KIMI: ○ STANDBY', '> CLAUDE: ● ACTIVE', '> PERPLEXITY: ○'] },
+ { title: 'SOVEREIGNTY', color: NEXUS.colors.gold, rot: 0.2, x: 3, y: 3, lines: ['REPLIT: GRADE: A', 'PERPLEXITY: GRADE: A-', 'HERMES: GRADE: B+', 'KIMI: GRADE: B', 'CLAUDE: GRADE: B+'] },
+ { title: 'AGENT STATUS', color: NEXUS.colors.primary, rot: 0.4, x: 6, y: 3, lines: ['> TIMMY: ● RUNNING', '> KIMI: ○ STANDBY', '> CLAUDE: ● ACTIVE', '> PERPLEXITY: ○'] },
];
panelData.forEach(data => {
- createTerminalPanel(terminalGroup, data.x, data.y, data.rot, data.title, data.color, data.lines);
+ const terminal = createTerminalPanel(terminalGroup, data.x, data.y, data.rot, data.title, data.color, data.lines);
+ batcaveTerminals.push(terminal);
});
scene.add(terminalGroup);
}
+// ═══ WORKSHOP TERMINAL ═══
+function createWorkshopTerminal() {
+ const w = 6, h = 4;
+ const group = new THREE.Group();
+ group.position.set(-14, 3, 0);
+ group.rotation.y = Math.PI / 4;
+
+ workshopPanelCanvas = document.createElement('canvas');
+ workshopPanelCanvas.width = 1024;
+ workshopPanelCanvas.height = 512;
+ workshopPanelCtx = workshopPanelCanvas.getContext('2d');
+
+ workshopPanelTexture = new THREE.CanvasTexture(workshopPanelCanvas);
+ workshopPanelTexture.minFilter = THREE.LinearFilter;
+
+ const panelGeo = new THREE.PlaneGeometry(w, h);
+ const panelMat = new THREE.MeshBasicMaterial({
+ map: workshopPanelTexture,
+ transparent: true,
+ opacity: 0.9,
+ side: THREE.DoubleSide
+ });
+ const panel = new THREE.Mesh(panelGeo, panelMat);
+ group.add(panel);
+
+ const scanGeo = new THREE.PlaneGeometry(w + 0.1, h + 0.1);
+ workshopScanMat = new THREE.ShaderMaterial({
+ transparent: true,
+ uniforms: { uTime: { value: 0 } },
+ vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
+ fragmentShader: `
+ uniform float uTime;
+ varying vec2 vUv;
+ void main() {
+ float scan = sin(vUv.y * 200.0 + uTime * 10.0) * 0.05;
+ float noise = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453) * 0.05;
+ gl_FragColor = vec4(0.0, 0.1, 0.2, scan + noise);
+ }
+ `
+ });
+ const scan = new THREE.Mesh(scanGeo, workshopScanMat);
+ scan.position.z = 0.01;
+ group.add(scan);
+
+ scene.add(group);
+ refreshWorkshopPanel();
+}
+
+function refreshWorkshopPanel() {
+ if (!workshopPanelCtx) return;
+ const ctx = workshopPanelCtx;
+ const w = 1024, h = 512;
+
+ ctx.clearRect(0, 0, w, h);
+ ctx.fillStyle = 'rgba(10, 15, 40, 0.8)';
+ ctx.fillRect(0, 0, w, h);
+
+ ctx.fillStyle = '#4af0c0';
+ ctx.font = 'bold 40px "Orbitron", sans-serif';
+ ctx.fillText('WORKSHOP TERMINAL v1.0', 40, 60);
+ ctx.fillRect(40, 80, 944, 4);
+
+ ctx.font = '24px "JetBrains Mono", monospace';
+ ctx.fillStyle = wsConnected ? '#4af0c0' : '#ff4466';
+ ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
+
+ ctx.fillStyle = '#7b5cff';
+ const contextName = activePortal ? activePortal.name.toUpperCase() : 'NEXUS CORE';
+ ctx.fillText(`CONTEXT: ${contextName}`, 40, 160);
+
+ ctx.fillStyle = '#a0b8d0';
+ ctx.font = 'bold 20px "Orbitron", sans-serif';
+ ctx.fillText('TOOL OUTPUT STREAM', 40, 220);
+ ctx.fillRect(40, 230, 400, 2);
+
+ ctx.font = '16px "JetBrains Mono", monospace';
+ recentToolOutputs.slice(-10).forEach((out, i) => {
+ ctx.fillStyle = out.type === 'call' ? '#ffd700' : '#4af0c0';
+ const text = `[${out.agent}] ${out.content.substring(0, 80)}${out.content.length > 80 ? '...' : ''}`;
+ ctx.fillText(text, 40, 260 + i * 24);
+ });
+
+ workshopPanelTexture.needsUpdate = true;
+}
+
function createTerminalPanel(parent, x, y, rot, title, color, lines) {
const w = 2.8, h = 3.5;
const group = new THREE.Group();
@@ -379,23 +484,32 @@ function createTerminalPanel(parent, x, y, rot, title, color, lines) {
textCanvas.width = 512;
textCanvas.height = 640;
const ctx = textCanvas.getContext('2d');
- ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
- ctx.font = 'bold 32px "Orbitron", sans-serif';
- ctx.fillText(title, 20, 45);
- ctx.fillRect(20, 55, 472, 2);
- ctx.font = '20px "JetBrains Mono", monospace';
- ctx.fillStyle = '#a0b8d0';
- lines.forEach((line, i) => {
- let fillColor = '#a0b8d0';
- if (line.includes('● RUNNING') || line.includes('● ACTIVE')) fillColor = '#4af0c0';
- else if (line.includes('○ STANDBY')) fillColor = '#5a6a8a';
- else if (line.includes('NOMINAL')) fillColor = '#4af0c0';
- ctx.fillStyle = fillColor;
- ctx.fillText(line, 20, 100 + i * 40);
- });
-
+
const textTexture = new THREE.CanvasTexture(textCanvas);
textTexture.minFilter = THREE.LinearFilter;
+
+ function updatePanelText(newLines) {
+ ctx.clearRect(0, 0, 512, 640);
+ ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
+ ctx.font = 'bold 32px "Orbitron", sans-serif';
+ ctx.fillText(title, 20, 45);
+ ctx.fillRect(20, 55, 472, 2);
+ ctx.font = '20px "JetBrains Mono", monospace';
+ ctx.fillStyle = '#a0b8d0';
+ const displayLines = newLines || lines;
+ displayLines.forEach((line, i) => {
+ let fillColor = '#a0b8d0';
+ if (line.includes('● RUNNING') || line.includes('● ACTIVE') || line.includes('ONLINE')) fillColor = '#4af0c0';
+ else if (line.includes('○ STANDBY') || line.includes('OFFLINE')) fillColor = '#5a6a8a';
+ else if (line.includes('NOMINAL')) fillColor = '#4af0c0';
+ ctx.fillStyle = fillColor;
+ ctx.fillText(line, 20, 100 + i * 40);
+ });
+ textTexture.needsUpdate = true;
+ }
+
+ updatePanelText();
+
const textMat = new THREE.MeshBasicMaterial({
map: textTexture,
transparent: true,
@@ -435,7 +549,70 @@ function createTerminalPanel(parent, x, y, rot, title, color, lines) {
group.add(scanMesh);
parent.add(group);
- batcaveTerminals.push({ group, scanMat, borderMat });
+ return { group, scanMat, borderMat, updatePanelText, title };
+}
+
+// ═══ GITEA DATA INTEGRATION ═══
+async function fetchGiteaData() {
+ try {
+ const [issuesRes, stateRes] = await Promise.all([
+ fetch('/api/gitea/repos/admin/timmy-tower/issues?state=all'),
+ fetch('/api/gitea/repos/admin/timmy-tower/contents/world_state.json')
+ ]);
+
+ if (issuesRes.ok) {
+ const issues = await issuesRes.json();
+ updateDevQueue(issues);
+ updateAgentStatus(issues);
+ }
+
+ if (stateRes.ok) {
+ const content = await stateRes.json();
+ const worldState = JSON.parse(atob(content.content));
+ updateNexusCommand(worldState);
+ }
+ } catch (e) {
+ console.error('Failed to fetch Gitea data:', e);
+ }
+}
+
+function updateAgentStatus(issues) {
+ const terminal = batcaveTerminals.find(t => t.title === 'AGENT STATUS');
+ if (!terminal) return;
+
+ // Check for Morrowind issues
+ const morrowindIssues = issues.filter(i => i.title.toLowerCase().includes('morrowind') && i.state === 'open');
+ const perplexityStatus = morrowindIssues.length > 0 ? '● MORROWIND' : '○ STANDBY';
+
+ const lines = [
+ '> TIMMY: ● RUNNING',
+ '> KIMI: ○ STANDBY',
+ '> CLAUDE: ● ACTIVE',
+ `> PERPLEXITY: ${perplexityStatus}`
+ ];
+ terminal.updatePanelText(lines);
+}
+
+function updateDevQueue(issues) {
+ const terminal = batcaveTerminals.find(t => t.title === 'DEV QUEUE');
+ if (!terminal) return;
+
+ const lines = issues.slice(0, 4).map(issue => `> #${issue.number}: ${issue.title.substring(0, 15)}...`);
+ while (lines.length < 4) lines.push('> [EMPTY SLOT]');
+ terminal.updatePanelText(lines);
+}
+
+function updateNexusCommand(state) {
+ const terminal = batcaveTerminals.find(t => t.title === 'NEXUS COMMAND');
+ if (!terminal) return;
+
+ const lines = [
+ `> STATUS: ${state.tower.status.toUpperCase()}`,
+ `> ENERGY: ${state.tower.energy}%`,
+ `> STABILITY: ${(state.matrix.stability * 100).toFixed(1)}%`,
+ `> AGENTS: ${state.matrix.active_agents.length}`
+ ];
+ terminal.updatePanelText(lines);
}
// ═══ AGENT PRESENCE SYSTEM ═══
@@ -1081,14 +1258,153 @@ function sendChatMessage() {
input.blur();
}
-function addChatMessage(type, text) {
+// ═══ HERMES WEBSOCKET ═══
+function connectHermes() {
+ if (hermesWs) return;
+
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
+
+ console.log(`Connecting to Hermes at ${wsUrl}...`);
+ hermesWs = new WebSocket(wsUrl);
+
+ hermesWs.onopen = () => {
+ console.log('Hermes connected.');
+ wsConnected = true;
+ addChatMessage('system', 'Hermes link established.');
+ updateWsHudStatus(true);
+ refreshWorkshopPanel();
+ };
+
+ hermesWs.onmessage = (evt) => {
+ try {
+ const data = JSON.parse(evt.data);
+ handleHermesMessage(data);
+ } catch (e) {
+ console.error('Failed to parse Hermes message:', e);
+ }
+ };
+
+ hermesWs.onclose = () => {
+ console.warn('Hermes disconnected. Retrying in 5s...');
+ wsConnected = false;
+ hermesWs = null;
+ updateWsHudStatus(false);
+ refreshWorkshopPanel();
+ if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
+ wsReconnectTimer = setTimeout(connectHermes, 5000);
+ };
+
+ hermesWs.onerror = (err) => {
+ console.error('Hermes WS error:', err);
+ };
+}
+
+function handleHermesMessage(data) {
+ if (data.type === 'chat') {
+ addChatMessage(data.agent || 'timmy', data.text);
+ } else if (data.type === 'tool_call') {
+ const content = `Calling ${data.tool}(${JSON.stringify(data.args)})`;
+ recentToolOutputs.push({ type: 'call', agent: data.agent || 'SYSTEM', content });
+ addToolMessage(data.agent || 'SYSTEM', 'call', content);
+ refreshWorkshopPanel();
+ } else if (data.type === 'tool_result') {
+ const content = `Result: ${JSON.stringify(data.result)}`;
+ recentToolOutputs.push({ type: 'result', agent: data.agent || 'SYSTEM', content });
+ addToolMessage(data.agent || 'SYSTEM', 'result', content);
+ refreshWorkshopPanel();
+ } else if (data.type === 'history') {
+ const container = document.getElementById('chat-messages');
+ container.innerHTML = '';
+ data.messages.forEach(msg => {
+ if (msg.type === 'tool_call') addToolMessage(msg.agent, 'call', msg.content, false);
+ else if (msg.type === 'tool_result') addToolMessage(msg.agent, 'result', msg.content, false);
+ else addChatMessage(msg.agent, msg.text, false);
+ });
+ }
+}
+
+function updateWsHudStatus(connected) {
+ const dot = document.querySelector('.chat-status-dot');
+ if (dot) {
+ dot.style.background = connected ? '#4af0c0' : '#ff4466';
+ dot.style.boxShadow = connected ? '0 0 10px #4af0c0' : '0 0 10px #ff4466';
+ }
+}
+
+// ═══ SESSION PERSISTENCE ═══
+function saveSession() {
+ const msgs = Array.from(document.querySelectorAll('.chat-msg')).slice(-60).map(el => ({
+ html: el.innerHTML,
+ className: el.className
+ }));
+ localStorage.setItem('nexus_chat_history', JSON.stringify(msgs));
+}
+
+function loadSession() {
+ const saved = localStorage.getItem('nexus_chat_history');
+ if (saved) {
+ const msgs = JSON.parse(saved);
+ const container = document.getElementById('chat-messages');
+ container.innerHTML = '';
+ msgs.forEach(m => {
+ const div = document.createElement('div');
+ div.className = m.className;
+ div.innerHTML = m.html;
+ container.appendChild(div);
+ });
+ container.scrollTop = container.scrollHeight;
+ }
+}
+
+function addChatMessage(agent, text, shouldSave = true) {
const container = document.getElementById('chat-messages');
const div = document.createElement('div');
- div.className = `chat-msg chat-msg-${type}`;
- const prefixes = { user: '[ALEXANDER]', timmy: '[TIMMY]', system: '[NEXUS]', error: '[ERROR]' };
- div.innerHTML = `${prefixes[type] || '[???]'} ${text}`;
+ div.className = `chat-msg chat-msg-${agent}`;
+
+ const prefixes = {
+ user: '[ALEXANDER]',
+ timmy: '[TIMMY]',
+ system: '[NEXUS]',
+ error: '[ERROR]',
+ kimi: '[KIMI]',
+ claude: '[CLAUDE]',
+ perplexity: '[PERPLEXITY]'
+ };
+
+ const prefix = document.createElement('span');
+ prefix.className = 'chat-msg-prefix';
+ prefix.textContent = `${prefixes[agent] || '[' + agent.toUpperCase() + ']'} `;
+
+ div.appendChild(prefix);
+ div.appendChild(document.createTextNode(text));
+
container.appendChild(div);
container.scrollTop = container.scrollHeight;
+
+ if (shouldSave) saveSession();
+}
+
+function addToolMessage(agent, type, content, shouldSave = true) {
+ const container = document.getElementById('chat-messages');
+ const div = document.createElement('div');
+ div.className = `chat-msg chat-msg-tool tool-${type}`;
+
+ const prefix = document.createElement('div');
+ prefix.className = 'chat-msg-prefix';
+ prefix.textContent = `[${agent.toUpperCase()} TOOL ${type.toUpperCase()}]`;
+
+ const pre = document.createElement('pre');
+ pre.className = 'tool-content';
+ pre.textContent = content;
+
+ div.appendChild(prefix);
+ div.appendChild(pre);
+
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+
+ if (shouldSave) saveSession();
}
// ═══ PORTAL INTERACTION ═══
@@ -1230,6 +1546,8 @@ function gameLoop() {
harnessPulseMesh.material.opacity = Math.max(0, harnessPulseMesh.material.opacity - delta * 0.5);
}
+ updateAshStorm(delta, elapsed);
+
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
@@ -1395,6 +1713,15 @@ function gameLoop() {
composer.render();
+ updateAshStorm(delta, elapsed);
+ updatePortalTunnel(delta, elapsed);
+
+ if (workshopScanMat) workshopScanMat.uniforms.uTime.value = clock.getElapsedTime();
+ if (activePortal !== lastFocusedPortal) {
+ lastFocusedPortal = activePortal;
+ refreshWorkshopPanel();
+ }
+
frameCount++;
const now = performance.now();
if (now - lastFPSTime >= 1000) {
@@ -1486,4 +1813,72 @@ function triggerHarnessPulse() {
}
}
-init();
+// ═══ ASH STORM (MORROWIND) ═══
+let ashStormParticles;
+function createAshStorm() {
+ const count = 1000;
+ const geo = new THREE.BufferGeometry();
+ const pos = new Float32Array(count * 3);
+ const vel = new Float32Array(count * 3);
+
+ for (let i = 0; i < count; i++) {
+ pos[i * 3] = (Math.random() - 0.5) * 20;
+ pos[i * 3 + 1] = Math.random() * 10;
+ pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
+
+ vel[i * 3] = -0.05 - Math.random() * 0.1;
+ vel[i * 3 + 1] = -0.02 - Math.random() * 0.05;
+ vel[i * 3 + 2] = (Math.random() - 0.5) * 0.05;
+ }
+
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
+ geo.setAttribute('velocity', new THREE.BufferAttribute(vel, 3));
+
+ const mat = new THREE.PointsMaterial({
+ color: 0x886644,
+ size: 0.05,
+ transparent: true,
+ opacity: 0,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending
+ });
+
+ ashStormParticles = new THREE.Points(geo, mat);
+ ashStormParticles.position.set(15, 0, -10); // Center on Morrowind portal
+ scene.add(ashStormParticles);
+}
+
+function updateAshStorm(delta, elapsed) {
+ if (!ashStormParticles) return;
+
+ const morrowindPortalPos = new THREE.Vector3(15, 0, -10);
+ const dist = playerPos.distanceTo(morrowindPortalPos);
+ const intensity = Math.max(0, 1 - (dist / 12));
+
+ ashStormParticles.material.opacity = intensity * 0.4;
+
+ if (intensity > 0) {
+ const pos = ashStormParticles.geometry.attributes.position.array;
+ const vel = ashStormParticles.geometry.attributes.velocity.array;
+
+ for (let i = 0; i < pos.length / 3; i++) {
+ pos[i * 3] += vel[i * 3];
+ pos[i * 3 + 1] += vel[i * 3 + 1];
+ pos[i * 3 + 2] += vel[i * 3 + 2];
+
+ if (pos[i * 3 + 1] < 0 || Math.abs(pos[i * 3]) > 10 || Math.abs(pos[i * 3 + 2]) > 10) {
+ pos[i * 3] = (Math.random() - 0.5) * 20;
+ pos[i * 3 + 1] = 10;
+ pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
+ }
+ }
+ ashStormParticles.geometry.attributes.position.needsUpdate = true;
+ }
+}
+
+init().then(() => {
+ createAshStorm();
+ createPortalTunnel();
+ fetchGiteaData();
+ setInterval(fetchGiteaData, 30000);
+});
diff --git a/index.html b/index.html
index dd4d42d..cf8cede 100644
--- a/index.html
+++ b/index.html
@@ -106,6 +106,7 @@
WASD move Mouse look Enter chat
V mode: WALK
+ HERMES:
diff --git a/style.css b/style.css
index 407b5c8..d6bca56 100644
--- a/style.css
+++ b/style.css
@@ -533,7 +533,7 @@ canvas#nexus-canvas {
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 6px var(--color-primary);
- animation: dot-pulse 2s ease-in-out infinite;
+ transition: all 0.3s ease;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.6; }
@@ -570,6 +570,29 @@ canvas#nexus-canvas {
.chat-msg-prefix {
font-weight: 700;
}
+.chat-msg-kimi .chat-msg-prefix { color: var(--color-secondary); }
+.chat-msg-claude .chat-msg-prefix { color: var(--color-gold); }
+.chat-msg-perplexity .chat-msg-prefix { color: #4488ff; }
+
+/* Tool Output Styling */
+.chat-msg-tool {
+ background: rgba(0, 0, 0, 0.3);
+ border-left: 2px solid #ffd700;
+ font-size: 11px;
+ padding: 8px;
+ margin: 4px 0;
+ border-radius: 4px;
+}
+.tool-call { border-left-color: #ffd700; }
+.tool-result { border-left-color: #4af0c0; }
+.tool-content {
+ font-family: 'JetBrains Mono', monospace;
+ white-space: pre-wrap;
+ word-break: break-all;
+ opacity: 0.8;
+ margin: 4px 0 0 0;
+ color: #a0b8d0;
+}
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }