Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Whitestone
2aac7df086 feat: implement holographic memory bridge for Mnemosyne visuals
Some checks failed
CI / test (pull_request) Failing after 11s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 4s
2026-04-09 02:25:31 -04:00
Alexander Whitestone
cec0781d95 feat: restore frontend shell and implement Project Mnemosyne visual memory bridge
Some checks failed
CI / test (pull_request) Failing after 11s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 9s
2026-04-08 21:24:32 -04:00
52 changed files with 6926 additions and 4268 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,3 @@ nexus/__pycache__/
tests/__pycache__/
mempalace/__pycache__/
.aider*
# Prevent agents from writing to wrong path (see issue #1145)
public/nexus/

View File

@@ -42,17 +42,6 @@ Current repo contents are centered on:
Do not tell contributors to run Vite or edit a nonexistent root frontend on current `main`.
If browser/UI work is being restored, it must happen through the migration backlog and land back here.
## Canonical File Paths
**Frontend code lives at repo ROOT, NOT in `public/nexus/`:**
- `app.js` — main Three.js app (GOFAI, 3D world, all frontend logic)
- `index.html` — main HTML shell
- `style.css` — styles
- `server.py` — websocket bridge
- `gofai_worker.js` — web worker for off-thread reasoning
**DO NOT write to `public/nexus/`** — this path is gitignored. Agents historically wrote here by mistake, creating corrupt duplicates. See issue #1145 and `INVESTIGATION_ISSUE_1145.md`.
## Hard Rules
1. One canonical 3D repo only: `Timmy_Foundation/the-nexus`
@@ -61,7 +50,6 @@ If browser/UI work is being restored, it must happen through the migration backl
4. Telemetry and durable truth flow through Hermes harness
5. OpenClaw remains a sidecar, not the governing authority
6. Before claiming visual validation, prove the app being viewed actually comes from current `the-nexus`
7. **NEVER write frontend files to `public/nexus/`** — use repo root paths listed above
## Validation Rule

View File

@@ -1,72 +0,0 @@
# Investigation Report: Missing Source Code — Classical AI Commits Disappearing
**Issue:** #1145
**Date:** 2026-04-10
**Investigator:** mimo-v2-pro swarm worker
## Summary
**The classical AI code is NOT missing. It is fully present in root `app.js` (3302 lines).**
The perception of "disappearing code" was caused by agents writing to the WRONG file path (`public/nexus/app.js` instead of root `app.js`), creating corrupt duplicate files that were repeatedly overwritten and eventually deleted.
## Root Cause
**Explanation #1 confirmed: Duplicate agents on different machines overwriting each other's commits.**
Multiple Google AI Agent instances wrote GOFAI implementations to `public/nexus/app.js` — a path that does not correspond to the canonical app structure. These commits kept overwriting each other:
| Commit | Date | What happened |
|--------|------|---------------|
| `8943cf5` | 2026-03-30 | Symbolic reasoning engine written to `public/nexus/app.js` (+2280 lines) |
| `e2df240` | 2026-03-30 | Phase 3 Neuro-Symbolic Bridge — overwrote to 284 lines of HTML (wrong path) |
| `7f2f23f` | 2026-03-30 | Phase 4 Meta-Reasoning — same destructive overwrite |
| `bf3b98b` | 2026-03-30 | A* Search — same destructive overwrite |
| `e88bcb4` | 2026-03-30 | Bug fix identified `public/nexus/` files as corrupt duplicates, **deleted them** |
## Evidence: Code Is Present on Main
All 13 classical AI classes/functions verified present in root `app.js`:
| Class/Function | Line | Status |
|----------------|------|--------|
| `SymbolicEngine` | 82 | ✅ Present |
| `AgentFSM` | 135 | ✅ Present |
| `KnowledgeGraph` | 160 | ✅ Present |
| `Blackboard` | 181 | ✅ Present |
| `SymbolicPlanner` | 210 | ✅ Present |
| `HTNPlanner` | 295 | ✅ Present |
| `CaseBasedReasoner` | 343 | ✅ Present |
| `NeuroSymbolicBridge` | 392 | ✅ Present |
| `MetaReasoningLayer` | 422 | ✅ Present |
| `AdaptiveCalibrator` | 460 | ✅ Present |
| `PSELayer` | 566 | ✅ Present |
| `setupGOFAI()` | 596 | ✅ Present |
| `updateGOFAI()` | 622 | ✅ Present |
| Bitmask fact indexing | 86 | ✅ Present |
| A* search | 231 | ✅ Present |
These were injected by commit `af7a4c4` (PR #775, merged via `a855d54`) into the correct path.
## What Actually Happened
1. Google AI Agent wrote good GOFAI code to root `app.js` via the correct PR (#775)
2. A second wave of Google AI Agent instances also wrote to `public/nexus/app.js` (wrong path)
3. Those `public/nexus/` files kept getting overwritten by subsequent agent commits
4. Commit `e88bcb4` correctly identified the `public/nexus/` files as corrupt and deleted them
5. Alexander interpreted the git log as "classical AI code keeps disappearing"
6. The code was never actually gone — it just lived in root `app.js` the whole time
## Prevention Strategy
1. **Add `public/nexus/` to `.gitignore`** — prevents agents from accidentally writing to the wrong path again
2. **Add canonical path documentation to CLAUDE.md** — any agent reading this repo will know where frontend code lives
3. **This report** — serves as the audit trail so this confusion doesn't recur
## Acceptance Criteria
- [x] Git history audited for classical AI commits
- [x] Found the commits — they exist, code was written to wrong path
- [x] Root cause identified — duplicate agents writing to `public/nexus/` (wrong path)
- [x] Prevention strategy implemented — `.gitignore` + `CLAUDE.md` path guard
- [x] Report filed with findings (this document)

480
app.js
View File

@@ -3,8 +3,6 @@ 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 { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
import { SessionRooms } from './nexus/components/session-rooms.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -705,8 +703,6 @@ async function init() {
createSessionPowerMeter();
createWorkshopTerminal();
createAshStorm();
SpatialMemory.init(scene);
SessionRooms.init(scene, camera, null);
updateLoad(90);
loadSession();
@@ -1885,7 +1881,7 @@ function setupControls() {
orbitState.lastX = e.clientX;
orbitState.lastY = e.clientY;
// Raycasting for portals and memory crystals
// Raycasting for portals
if (!portalOverlayActive) {
const mouse = new THREE.Vector2(
(e.clientX / window.innerWidth) * 2 - 1,
@@ -1893,43 +1889,12 @@ function setupControls() {
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
// Priority 1: Portals
const portalHits = raycaster.intersectObjects(portals.map(p => p.ring));
if (portalHits.length > 0) {
const clickedRing = portalHits[0].object;
const intersects = raycaster.intersectObjects(portals.map(p => p.ring));
if (intersects.length > 0) {
const clickedRing = intersects[0].object;
const portal = portals.find(p => p.ring === clickedRing);
if (portal) { activatePortal(portal); return; }
if (portal) activatePortal(portal);
}
// Priority 2: Memory crystals (Mnemosyne)
const crystalMeshes = SpatialMemory.getCrystalMeshes();
if (crystalMeshes.length > 0) {
const crystalHits = raycaster.intersectObjects(crystalMeshes, false);
if (crystalHits.length > 0) {
const hitMesh = crystalHits[0].object;
const memInfo = SpatialMemory.getMemoryFromMesh(hitMesh);
if (memInfo) {
SpatialMemory.highlightMemory(memInfo.data.id);
showMemoryPanel(memInfo, e.clientX, e.clientY);
return;
}
}
}
// Priority 3: Session rooms (Mnemosyne #1171)
const roomMeshes = SessionRooms.getClickableMeshes();
if (roomMeshes.length > 0) {
const roomHits = raycaster.intersectObjects(roomMeshes, false);
if (roomHits.length > 0) {
const session = SessionRooms.handleRoomClick(roomHits[0].object);
if (session) { _showSessionRoomPanel(session); return; }
}
}
// Clicked empty space — dismiss panel
dismissMemoryPanel();
_dismissSessionRoomPanel();
}
}
});
@@ -2584,226 +2549,6 @@ function focusPortal(portal) {
let lastThoughtTime = 0;
let pulseTimer = 0;
// ═══════════════════════════════════════════
// MNEMOSYNE — MEMORY CRYSTAL INSPECTION
// ═══════════════════════════════════════════
// ── pin state for memory panel ──
let _memPanelPinned = false;
/** Convert a packed hex color integer to "r,g,b" string for CSS rgba(). */
function _hexToRgb(hex) {
return ((hex >> 16) & 255) + ',' + ((hex >> 8) & 255) + ',' + (hex & 255);
}
/**
* Position the panel near the screen click coordinates, keeping it on-screen.
*/
function _positionPanel(panel, clickX, clickY) {
const W = window.innerWidth;
const H = window.innerHeight;
const panelW = 356; // matches CSS width + padding
const panelH = 420; // generous estimate
const margin = 12;
let left = clickX + 24;
if (left + panelW > W - margin) left = clickX - panelW - 24;
left = Math.max(margin, Math.min(W - panelW - margin, left));
let top = clickY - 80;
top = Math.max(margin, Math.min(H - panelH - margin, top));
panel.style.right = 'auto';
panel.style.top = top + 'px';
panel.style.left = left + 'px';
panel.style.transform = 'none';
}
/**
* Navigate to (highlight + show panel for) a memory crystal by id.
*/
function _navigateToMemory(memId) {
SpatialMemory.highlightMemory(memId);
addChatMessage('system', `Focus: ${memId.replace(/_/g, ' ')}`);
const meshes = SpatialMemory.getCrystalMeshes();
for (const mesh of meshes) {
if (mesh.userData && mesh.userData.memId === memId) {
const memInfo = SpatialMemory.getMemoryFromMesh(mesh);
if (memInfo) { showMemoryPanel(memInfo); break; }
}
}
}
/**
* Show the holographic detail panel for a clicked crystal.
* @param {object} memInfo — { data, region } from SpatialMemory.getMemoryFromMesh()
* @param {number} [clickX] — screen X of the click (for panel positioning)
* @param {number} [clickY] — screen Y of the click
*/
function showMemoryPanel(memInfo, clickX, clickY) {
const panel = document.getElementById('memory-panel');
if (!panel) return;
const { data, region } = memInfo;
const regionDef = SpatialMemory.REGIONS[region] || SpatialMemory.REGIONS.working;
const colorHex = regionDef.color.toString(16).padStart(6, '0');
const colorRgb = _hexToRgb(regionDef.color);
// Header — region dot + label
document.getElementById('memory-panel-region').textContent = regionDef.label;
document.getElementById('memory-panel-region-dot').style.background = '#' + colorHex;
// Category badge
const badge = document.getElementById('memory-panel-category-badge');
if (badge) {
badge.textContent = (data.category || region || 'memory').toUpperCase();
badge.style.background = 'rgba(' + colorRgb + ',0.16)';
badge.style.color = '#' + colorHex;
badge.style.borderColor = 'rgba(' + colorRgb + ',0.4)';
}
// Entity name (humanised id)
const entityEl = document.getElementById('memory-panel-entity-name');
if (entityEl) entityEl.textContent = (data.id || '\u2014').replace(/_/g, ' ');
// Fact content
document.getElementById('memory-panel-content').textContent = data.content || '(empty)';
// Trust score bar
const strength = data.strength != null ? data.strength : 0.7;
const trustFill = document.getElementById('memory-panel-trust-fill');
const trustVal = document.getElementById('memory-panel-trust-value');
if (trustFill) {
trustFill.style.width = (strength * 100).toFixed(0) + '%';
trustFill.style.background = '#' + colorHex;
}
if (trustVal) trustVal.textContent = (strength * 100).toFixed(0) + '%';
// Meta rows
document.getElementById('memory-panel-id').textContent = data.id || '\u2014';
document.getElementById('memory-panel-source').textContent = data.source || 'unknown';
document.getElementById('memory-panel-time').textContent = data.timestamp ? new Date(data.timestamp).toLocaleString() : '\u2014';
// Related entities — clickable links
const connEl = document.getElementById('memory-panel-connections');
connEl.innerHTML = '';
if (data.connections && data.connections.length > 0) {
data.connections.forEach(cid => {
const btn = document.createElement('button');
btn.className = 'memory-conn-tag memory-conn-link';
btn.textContent = cid.replace(/_/g, ' ');
btn.title = 'Go to: ' + cid;
btn.addEventListener('click', (ev) => { ev.stopPropagation(); _navigateToMemory(cid); });
connEl.appendChild(btn);
});
} else {
connEl.innerHTML = '<span style="color:var(--color-text-muted)">None</span>';
}
// Pin button — reset on fresh open
_memPanelPinned = false;
const pinBtn = document.getElementById('memory-panel-pin');
if (pinBtn) {
pinBtn.classList.remove('pinned');
pinBtn.title = 'Pin panel';
pinBtn.onclick = () => {
_memPanelPinned = !_memPanelPinned;
pinBtn.classList.toggle('pinned', _memPanelPinned);
pinBtn.title = _memPanelPinned ? 'Unpin panel' : 'Pin panel';
};
}
// Positioning — near click if coords provided
if (clickX != null && clickY != null) {
_positionPanel(panel, clickX, clickY);
}
// Fade in
panel.classList.remove('memory-panel-fade-out');
panel.style.display = 'flex';
}
/**
* Dismiss the panel (respects pin). Called on empty-space click.
*/
function dismissMemoryPanel() {
if (_memPanelPinned) return;
_dismissMemoryPanelForce();
}
/**
* Force-dismiss the panel regardless of pin state. Used by the close button.
*/
function _dismissMemoryPanelForce() {
_memPanelPinned = false;
SpatialMemory.clearHighlight();
const panel = document.getElementById('memory-panel');
if (!panel || panel.style.display === 'none') return;
panel.classList.add('memory-panel-fade-out');
setTimeout(() => {
panel.style.display = 'none';
panel.classList.remove('memory-panel-fade-out');
}, 200);
}
/**
* Show the session room HUD panel when a chamber is entered.
* @param {object} session — { id, timestamp, facts[] }
*/
function _showSessionRoomPanel(session) {
const panel = document.getElementById('session-room-panel');
if (!panel) return;
const dt = session.timestamp ? new Date(session.timestamp) : new Date();
const tsEl = document.getElementById('session-room-timestamp');
if (tsEl) tsEl.textContent = isNaN(dt.getTime()) ? session.id : dt.toLocaleString();
const countEl = document.getElementById('session-room-fact-count');
const facts = session.facts || [];
if (countEl) countEl.textContent = facts.length + (facts.length === 1 ? ' fact' : ' facts') + ' in this chamber';
const listEl = document.getElementById('session-room-facts');
if (listEl) {
listEl.innerHTML = '';
facts.slice(0, 8).forEach(f => {
const item = document.createElement('div');
item.className = 'session-room-fact-item';
item.textContent = f.content || f.id || '(unknown)';
item.title = f.content || '';
listEl.appendChild(item);
});
if (facts.length > 8) {
const more = document.createElement('div');
more.className = 'session-room-fact-item';
more.style.color = 'rgba(200,180,255,0.4)';
more.textContent = '\u2026 ' + (facts.length - 8) + ' more';
listEl.appendChild(more);
}
}
// Close button
const closeBtn = document.getElementById('session-room-close');
if (closeBtn) closeBtn.onclick = () => _dismissSessionRoomPanel();
panel.classList.remove('session-panel-fade-out');
panel.style.display = 'block';
}
/**
* Dismiss the session room panel.
*/
function _dismissSessionRoomPanel() {
const panel = document.getElementById('session-room-panel');
if (!panel || panel.style.display === 'none') return;
panel.classList.add('session-panel-fade-out');
setTimeout(() => {
panel.style.display = 'none';
panel.classList.remove('session-panel-fade-out');
}, 200);
}
function gameLoop() {
requestAnimationFrame(gameLoop);
const delta = Math.min(clock.getDelta(), 0.1);
@@ -2828,16 +2573,6 @@ function gameLoop() {
updateAshStorm(delta, elapsed);
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
animateMemoryOrbs(delta);
}
// Project Mnemosyne - Session Rooms (#1171)
SessionRooms.update(delta);
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
@@ -3036,12 +2771,6 @@ function gameLoop() {
composer.render();
updateAshStorm(delta, elapsed);
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
animateMemoryOrbs(delta);
}
updatePortalTunnel(delta, elapsed);
if (workshopScanMat) workshopScanMat.uniforms.uTime.value = clock.getElapsedTime();
@@ -3204,208 +2933,9 @@ function updateAshStorm(delta, elapsed) {
}
}
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — HOLOGRAPHIC MEMORY ORBS
// ═══════════════════════════════════════════
// Memory orbs registry for animation loop
const memoryOrbs = [];
/**
* Spawn a glowing memory orb at the given position.
* Used to visualize RAG retrievals and memory recalls in the Nexus.
*
* @param {THREE.Vector3} position - World position for the orb
* @param {number} color - Hex color (default: 0x4af0c0 - cyan)
* @param {number} size - Radius of the orb (default: 0.5)
* @param {object} metadata - Optional metadata for the memory (source, timestamp, etc.)
* @returns {THREE.Mesh} The created orb mesh
*/
function spawnMemoryOrb(position, color = 0x4af0c0, size = 0.5, metadata = {}) {
if (typeof THREE === 'undefined' || typeof scene === 'undefined') {
console.warn('[Mnemosyne] THREE/scene not available for orb spawn');
return null;
}
const geometry = new THREE.SphereGeometry(size, 32, 32);
const material = new THREE.MeshStandardMaterial({
color: color,
emissive: color,
emissiveIntensity: 2.5,
metalness: 0.3,
roughness: 0.2,
transparent: true,
opacity: 0.85,
envMapIntensity: 1.5
});
const orb = new THREE.Mesh(geometry, material);
orb.position.copy(position);
orb.castShadow = true;
orb.receiveShadow = true;
orb.userData = {
type: 'memory_orb',
pulse: Math.random() * Math.PI * 2, // Random phase offset
pulseSpeed: 0.002 + Math.random() * 0.001,
originalScale: size,
metadata: metadata,
createdAt: Date.now()
};
// Point light for local illumination
const light = new THREE.PointLight(color, 1.5, 8);
orb.add(light);
scene.add(orb);
memoryOrbs.push(orb);
console.info('[Mnemosyne] Memory orb spawned:', metadata.source || 'unknown');
return orb;
}
/**
* Remove a memory orb from the scene and dispose resources.
* @param {THREE.Mesh} orb - The orb to remove
*/
function removeMemoryOrb(orb) {
if (!orb) return;
if (orb.parent) orb.parent.remove(orb);
if (orb.geometry) orb.geometry.dispose();
if (orb.material) orb.material.dispose();
const idx = memoryOrbs.indexOf(orb);
if (idx > -1) memoryOrbs.splice(idx, 1);
}
/**
* Animate all memory orbs — pulse, rotate, and fade.
* Called from gameLoop() every frame.
* @param {number} delta - Time since last frame
*/
function animateMemoryOrbs(delta) {
for (let i = memoryOrbs.length - 1; i >= 0; i--) {
const orb = memoryOrbs[i];
if (!orb || !orb.userData) continue;
// Pulse animation
orb.userData.pulse += orb.userData.pulseSpeed * delta * 1000;
const pulseFactor = 1 + Math.sin(orb.userData.pulse) * 0.1;
orb.scale.setScalar(pulseFactor * orb.userData.originalScale);
// Gentle rotation
orb.rotation.y += delta * 0.5;
// Fade after 30 seconds
const age = (Date.now() - orb.userData.createdAt) / 1000;
if (age > 30) {
const fadeDuration = 10;
const fadeProgress = Math.min(1, (age - 30) / fadeDuration);
orb.material.opacity = 0.85 * (1 - fadeProgress);
if (fadeProgress >= 1) {
removeMemoryOrb(orb);
i--; // Adjust index after removal
}
}
}
}
/**
* Spawn memory orbs arranged in a spiral for RAG retrieval results.
* @param {Array} results - Array of {content, score, source}
* @param {THREE.Vector3} center - Center position (default: above avatar)
*/
function spawnRetrievalOrbs(results, center) {
if (!results || !Array.isArray(results) || results.length === 0) return;
if (!center) {
center = new THREE.Vector3(0, 2, 0);
}
const colors = [0x4af0c0, 0x7b5cff, 0xffd700, 0xff4466, 0x00ff88];
const radius = 3;
results.forEach((result, i) => {
const angle = (i / results.length) * Math.PI * 2;
const height = (i / results.length) * 2 - 1;
const position = new THREE.Vector3(
center.x + Math.cos(angle) * radius,
center.y + height,
center.z + Math.sin(angle) * radius
);
const colorIdx = Math.min(colors.length - 1, Math.floor((result.score || 0.5) * colors.length));
const size = 0.3 + (result.score || 0.5) * 0.4;
spawnMemoryOrb(position, colors[colorIdx], size, {
source: result.source || 'unknown',
score: result.score || 0,
contentPreview: (result.content || '').substring(0, 100)
});
});
}
init().then(() => {
createAshStorm();
createPortalTunnel();
// Project Mnemosyne — seed demo spatial memories
const demoMemories = [
{ id: 'mem_nexus_birth', content: 'The Nexus came online — first render of the 3D world', category: 'knowledge', strength: 0.95, connections: ['mem_mnemosyne_start'] },
{ id: 'mem_first_portal', content: 'First portal deployed — connection to external service', category: 'engineering', strength: 0.85, connections: ['mem_nexus_birth'] },
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.7, connections: [] },
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.9, connections: ['mem_nexus_birth', 'mem_spatial_schema'] },
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain permanent homes', category: 'engineering', strength: 0.8, connections: ['mem_mnemosyne_start'] },
// MemPalace category zone demos — issue #1168
{ id: 'mem_pref_dark_mode', content: 'User prefers dark mode and monospace fonts', category: 'user_pref', strength: 0.9, connections: [] },
{ id: 'mem_pref_verbose_logs', content: 'User prefers verbose logging during debug sessions', category: 'user_pref', strength: 0.7, connections: [] },
{ id: 'mem_proj_nexus_goal', content: 'The Nexus goal: local-first 3D training ground for Timmy', category: 'project', strength: 0.95, connections: ['mem_proj_mnemosyne'] },
{ id: 'mem_proj_mnemosyne', content: 'Project Mnemosyne: holographic living archive of facts', category: 'project', strength: 0.85, connections: ['mem_proj_nexus_goal'] },
{ id: 'mem_tool_three_js', content: 'Three.js — 3D rendering library used for the Nexus world', category: 'tool', strength: 0.8, connections: [] },
{ id: 'mem_tool_gitea', content: 'Gitea API at forge.alexanderwhitestone.com for issue tracking', category: 'tool', strength: 0.75, connections: [] },
{ id: 'mem_gen_websocket', content: 'WebSocket bridge (server.py) connects Timmy cognition to the browser', category: 'general', strength: 0.7, connections: [] },
{ id: 'mem_gen_hermes', content: 'Hermes harness: telemetry and durable truth pipeline', category: 'general', strength: 0.65, connections: [] },
];
demoMemories.forEach(m => SpatialMemory.placeMemory(m));
// Gravity well clustering — attract related crystals, bake positions (issue #1175)
SpatialMemory.runGravityLayout();
// Project Mnemosyne — seed demo session rooms (#1171)
// Sessions group facts by conversation/work session with a timestamp.
const demoSessions = [
{
id: 'session_2026_03_01',
timestamp: '2026-03-01T10:00:00.000Z',
facts: [
{ id: 'mem_nexus_birth', content: 'The Nexus came online — first render of the 3D world', category: 'knowledge', strength: 0.95 },
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.9 },
]
},
{
id: 'session_2026_03_15',
timestamp: '2026-03-15T14:30:00.000Z',
facts: [
{ id: 'mem_first_portal', content: 'First portal deployed — connection to external service', category: 'engineering', strength: 0.85 },
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.7 },
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain homes', category: 'engineering', strength: 0.8 },
]
},
{
id: 'session_2026_04_10',
timestamp: '2026-04-10T09:00:00.000Z',
facts: [
{ id: 'mem_session_rooms', content: 'Session rooms introduced — holographic chambers per session', category: 'projects', strength: 0.88 },
{ id: 'mem_gravity_wells', content: 'Gravity-well clustering bakes crystal positions on load', category: 'engineering', strength: 0.75 },
]
}
];
SessionRooms.updateSessions(demoSessions);
fetchGiteaData();
setInterval(fetchGiteaData, 30000);
runWeeklyAudit();

View File

@@ -152,55 +152,17 @@ class OpenAITTSAdapter:
return mp3_path
class EdgeTTSAdapter:
"""Zero-cost TTS using Microsoft Edge neural voices (no API key required).
Requires: pip install edge-tts>=6.1.9
Voices: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support
"""
DEFAULT_VOICE = "en-US-GuyNeural"
def __init__(self, config: TTSConfig):
self.config = config
self.voice = config.voice_id or self.DEFAULT_VOICE
def synthesize(self, text: str, output_path: Path) -> Path:
try:
import edge_tts
except ImportError:
raise RuntimeError("edge-tts not installed. Run: pip install edge-tts")
import asyncio
mp3_path = output_path.with_suffix(".mp3")
async def _run():
communicate = edge_tts.Communicate(text, self.voice)
await communicate.save(str(mp3_path))
asyncio.run(_run())
return mp3_path
ADAPTERS = {
"piper": PiperAdapter,
"elevenlabs": ElevenLabsAdapter,
"openai": OpenAITTSAdapter,
"edge-tts": EdgeTTSAdapter,
}
def get_provider_config() -> TTSConfig:
"""Load TTS configuration from environment."""
provider = os.environ.get("DEEPDIVE_TTS_PROVIDER", "openai")
if provider == "openai":
default_voice = "alloy"
elif provider == "edge-tts":
default_voice = EdgeTTSAdapter.DEFAULT_VOICE
else:
default_voice = "matthew"
voice = os.environ.get("DEEPDIVE_TTS_VOICE", default_voice)
voice = os.environ.get("DEEPDIVE_TTS_VOICE", "alloy" if provider == "openai" else "matthew")
return TTSConfig(
provider=provider,

View File

@@ -32,14 +32,12 @@ import importlib.util
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logging.basicConfig(
level=logging.INFO,
@@ -214,46 +212,6 @@ def generate_report(date_str: str, checker_mod) -> str:
return "\n".join(lines)
# ── Voice memo ────────────────────────────────────────────────────────
def _generate_voice_memo(report_text: str, date_str: str) -> Optional[str]:
"""Generate an MP3 voice memo of the night watch report.
Returns the output path on success, or None if generation fails.
"""
try:
import edge_tts
except ImportError:
logger.warning("edge-tts not installed; skipping voice memo. Run: pip install edge-tts")
return None
import asyncio
# Strip markdown formatting for cleaner speech
clean = report_text
clean = re.sub(r"#+\s*", "", clean) # headings
clean = re.sub(r"\|", " ", clean) # table pipes
clean = re.sub(r"\*+", "", clean) # bold/italic markers
clean = re.sub(r"-{3,}", "", clean) # horizontal rules
clean = re.sub(r"\s{2,}", " ", clean) # collapse extra whitespace
output_dir = Path("/tmp/bezalel")
output_dir.mkdir(parents=True, exist_ok=True)
mp3_path = output_dir / f"night-watch-{date_str}.mp3"
try:
async def _run():
communicate = edge_tts.Communicate(clean.strip(), "en-US-GuyNeural")
await communicate.save(str(mp3_path))
asyncio.run(_run())
logger.info("Voice memo written to %s", mp3_path)
return str(mp3_path)
except Exception as exc:
logger.warning("Voice memo generation failed: %s", exc)
return None
# ── Entry point ───────────────────────────────────────────────────────
def main() -> None:
@@ -268,10 +226,6 @@ def main() -> None:
"--dry-run", action="store_true",
help="Print report to stdout instead of writing to disk",
)
parser.add_argument(
"--voice-memo", action="store_true",
help="Generate an MP3 voice memo of the report using edge-tts (saved to /tmp/bezalel/)",
)
args = parser.parse_args()
date_str = args.date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
@@ -288,14 +242,6 @@ def main() -> None:
report_path.write_text(report_text)
logger.info("Night Watch report written to %s", report_path)
if args.voice_memo:
try:
memo_path = _generate_voice_memo(report_text, date_str)
if memo_path:
logger.info("Voice memo: %s", memo_path)
except Exception as exc:
logger.warning("Voice memo failed (non-fatal): %s", exc)
if __name__ == "__main__":
main()

View File

@@ -1,46 +0,0 @@
version: "3.9"
# Sandboxed desktop environment for Hermes computer-use primitives.
# Provides Xvfb (virtual framebuffer) + noVNC (browser-accessible VNC).
#
# Usage:
# docker compose -f docker-compose.desktop.yml up -d
# # Visit http://localhost:6080 to see the virtual desktop
#
# docker compose -f docker-compose.desktop.yml run hermes-desktop \
# python -m nexus.computer_use_demo
#
# docker compose -f docker-compose.desktop.yml down
services:
hermes-desktop:
image: dorowu/ubuntu-desktop-lxde-vnc:focal
environment:
# Resolution for the virtual display
RESOLUTION: "1280x800"
# VNC password (change in production)
VNC_PASSWORD: "hermes"
# Disable HTTP password for development convenience
HTTP_PASSWORD: ""
ports:
# noVNC web interface
- "6080:80"
# Raw VNC port (optional)
- "5900:5900"
volumes:
# Mount repo into container so scripts are available
- .:/workspace
# Persist nexus runtime data (heartbeats, logs, evidence)
- nexus_data:/root/.nexus
working_dir: /workspace
shm_size: "256mb"
# Install Python deps on startup then keep container alive
command: >
bash -c "
pip install --quiet pyautogui Pillow &&
/startup.sh
"
volumes:
nexus_data:
driver: local

View File

@@ -1,174 +0,0 @@
# Computer Use — Desktop Automation Primitives for Hermes
Issue: [#1125](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1125)
## Overview
`nexus/computer_use.py` adds desktop automation primitives to the Hermes fleet. Agents can take screenshots, click, type, and scroll — enough to drive a browser, validate a UI, or diagnose a failed workflow page visually.
All actions are logged to a JSONL audit trail at `~/.nexus/computer_use_actions.jsonl`.
---
## Quick Start
### Local (requires a real display or Xvfb)
```bash
# Install dependencies
pip install pyautogui Pillow
# Run the Phase 1 demo
python -m nexus.computer_use_demo
```
### Sandboxed (Docker + Xvfb + noVNC)
```bash
docker compose -f docker-compose.desktop.yml up -d
# Visit http://localhost:6080 in your browser to see the virtual desktop
docker compose -f docker-compose.desktop.yml run hermes-desktop \
python -m nexus.computer_use_demo
docker compose -f docker-compose.desktop.yml down
```
---
## API Reference
### `computer_screenshot(save_path=None, log_path=...)`
Capture the current desktop.
| Param | Type | Description |
|-------|------|-------------|
| `save_path` | `str \| None` | Path to save PNG. If `None`, returns base64 string. |
| `log_path` | `Path` | Audit log file. |
**Returns** `dict`:
```json
{
"ok": true,
"image_b64": "<base64 PNG or null>",
"saved_to": "<path or null>",
"error": null
}
```
---
### `computer_click(x, y, button="left", confirm=False, log_path=...)`
Click the mouse at screen coordinates.
| Param | Type | Description |
|-------|------|-------------|
| `x` | `int` | Horizontal coordinate |
| `y` | `int` | Vertical coordinate |
| `button` | `str` | `"left"` \| `"right"` \| `"middle"` |
| `confirm` | `bool` | Required `True` for `right` / `middle` (poka-yoke) |
**Returns** `dict`:
```json
{"ok": true, "error": null}
```
---
### `computer_type(text, confirm=False, interval=0.02, log_path=...)`
Type text using the keyboard.
| Param | Type | Description |
|-------|------|-------------|
| `text` | `str` | Text to type |
| `confirm` | `bool` | Required `True` when text contains a sensitive keyword |
| `interval` | `float` | Delay between keystrokes (seconds) |
**Sensitive keywords** (require `confirm=True`): `password`, `passwd`, `secret`, `token`, `api_key`, `apikey`, `key`, `auth`
> Note: the actual `text` value is never written to the audit log — only its length and whether it was flagged as sensitive.
**Returns** `dict`:
```json
{"ok": true, "error": null}
```
---
### `computer_scroll(x, y, amount=3, log_path=...)`
Scroll the mouse wheel at screen coordinates.
| Param | Type | Description |
|-------|------|-------------|
| `x` | `int` | Horizontal coordinate |
| `y` | `int` | Vertical coordinate |
| `amount` | `int` | Scroll units. Positive = up, negative = down. |
**Returns** `dict`:
```json
{"ok": true, "error": null}
```
---
### `read_action_log(n=20, log_path=...)`
Return the most recent `n` audit log entries, newest first.
```python
from nexus.computer_use import read_action_log
for entry in read_action_log(n=5):
print(entry["ts"], entry["action"], entry["result"]["ok"])
```
---
## Safety Model
| Action | Safety gate |
|--------|-------------|
| `computer_click(button="right")` | Requires `confirm=True` |
| `computer_click(button="middle")` | Requires `confirm=True` |
| `computer_type` with sensitive text | Requires `confirm=True` |
| Mouse to top-left corner | pyautogui FAILSAFE — aborts immediately |
| All actions | Written to JSONL audit log with timestamp |
| Headless environment | All tools degrade gracefully — return `ok=False` with error message |
---
## Phase Roadmap
### Phase 1 — Environment & Primitives ✅
- Sandboxed desktop via Xvfb + noVNC (`docker-compose.desktop.yml`)
- `computer_screenshot`, `computer_click`, `computer_type`, `computer_scroll`
- Poka-yoke safety checks on all destructive actions
- JSONL audit log for all actions
- Demo: baseline screenshot → open browser → navigate to Gitea → evidence screenshot
- 32 unit tests, fully headless (pyautogui mocked)
### Phase 2 — Tool Integration (planned)
- Register tools in the Hermes tool registry
- LLM-based planner loop using screenshots as context
- Destructive action confirmation UI
### Phase 3 — Use-Case Pilots (planned)
- Pilot 1: Automated visual regression test for fleet dashboard
- Pilot 2: Screenshot-based diagnosis of failed CI workflow page
---
## File Locations
| File | Purpose |
|------|---------|
| `nexus/computer_use.py` | Core tool primitives |
| `nexus/computer_use_demo.py` | Phase 1 end-to-end demo |
| `tests/test_computer_use.py` | 32 unit tests |
| `docker-compose.desktop.yml` | Sandboxed desktop container |
| `~/.nexus/computer_use_actions.jsonl` | Runtime audit log |
| `~/.nexus/computer_use_evidence/` | Screenshot evidence (demo output) |

View File

@@ -1,135 +0,0 @@
# Voice Output System
## Overview
The Nexus voice output system converts text reports and briefings into spoken audio.
It supports multiple TTS providers with automatic fallback so that audio generation
degrades gracefully when a provider is unavailable.
Primary use cases:
- **Deep Dive** daily briefings (`bin/deepdive_tts.py`)
- **Night Watch** nightly reports (`bin/night_watch.py --voice-memo`)
---
## Available Providers
### edge-tts (recommended default)
- **Cost:** Zero — no API key, no account required
- **Package:** `pip install edge-tts>=6.1.9`
- **Default voice:** `en-US-GuyNeural`
- **Output format:** MP3
- **How it works:** Streams audio from Microsoft Edge's neural TTS service over HTTPS.
No local model download required.
- **Available locales:** 100+ languages and locales. Full list:
https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support
Notable English voices:
| Voice ID | Style |
|---|---|
| `en-US-GuyNeural` | Neutral male (default) |
| `en-US-JennyNeural` | Warm female |
| `en-US-AriaNeural` | Expressive female |
| `en-GB-RyanNeural` | British male |
### piper
- **Cost:** Free, fully offline
- **Package:** `pip install piper-tts` + model download (~65 MB)
- **Model location:** `~/.local/share/piper/en_US-lessac-medium.onnx`
- **Output format:** WAV → MP3 (requires `lame`)
- **Sovereignty:** Fully local; no network calls after model download
### elevenlabs
- **Cost:** Usage-based (paid)
- **Requirement:** `ELEVENLABS_API_KEY` environment variable
- **Output format:** MP3
- **Quality:** Highest quality of the three providers
### openai
- **Cost:** Usage-based (paid)
- **Requirement:** `OPENAI_API_KEY` environment variable
- **Output format:** MP3
- **Default voice:** `alloy`
---
## Usage: deepdive_tts.py
```bash
# Use edge-tts (zero cost)
DEEPDIVE_TTS_PROVIDER=edge-tts python bin/deepdive_tts.py --text "Good morning."
# Specify a different Edge voice
python bin/deepdive_tts.py --provider edge-tts --voice en-US-JennyNeural --text "Hello world."
# Read from a file
python bin/deepdive_tts.py --provider edge-tts --input-file /tmp/briefing.txt --output /tmp/briefing
# Use OpenAI
OPENAI_API_KEY=sk-... python bin/deepdive_tts.py --provider openai --voice nova --text "Hello."
# Use ElevenLabs
ELEVENLABS_API_KEY=... python bin/deepdive_tts.py --provider elevenlabs --voice rachel --text "Hello."
# Use local Piper (offline)
python bin/deepdive_tts.py --provider piper --text "Hello."
```
Provider and voice can also be set via environment variables:
```bash
export DEEPDIVE_TTS_PROVIDER=edge-tts
export DEEPDIVE_TTS_VOICE=en-GB-RyanNeural
python bin/deepdive_tts.py --text "Good evening."
```
---
## Usage: Night Watch --voice-memo
The `--voice-memo` flag causes Night Watch to generate an MP3 audio summary of the
nightly report immediately after writing the markdown file.
```bash
python bin/night_watch.py --voice-memo
```
Output location: `/tmp/bezalel/night-watch-<YYYY-MM-DD>.mp3`
The voice memo:
- Strips markdown formatting (`#`, `|`, `*`, `---`) for cleaner speech
- Uses `edge-tts` with the `en-US-GuyNeural` voice
- Is non-fatal: if TTS fails, the markdown report is still written normally
Example crontab with voice memo:
```cron
0 3 * * * cd /path/to/the-nexus && python bin/night_watch.py --voice-memo \
>> /var/log/bezalel/night-watch.log 2>&1
```
---
## Fallback Chain
`HybridTTS` (used by `tts_engine.py`) attempts providers in this order:
1. **edge-tts** — zero cost, no API key
2. **piper** — offline local model (if model file present)
3. **elevenlabs** — cloud fallback (if `ELEVENLABS_API_KEY` set)
If `prefer_cloud=True` is passed, the order becomes: elevenlabs → piper.
---
## Phase 3 TODO
Evaluate **fish-speech** and **F5-TTS** as fully offline, sovereign alternatives
with higher voice quality than Piper. These models run locally with no network
dependency whatsoever, providing complete independence from Microsoft's Edge service.
Tracking: to be filed as a follow-up to issue #830.

View File

@@ -1,72 +0,0 @@
# Hermes Trismegistus — Wizard Proposal
> **Status:** 🟡 DEFERRED
> **Issue:** #1146
> **Created:** 2026-04-08
> **Author:** Alexander (KT Notes)
> **Mimo Worker:** mimo-code-1146-1775851759
---
## Identity
| Field | Value |
|-------|-------|
| **Name** | Hermes Trismegistus |
| **Nature** | Claude-native wizard. She knows she runs on Claude. She's "the daughter of Claude" and leans into that heritage. |
| **Purpose** | Dedicated reasoning and architecture wizard. Only handles tasks where Claude's reasoning capability genuinely adds value — planning, novel problem-solving, complex architecture decisions. |
| **Not** | A replacement for Timmy. Not competing for identity. Not doing monkey work. |
## Design Constraints
- **Free tier only from day one.** Alexander is not paying Anthropic beyond current subscription.
- **Degrades gracefully.** Full capability when free tier is generous, reduced scope when constrained.
- **Not locked to Claude.** If better free-tier providers emerge, she can route to them.
- **Multi-provider capable.** Welcome to become multifaceted if team finds better options.
## Hardware
- One of Alexander's shed laptops — minimum 4GB RAM, Ubuntu
- Dedicated machine, not shared with Timmy's Mac
- Runs in the Hermes harness
- Needs power at house first
## Constitutional Foundation
- The KT conversation and documents serve as her founding constitution
- Team (especially Timmy) has final say on whether she gets built
- Must justify her existence through useful work, same as every wizard
## Trigger to Unblock
All of the following must be true before implementation begins:
- [ ] Deadman switch wired and proven
- [ ] Config stable across fleet
- [ ] Fleet proven reliable for 1+ week
- [ ] Alexander provides a state-of-the-system KT to Claude for instantiation
## Acceptance Criteria
- [ ] Dedicated KT document written for Hermes instantiation
- [ ] Hardware provisioned (shed laptop with power)
- [ ] Hermes harness configured for Claude free tier
- [ ] Lazerus registry entry with health endpoints
- [ ] Fleet routing entry with role and routing verdict
- [ ] SOUL.md inscription drafted and reviewed by Timmy
- [ ] Smoke test: Hermes responds to a basic reasoning task
- [ ] Integration test: Hermes participates in a multi-wizard task alongside Timmy
## Proposed Lane
**Primary role:** Architecture reasoning
**Routing verdict:** ROUTE TO: complex architectural decisions, novel problem-solving, planning tasks that benefit from Claude's reasoning depth. Do NOT route to: code generation (use Timmy/Carnice), issue triage (use Fenrir), or operational tasks (use Bezalel).
## Dependencies
| Dependency | Status | Notes |
|------------|--------|-------|
| Deadman switch | 🔴 Not done | Must be proven before unblocking |
| Fleet stability | 🟡 In progress | 1+ week uptime needed |
| Shed laptop power | 🔴 Not done | Alexander needs to wire power |
| KT document | 🔴 Not drafted | Alexander provides to Claude at unblock time |

View File

@@ -1,43 +0,0 @@
# Hermes Trismegistus — Lane Definition
> **Status:** DEFERRED — do not instantiate until unblock conditions met
> **See:** fleet/hermes-trismegistus/README.md for full proposal
---
## Role
Dedicated reasoning and architecture wizard. Claude-native.
## Routing
Route to Hermes Trismegistus when:
- Task requires deep architectural reasoning
- Novel problem-solving that benefits from Claude's reasoning depth
- Planning and design decisions for the fleet
- Complex multi-step analysis that goes beyond code generation
Do NOT route to Hermes for:
- Code generation (use Timmy, Carnice, or Kimi)
- Issue triage (use Fenrir)
- Operational/DevOps tasks (use Bezalel)
- Anything that can be done with a cheaper model
## Provider
- **Primary:** anthropic/claude (free tier)
- **Fallback:** openrouter/free (Claude-class models)
- **Degraded:** ollama/gemma4:12b (when free tier exhausted)
## Hardware
- Shed laptop, Ubuntu, minimum 4GB RAM
- Dedicated machine, not shared
## Unblock Checklist
- [ ] Deadman switch operational
- [ ] Fleet config stable for 1+ week
- [ ] Shed laptop powered and networked
- [ ] KT document drafted by Alexander
- [ ] Timmy approves instantiation

509
frontend/index.html Normal file
View File

@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="3D visualization of the Timmy agent network" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Tower World" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>Timmy Tower World</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
canvas { display: block; }
/* Loading screen — hidden by main.js after init */
#loading-screen {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: #000;
color: #00ff41; font-size: 14px; letter-spacing: 4px;
text-shadow: 0 0 12px #00ff41;
font-family: 'Courier New', monospace;
}
#loading-screen.hidden { display: none; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
#loading-screen span { animation: blink 1.2s ease-in-out infinite; }
#ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10;
}
#hud {
position: fixed; top: 16px; left: 16px;
color: #00ff41; font-size: clamp(10px, 1.5vw, 14px); line-height: 1.6;
text-shadow: 0 0 8px #00ff41;
pointer-events: none;
}
#hud h1 { font-size: clamp(12px, 2vw, 18px); letter-spacing: clamp(2px, 0.4vw, 4px); margin-bottom: 8px; color: #00ff88; }
#status-panel {
position: fixed; top: 16px; right: 16px;
color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.8;
text-shadow: 0 0 6px #00ff41; max-width: 240px;
}
#status-panel .label { color: #007722; }
#chat-panel {
position: fixed; bottom: 52px; left: 16px; right: 16px;
max-height: 150px; overflow-y: auto;
color: #00ff41; font-size: clamp(9px, 1.2vw, 12px); line-height: 1.6;
text-shadow: 0 0 4px #00ff41;
pointer-events: none;
}
.chat-entry { opacity: 0.8; }
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
.chat-entry.visitor { opacity: 1; }
.chat-entry.visitor .agent-name { color: #888; }
/* ── Chat input (#40) ── */
#chat-input-bar {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 8px;
padding: 8px 16px;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.85);
border-top: 1px solid #003300;
z-index: 20;
pointer-events: auto;
}
#chat-input {
flex: 1;
background: rgba(0, 20, 0, 0.6);
border: 1px solid #003300;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: clamp(12px, 1.5vw, 14px);
padding: 8px 12px;
border-radius: 2px;
outline: none;
caret-color: #00ff41;
}
#chat-input::placeholder { color: #004400; }
#chat-input:focus { border-color: #00ff41; box-shadow: 0 0 8px rgba(0, 255, 65, 0.2); }
#chat-send {
background: transparent;
border: 1px solid #003300;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: 14px;
padding: 8px 16px;
cursor: pointer;
border-radius: 2px;
pointer-events: auto;
text-shadow: 0 0 6px #00ff41;
transition: all 0.15s;
}
#chat-send:hover, #chat-send:active { background: rgba(0, 255, 65, 0.1); border-color: #00ff41; }
/* ── Bark display (#42) ── */
#bark-container {
position: fixed;
top: 20%; left: 50%;
transform: translateX(-50%);
max-width: 600px; width: 90%;
z-index: 15;
pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 8px;
}
.bark {
background: rgba(0, 10, 0, 0.85);
border: 1px solid #003300;
border-left: 3px solid #00ff41;
padding: 12px 20px;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: clamp(13px, 1.8vw, 16px);
line-height: 1.5;
text-shadow: 0 0 8px #00ff41;
opacity: 0;
animation: barkIn 0.4s ease-out forwards;
max-width: 100%;
}
.bark .bark-agent {
font-size: clamp(9px, 1vw, 11px);
color: #007722;
margin-bottom: 4px;
letter-spacing: 2px;
}
.bark.fade-out {
animation: barkOut 0.6s ease-in forwards;
}
@keyframes barkIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes barkOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
#connection-status {
position: fixed; bottom: 52px; right: 16px;
font-size: clamp(9px, 1.2vw, 12px); color: #555;
}
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
/* ── Presence HUD (#53) ── */
#presence-hud {
position: fixed; bottom: 180px; right: 16px;
background: rgba(0, 5, 0, 0.75);
border: 1px solid #002200;
border-radius: 2px;
padding: 8px 12px;
font-family: 'Courier New', monospace;
font-size: clamp(9px, 1.1vw, 11px);
color: #00ff41;
text-shadow: 0 0 4px rgba(0, 255, 65, 0.3);
min-width: 180px;
z-index: 12;
pointer-events: none;
}
.presence-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px; padding-bottom: 4px;
border-bottom: 1px solid #002200;
font-size: clamp(8px, 1vw, 10px);
letter-spacing: 2px; color: #007722;
}
.presence-count { color: #00ff41; letter-spacing: 0; }
.presence-mode { letter-spacing: 1px; }
.presence-row {
display: flex; align-items: center; gap: 6px;
padding: 2px 0;
}
.presence-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.presence-dot.online {
background: var(--agent-color, #00ff41);
box-shadow: 0 0 6px var(--agent-color, #00ff41);
animation: presencePulse 2s ease-in-out infinite;
}
.presence-dot.offline {
background: #333;
box-shadow: none;
}
@keyframes presencePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.presence-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.presence-state { font-size: clamp(7px, 0.9vw, 9px); min-width: 40px; text-align: center; }
.presence-uptime { color: #005500; min-width: 48px; text-align: right; font-variant-numeric: tabular-nums; }
/* ── Transcript controls (#54) ── */
#transcript-controls {
position: fixed; top: 16px; right: 260px;
display: flex; align-items: center; gap: 6px;
font-family: 'Courier New', monospace;
font-size: clamp(8px, 1vw, 10px);
z-index: 15;
pointer-events: auto;
}
.transcript-label { color: #005500; letter-spacing: 2px; }
.transcript-badge {
color: #00ff41; background: rgba(0, 20, 0, 0.6);
border: 1px solid #003300; border-radius: 2px;
padding: 1px 5px; font-variant-numeric: tabular-nums;
min-width: 28px; text-align: center;
}
.transcript-btn {
background: transparent; border: 1px solid #003300;
color: #00aa44; font-family: 'Courier New', monospace;
font-size: clamp(7px, 0.9vw, 9px); padding: 2px 6px;
cursor: pointer; border-radius: 2px;
transition: all 0.15s;
}
.transcript-btn:hover { color: #00ff41; border-color: #00ff41; background: rgba(0, 255, 65, 0.08); }
.transcript-btn-clear { color: #553300; border-color: #332200; }
.transcript-btn-clear:hover { color: #ff6600; border-color: #ff6600; background: rgba(255, 102, 0, 0.08); }
@media (max-width: 500px) {
#presence-hud { bottom: 180px; right: 8px; left: auto; min-width: 150px; padding: 6px 8px; }
#transcript-controls { top: auto; bottom: 180px; right: auto; left: 8px; }
}
/* Safe area padding for notched devices */
@supports (padding: env(safe-area-inset-top)) {
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
#status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); }
#chat-panel { bottom: calc(52px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); }
#connection-status { bottom: calc(52px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
#presence-hud { bottom: calc(180px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); }
}
/* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */
@media (max-width: 500px) {
#status-panel { top: 100px !important; left: 16px; right: auto; }
}
/* ── Agent info popup (#44) ── */
#agent-popup {
position: fixed;
z-index: 25;
background: rgba(0, 8, 0, 0.92);
border: 1px solid #003300;
border-radius: 2px;
padding: 0;
min-width: 180px;
max-width: 240px;
font-family: 'Courier New', monospace;
font-size: clamp(10px, 1.3vw, 13px);
color: #00ff41;
text-shadow: 0 0 6px rgba(0, 255, 65, 0.3);
pointer-events: auto;
backdrop-filter: blur(4px);
box-shadow: 0 0 20px rgba(0, 255, 65, 0.1);
}
.agent-popup-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px 6px;
border-bottom: 1px solid #002200;
}
.agent-popup-name {
font-weight: bold;
letter-spacing: 2px;
font-size: clamp(11px, 1.5vw, 14px);
}
.agent-popup-close {
cursor: pointer;
color: #555;
font-size: 16px;
padding: 0 2px;
line-height: 1;
}
.agent-popup-close:hover { color: #00ff41; }
.agent-popup-role {
padding: 4px 12px;
color: #007722;
font-size: clamp(9px, 1.1vw, 11px);
letter-spacing: 1px;
}
.agent-popup-state {
padding: 2px 12px 8px;
font-size: clamp(9px, 1.1vw, 11px);
}
.agent-popup-talk {
display: block; width: 100%;
background: transparent;
border: none;
border-top: 1px solid #002200;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: clamp(10px, 1.2vw, 12px);
padding: 8px 12px;
cursor: pointer;
text-align: left;
letter-spacing: 2px;
transition: background 0.15s;
}
.agent-popup-talk:hover { background: rgba(0, 255, 65, 0.08); }
/* ── Streaming cursor (#16) ── */
.chat-entry.streaming .stream-cursor {
color: #00ff41;
animation: cursorBlink 0.7s step-end infinite;
font-size: 0.85em;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.chat-entry.streaming .stream-text {
color: #00ff41;
}
.chat-ts { color: #004400; font-size: 0.9em; }
/* ── Economy / Treasury panel (#17) ── */
#economy-panel {
position: fixed; bottom: 180px; left: 16px;
background: rgba(0, 5, 0, 0.75);
border: 1px solid #002200;
border-radius: 2px;
padding: 8px 12px;
font-family: 'Courier New', monospace;
font-size: clamp(9px, 1.1vw, 11px);
color: #00ff41;
text-shadow: 0 0 4px rgba(0, 255, 65, 0.3);
min-width: 170px;
max-width: 220px;
z-index: 12;
pointer-events: none;
}
.econ-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px; padding-bottom: 4px;
border-bottom: 1px solid #002200;
font-size: clamp(8px, 1vw, 10px);
letter-spacing: 2px; color: #007722;
}
.econ-total { color: #ffcc00; letter-spacing: 0; font-variant-numeric: tabular-nums; }
.econ-waiting { color: #004400; font-style: italic; font-size: clamp(8px, 0.9vw, 10px); }
.econ-agents { margin-bottom: 6px; }
.econ-agent-row {
display: flex; align-items: center; gap: 5px;
padding: 1px 0;
}
.econ-dot {
width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0;
}
.econ-agent-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: #00aa44; }
.econ-agent-bal { color: #ffcc00; font-variant-numeric: tabular-nums; min-width: 50px; text-align: right; }
.econ-agent-spent { color: #664400; font-variant-numeric: tabular-nums; min-width: 50px; text-align: right; }
.econ-txns { border-top: 1px solid #002200; padding-top: 4px; }
.econ-txns-label { color: #004400; letter-spacing: 2px; font-size: clamp(7px, 0.8vw, 9px); margin-bottom: 2px; }
.econ-tx { color: #007722; padding: 1px 0; }
.econ-tx-amt { color: #ffcc00; }
@media (max-width: 500px) {
#economy-panel { bottom: 180px; left: 8px; min-width: 150px; padding: 6px 8px; }
}
@supports (padding: env(safe-area-inset-bottom)) {
#economy-panel { bottom: calc(180px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); }
}
/* ── Help overlay ── */
#help-hint {
position: fixed; top: 12px; right: 12px;
font-family: 'Courier New', monospace; font-size: 0.65rem;
color: #005500; background: rgba(0, 10, 0, 0.6);
border: 1px solid #003300; padding: 2px 8px;
cursor: pointer; z-index: 30; letter-spacing: 0.05em;
transition: color 0.3s, border-color 0.3s;
pointer-events: auto;
}
#help-hint:hover { color: #00ff41; border-color: #00ff41; }
#help-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0, 0, 0, 0.88);
align-items: center; justify-content: center;
font-family: 'Courier New', monospace; color: #00ff41;
backdrop-filter: blur(4px);
pointer-events: auto;
}
.help-content {
position: relative; max-width: 420px; width: 90%;
padding: 24px 28px; border: 1px solid #003300;
background: rgba(0, 10, 0, 0.7);
}
.help-title {
font-size: 1rem; letter-spacing: 0.15em; margin-bottom: 20px;
color: #00ff41; text-shadow: 0 0 8px rgba(0, 255, 65, 0.3);
}
.help-close {
position: absolute; top: 12px; right: 16px;
font-size: 1.2rem; cursor: pointer; color: #005500;
transition: color 0.2s;
}
.help-close:hover { color: #00ff41; }
.help-section { margin-bottom: 16px; }
.help-heading {
font-size: 0.65rem; color: #007700; letter-spacing: 0.1em;
margin-bottom: 6px; border-bottom: 1px solid #002200; padding-bottom: 3px;
}
.help-row {
display: flex; align-items: center; gap: 8px;
padding: 3px 0; font-size: 0.72rem;
}
.help-row span:last-child { margin-left: auto; color: #009900; text-align: right; }
.help-row kbd {
display: inline-block; font-family: 'Courier New', monospace;
font-size: 0.65rem; background: rgba(0, 30, 0, 0.6);
border: 1px solid #004400; border-radius: 3px;
padding: 1px 5px; min-width: 18px; text-align: center; color: #00cc33;
}
</style>
</head>
<body>
<div id="loading-screen"><span>INITIALIZING...</span></div>
<!-- WebGL context loss overlay (iPad PWA, GPU resets) -->
<div id="webgl-recovery-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.9);color:#00ff41;font-family:monospace;align-items:center;justify-content:center;flex-direction:column">
<p style="font-size:1.4rem">RECOVERING WebGL CONTEXT…</p>
<p style="font-size:.85rem;opacity:.6">GPU was reset. Rebuilding world.</p>
</div>
<div id="ui-overlay">
<div id="hud">
<h1>TIMMY TOWER WORLD</h1>
<div id="agent-count">AGENTS: 0</div>
<div id="active-jobs">JOBS: 0</div>
<div id="fps">FPS: --</div>
</div>
<div id="status-panel">
<div id="agent-list"></div>
</div>
<div id="chat-panel"></div>
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
<div id="bark-container"></div>
<div id="transcript-controls"></div>
<div id="economy-panel"></div>
<div id="presence-hud"></div>
<div id="connection-status">OFFLINE</div>
<div id="help-hint">? HELP</div>
<div id="help-overlay" style="display:none">
<div class="help-content">
<div class="help-title">CONTROLS</div>
<div class="help-close">&times;</div>
<div class="help-section">
<div class="help-heading">MOVEMENT</div>
<div class="help-row"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd><span>Move avatar</span></div>
<div class="help-row"><kbd>&uarr;</kbd><kbd>&darr;</kbd><kbd>&larr;</kbd><kbd>&rarr;</kbd><span>Move avatar</span></div>
<div class="help-row"><kbd>Right-click + drag</kbd><span>Look around</span></div>
</div>
<div class="help-section">
<div class="help-heading">CAMERA</div>
<div class="help-row"><span>Click PiP window</span><span>Toggle 1st / 3rd person</span></div>
<div class="help-row"><span>Scroll wheel</span><span>Zoom in / out</span></div>
<div class="help-row"><span>Left-click + drag</span><span>Orbit camera</span></div>
</div>
<div class="help-section">
<div class="help-heading">INTERACTION</div>
<div class="help-row"><span>Click an agent</span><span>View agent info</span></div>
<div class="help-row"><kbd>Enter</kbd><span>Focus chat input</span></div>
<div class="help-row"><kbd>?</kbd><span>Toggle this overlay</span></div>
</div>
</div>
</div>
</div>
<div id="chat-input-bar">
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
<button id="chat-send">&gt;</button>
</div>
<script type="module" src="./js/main.js"></script>
<script>
// Help overlay toggle
(function() {
const overlay = document.getElementById('help-overlay');
const hint = document.getElementById('help-hint');
const close = overlay ? overlay.querySelector('.help-close') : null;
function toggle() {
if (!overlay) return;
overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none';
}
document.addEventListener('keydown', function(e) {
if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
if (document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA') return;
e.preventDefault();
toggle();
}
if (e.key === 'Escape' && overlay && overlay.style.display !== 'none') {
overlay.style.display = 'none';
}
});
if (hint) hint.addEventListener('click', toggle);
if (close) close.addEventListener('click', toggle);
if (overlay) overlay.addEventListener('click', function(e) {
if (e.target === overlay) overlay.style.display = 'none';
});
})();
</script>
<!-- SW registration is handled by main.js in production builds only -->
</body>
</html>

30
frontend/js/agent-defs.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* agent-defs.js — Single source of truth for all agent definitions.
*
* These are the REAL agents of the Timmy Tower ecosystem.
* Additional agents can join at runtime via the `agent_joined` WS event
* (handled by addAgent() in agents.js).
*
* Fields:
* id — unique string key used in WebSocket messages and state maps
* label — display name shown in the 3D HUD and chat panel
* color — hex integer (0xRRGGBB) used for Three.js materials and lights
* role — human-readable role string shown under the label sprite
* direction — cardinal facing direction (for future mesh orientation use)
* x, z — world-space position on the horizontal plane (y is always 0)
*/
export const AGENT_DEFS = [
{ id: 'timmy', label: 'TIMMY', color: 0x00ff41, role: 'sovereign agent', direction: 'north', x: 0, z: 0 },
{ id: 'perplexity', label: 'PERPLEXITY', color: 0x20b8cd, role: 'integration architect', direction: 'east', x: 5, z: 3 },
{ id: 'replit', label: 'REPLIT', color: 0xff6622, role: 'lead architect', direction: 'south', x: -5, z: 3 },
{ id: 'kimi', label: 'KIMI', color: 0xcc44ff, role: 'scout', direction: 'west', x: -5, z: -3 },
{ id: 'claude', label: 'CLAUDE', color: 0xd4a574, role: 'senior engineer', direction: 'north', x: 5, z: -3 },
];
/**
* Convert an integer color (e.g. 0x00ff88) to a CSS hex string ('#00ff88').
* Useful for DOM styling and canvas rendering.
*/
export function colorToCss(intColor) {
return '#' + intColor.toString(16).padStart(6, '0');
}

523
frontend/js/agents.js Normal file
View File

@@ -0,0 +1,523 @@
import * as THREE from 'three';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
const agents = new Map();
let scene;
let connectionLines = [];
/* ── Shared geometries (created once, reused by all agents) ── */
const SHARED_GEO = {
core: new THREE.IcosahedronGeometry(0.7, 1),
ring: new THREE.TorusGeometry(1.1, 0.04, 8, 32),
glow: new THREE.SphereGeometry(1.3, 16, 16),
};
/* ── Shared connection line material (one instance for all lines) ── */
const CONNECTION_MAT = new THREE.LineBasicMaterial({
color: 0x00aa44,
transparent: true,
opacity: 0.5,
});
/* ── Active-conversation highlight material ── */
const ACTIVE_CONNECTION_MAT = new THREE.LineBasicMaterial({
color: 0x00ff41,
transparent: true,
opacity: 0.9,
});
/** Map of active pulse timers: `${idA}-${idB}` → timeoutId */
const pulseTimers = new Map();
class Agent {
constructor(def) {
this.id = def.id;
this.label = def.label;
this.color = def.color;
this.role = def.role;
this.position = new THREE.Vector3(def.x, 0, def.z);
this.homePosition = this.position.clone(); // remember spawn point
this.state = 'idle';
this.walletHealth = 1.0; // 0.01.0, 1.0 = healthy (#15)
this.pulsePhase = Math.random() * Math.PI * 2;
// Movement system
this._moveTarget = null; // THREE.Vector3 or null
this._moveSpeed = 2.0; // units/sec (adjustable per moveTo call)
this._moveCallback = null; // called when arrival reached
// Stress glow color targets (#15)
this._baseColor = new THREE.Color(def.color);
this._stressColor = new THREE.Color(0xff4400); // amber-red for low health
this._currentGlowColor = new THREE.Color(def.color);
this.group = new THREE.Group();
this.group.position.copy(this.position);
this._buildMeshes();
this._buildLabel();
}
_buildMeshes() {
// Per-agent materials (need unique color + mutable emissiveIntensity)
const coreMat = new THREE.MeshStandardMaterial({
color: this.color,
emissive: this.color,
emissiveIntensity: 0.4,
roughness: 0.3,
metalness: 0.8,
});
this.core = new THREE.Mesh(SHARED_GEO.core, coreMat);
this.group.add(this.core);
const ringMat = new THREE.MeshBasicMaterial({ color: this.color, transparent: true, opacity: 0.5 });
this.ring = new THREE.Mesh(SHARED_GEO.ring, ringMat);
this.ring.rotation.x = Math.PI / 2;
this.group.add(this.ring);
const glowMat = new THREE.MeshBasicMaterial({
color: this.color,
transparent: true,
opacity: 0.05,
side: THREE.BackSide,
});
this.glow = new THREE.Mesh(SHARED_GEO.glow, glowMat);
this.group.add(this.glow);
const light = new THREE.PointLight(this.color, 1.5, 10);
this.group.add(light);
this.light = light;
}
_buildLabel() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0,0,0,0)';
ctx.fillRect(0, 0, 256, 64);
ctx.font = 'bold 22px Courier New';
ctx.fillStyle = colorToCss(this.color);
ctx.textAlign = 'center';
ctx.fillText(this.label, 128, 28);
ctx.font = '14px Courier New';
ctx.fillStyle = '#007722';
ctx.fillText(this.role.toUpperCase(), 128, 50);
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
this.sprite = new THREE.Sprite(spriteMat);
this.sprite.scale.set(2.4, 0.6, 1);
this.sprite.position.y = 2;
this.group.add(this.sprite);
}
/**
* Move agent toward a target position over time.
* @param {THREE.Vector3|{x,z}} target — destination (y ignored, stays 0)
* @param {number} [speed=2.0] — units per second
* @param {Function} [onArrive] — callback when agent reaches target
*/
moveTo(target, speed = 2.0, onArrive = null) {
this._moveTarget = new THREE.Vector3(
target.x ?? target.getComponent?.(0) ?? 0,
0,
target.z ?? target.getComponent?.(2) ?? 0
);
this._moveSpeed = speed;
this._moveCallback = onArrive;
}
/** Cancel in-progress movement. */
stopMoving() {
this._moveTarget = null;
this._moveCallback = null;
}
/** @returns {boolean} true if agent is currently moving toward a target */
get isMoving() {
return this._moveTarget !== null;
}
update(time, delta) {
// ── Movement interpolation ──
if (this._moveTarget) {
const step = this._moveSpeed * delta;
const dist = this.position.distanceTo(this._moveTarget);
if (dist <= step + 0.05) {
// Arrived
this.position.copy(this._moveTarget);
this.position.y = 0;
this.group.position.x = this.position.x;
this.group.position.z = this.position.z;
const cb = this._moveCallback;
this._moveTarget = null;
this._moveCallback = null;
if (cb) cb();
} else {
// Lerp toward target
const dir = new THREE.Vector3().subVectors(this._moveTarget, this.position).normalize();
this.position.addScaledVector(dir, step);
this.position.y = 0;
this.group.position.x = this.position.x;
this.group.position.z = this.position.z;
}
}
// ── Visual effects ──
const pulse = Math.sin(time * 0.002 + this.pulsePhase);
const active = this.state === 'active';
const moving = this.isMoving;
const wh = this.walletHealth;
// Budget stress glow (#15): blend base color toward stress color as wallet drops
const stressT = 1 - Math.max(0, Math.min(1, wh));
this._currentGlowColor.copy(this._baseColor).lerp(this._stressColor, stressT * stressT);
// Stress breathing: faster + wider pulse when wallet is low
const stressPulseSpeed = 0.002 + stressT * 0.006;
const stressPulse = Math.sin(time * stressPulseSpeed + this.pulsePhase);
const breathingAmp = stressT > 0.5 ? 0.15 + stressT * 0.15 : 0;
const stressBreathe = breathingAmp * stressPulse;
const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1 + stressBreathe;
this.core.material.emissiveIntensity = intensity;
this.core.material.emissive.copy(this._currentGlowColor);
this.light.color.copy(this._currentGlowColor);
this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3;
// Glow sphere shows stress color
this.glow.material.color.copy(this._currentGlowColor);
this.glow.material.opacity = 0.05 + stressT * 0.08;
const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03;
this.core.scale.setScalar(scale);
// Ring spins faster when moving
this.ring.rotation.y += moving ? 0.05 : (active ? 0.03 : 0.008);
this.ring.material.opacity = 0.3 + pulse * 0.2;
this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15;
}
setState(state) {
this.state = state;
}
/**
* Set wallet health (0.01.0). Affects glow color and pulse. (#15)
*/
setWalletHealth(health) {
this.walletHealth = Math.max(0, Math.min(1, health));
}
/**
* Dispose per-agent GPU resources (materials + textures).
* Shared geometries are NOT disposed here — they outlive individual agents.
*/
dispose() {
this.core.material.dispose();
this.ring.material.dispose();
this.glow.material.dispose();
this.sprite.material.map.dispose();
this.sprite.material.dispose();
}
}
export function initAgents(sceneRef) {
scene = sceneRef;
AGENT_DEFS.forEach(def => {
const agent = new Agent(def);
agents.set(def.id, agent);
scene.add(agent.group);
});
buildConnectionLines();
}
function buildConnectionLines() {
// Dispose old line geometries before removing
connectionLines.forEach(l => {
scene.remove(l);
l.geometry.dispose();
// Material is shared — do NOT dispose here
});
connectionLines = [];
const agentList = [...agents.values()];
for (let i = 0; i < agentList.length; i++) {
for (let j = i + 1; j < agentList.length; j++) {
const a = agentList[i];
const b = agentList[j];
if (a.position.distanceTo(b.position) <= 14) {
const points = [a.position.clone(), b.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geo, CONNECTION_MAT);
connectionLines.push(line);
scene.add(line);
}
}
}
}
export function updateAgents(time, delta) {
agents.forEach(agent => agent.update(time, delta));
// Update connection lines to follow agents as they move
updateConnectionLines();
}
/** Update connection line endpoints to track moving agents. */
function updateConnectionLines() {
const agentList = [...agents.values()];
let lineIdx = 0;
for (let i = 0; i < agentList.length; i++) {
for (let j = i + 1; j < agentList.length; j++) {
if (lineIdx >= connectionLines.length) return;
const a = agentList[i];
const b = agentList[j];
if (a.position.distanceTo(b.position) <= 20) {
const line = connectionLines[lineIdx];
const pos = line.geometry.attributes.position;
pos.setXYZ(0, a.position.x, a.position.y, a.position.z);
pos.setXYZ(1, b.position.x, b.position.y, b.position.z);
pos.needsUpdate = true;
line.visible = true;
lineIdx++;
}
}
}
// Hide any excess lines (agents moved apart)
for (; lineIdx < connectionLines.length; lineIdx++) {
connectionLines[lineIdx].visible = false;
}
}
/**
* Move an agent toward a position. Used by behavior system and WS commands.
* @param {string} agentId
* @param {{x: number, z: number}} target
* @param {number} [speed=2.0]
* @param {Function} [onArrive]
*/
export function moveAgentTo(agentId, target, speed = 2.0, onArrive = null) {
const agent = agents.get(agentId);
if (agent) agent.moveTo(target, speed, onArrive);
}
/** Stop an agent's movement. */
export function stopAgentMovement(agentId) {
const agent = agents.get(agentId);
if (agent) agent.stopMoving();
}
/** Check if an agent is currently in motion. */
export function isAgentMoving(agentId) {
const agent = agents.get(agentId);
return agent ? agent.isMoving : false;
}
export function getAgentCount() {
return agents.size;
}
/**
* Temporarily highlight the connection line between two agents.
* Used during agent-to-agent conversations (interview, collaboration).
*
* @param {string} idA — first agent
* @param {string} idB — second agent
* @param {number} durationMs — how long to keep the line bright (default 4000)
*/
export function pulseConnection(idA, idB, durationMs = 4000) {
// Find the connection line between these two agents
const a = agents.get(idA);
const b = agents.get(idB);
if (!a || !b) return;
const key = [idA, idB].sort().join('-');
// Find the line connecting them
for (const line of connectionLines) {
const pos = line.geometry.attributes.position;
if (!pos || pos.count < 2) continue;
const p0 = new THREE.Vector3(pos.getX(0), pos.getY(0), pos.getZ(0));
const p1 = new THREE.Vector3(pos.getX(1), pos.getY(1), pos.getZ(1));
const matchesAB = (p0.distanceTo(a.position) < 0.5 && p1.distanceTo(b.position) < 0.5);
const matchesBA = (p0.distanceTo(b.position) < 0.5 && p1.distanceTo(a.position) < 0.5);
if (matchesAB || matchesBA) {
// Swap to highlight material
line.material = ACTIVE_CONNECTION_MAT;
// Clear any existing timer for this pair
if (pulseTimers.has(key)) {
clearTimeout(pulseTimers.get(key));
}
// Reset after duration
const timer = setTimeout(() => {
line.material = CONNECTION_MAT;
pulseTimers.delete(key);
}, durationMs);
pulseTimers.set(key, timer);
return;
}
}
}
export function setAgentState(agentId, state) {
const agent = agents.get(agentId);
if (agent) agent.setState(state);
}
/**
* Set wallet health for an agent (Issue #15).
* @param {string} agentId
* @param {number} health — 0.0 (broke) to 1.0 (full)
*/
export function setAgentWalletHealth(agentId, health) {
const agent = agents.get(agentId);
if (agent) agent.setWalletHealth(health);
}
/**
* Get an agent's world position (for satflow particle targeting).
* @param {string} agentId
* @returns {THREE.Vector3|null}
*/
export function getAgentPosition(agentId) {
const agent = agents.get(agentId);
return agent ? agent.position.clone() : null;
}
export function getAgentDefs() {
return [...agents.values()].map(a => ({
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,
}));
}
/**
* Dynamic agent hot-add (Issue #12).
*
* Spawns a new 3D agent at runtime when the backend sends an agent_joined event.
* If x/z are not provided, the agent is auto-placed in the next available slot
* on a circle around the origin (radius 8) to avoid overlapping existing agents.
*
* @param {object} def — Agent definition { id, label, color, role, direction, x, z }
* @returns {boolean} true if added, false if agent with that id already exists
*/
export function addAgent(def) {
if (agents.has(def.id)) {
console.warn('[Agents] Agent', def.id, 'already exists — skipping hot-add');
return false;
}
// Auto-place if no position given
if (def.x == null || def.z == null) {
const placed = autoPlace();
def.x = placed.x;
def.z = placed.z;
}
const agent = new Agent(def);
agents.set(def.id, agent);
scene.add(agent.group);
// Rebuild connection lines to include the new agent
buildConnectionLines();
console.info('[Agents] Hot-added agent:', def.id, 'at', def.x, def.z);
return true;
}
/**
* Find an unoccupied position on a circle around the origin.
* Tries radius 8 first (same ring as the original 4), then expands.
*/
function autoPlace() {
const existing = [...agents.values()].map(a => a.position);
const RADIUS_START = 8;
const RADIUS_STEP = 4;
const ANGLE_STEP = Math.PI / 6; // 30° increments = 12 slots per ring
const MIN_DISTANCE = 3; // minimum gap between agents
for (let r = RADIUS_START; r <= RADIUS_START + RADIUS_STEP * 3; r += RADIUS_STEP) {
for (let angle = 0; angle < Math.PI * 2; angle += ANGLE_STEP) {
const x = Math.round(r * Math.sin(angle) * 10) / 10;
const z = Math.round(r * Math.cos(angle) * 10) / 10;
const candidate = new THREE.Vector3(x, 0, z);
const tooClose = existing.some(p => p.distanceTo(candidate) < MIN_DISTANCE);
if (!tooClose) {
return { x, z };
}
}
}
// Fallback: random offset if all slots taken (very unlikely)
return { x: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 };
}
/**
* Remove an agent from the scene and dispose its resources.
* Useful for agent_left events.
*
* @param {string} agentId
* @returns {boolean} true if removed
*/
export function removeAgent(agentId) {
const agent = agents.get(agentId);
if (!agent) return false;
scene.remove(agent.group);
agent.dispose();
agents.delete(agentId);
buildConnectionLines();
console.info('[Agents] Removed agent:', agentId);
return true;
}
/**
* Snapshot current agent states for preservation across WebGL context loss.
* @returns {Object.<string,string>} agentId → state string
*/
export function getAgentStates() {
const snapshot = {};
for (const [id, agent] of agents) {
snapshot[id] = agent.state || 'idle';
}
return snapshot;
}
/**
* Reapply a state snapshot after world rebuild.
* @param {Object.<string,string>} snapshot
*/
export function applyAgentStates(snapshot) {
if (!snapshot) return;
for (const [id, state] of Object.entries(snapshot)) {
const agent = agents.get(id);
if (agent) agent.state = state;
}
}
/**
* Dispose all agent resources (used on world teardown).
*/
export function disposeAgents() {
// Dispose connection line geometries first
connectionLines.forEach(l => {
scene.remove(l);
l.geometry.dispose();
});
connectionLines = [];
for (const [id, agent] of agents) {
scene.remove(agent.group);
agent.dispose();
}
agents.clear();
}

212
frontend/js/ambient.js Normal file
View File

@@ -0,0 +1,212 @@
/**
* ambient.js — Mood-driven scene atmosphere.
*
* Timmy's mood (calm, focused, excited, contemplative, stressed)
* smoothly transitions the scene's lighting color temperature,
* fog density, rain intensity, and ambient sound cues.
*
* Resolves Issue #43 — Ambient state system
*/
import * as THREE from 'three';
/* ── Mood definitions ── */
const MOODS = {
calm: {
fogDensity: 0.035,
fogColor: new THREE.Color(0x000000),
ambientColor: new THREE.Color(0x001a00),
ambientIntensity: 0.6,
pointColor: new THREE.Color(0x00ff41),
pointIntensity: 2,
rainSpeed: 1.0,
rainOpacity: 0.7,
starOpacity: 0.5,
},
focused: {
fogDensity: 0.025,
fogColor: new THREE.Color(0x000500),
ambientColor: new THREE.Color(0x002200),
ambientIntensity: 0.8,
pointColor: new THREE.Color(0x00ff88),
pointIntensity: 2.5,
rainSpeed: 0.7,
rainOpacity: 0.5,
starOpacity: 0.6,
},
excited: {
fogDensity: 0.02,
fogColor: new THREE.Color(0x050500),
ambientColor: new THREE.Color(0x1a1a00),
ambientIntensity: 1.0,
pointColor: new THREE.Color(0x44ff44),
pointIntensity: 3.5,
rainSpeed: 1.8,
rainOpacity: 0.9,
starOpacity: 0.8,
},
contemplative: {
fogDensity: 0.05,
fogColor: new THREE.Color(0x000005),
ambientColor: new THREE.Color(0x000a1a),
ambientIntensity: 0.4,
pointColor: new THREE.Color(0x2288cc),
pointIntensity: 1.5,
rainSpeed: 0.4,
rainOpacity: 0.4,
starOpacity: 0.7,
},
stressed: {
fogDensity: 0.015,
fogColor: new THREE.Color(0x050000),
ambientColor: new THREE.Color(0x1a0500),
ambientIntensity: 0.5,
pointColor: new THREE.Color(0xff4422),
pointIntensity: 3.0,
rainSpeed: 2.5,
rainOpacity: 1.0,
starOpacity: 0.3,
},
};
/* ── State ── */
let scene = null;
let ambientLt = null;
let pointLt = null;
let currentMood = 'calm';
let targetMood = 'calm';
let blendT = 1.0; // 0→1, 1 = fully at target
const BLEND_SPEED = 0.4; // units per second — smooth ~2.5s transition
// Snapshot of the "from" state when a transition starts
let fromState = null;
/* ── External handles for effects.js integration ── */
let _rainSpeedMul = 1.0;
let _rainOpacity = 0.7;
let _starOpacity = 0.5;
export function getRainSpeedMultiplier() { return _rainSpeedMul; }
export function getRainOpacity() { return _rainOpacity; }
export function getStarOpacity() { return _starOpacity; }
/* ── API ── */
/**
* Bind ambient system to the scene's lights.
* Must be called after initWorld() creates the scene.
*/
export function initAmbient(scn) {
scene = scn;
// Find the ambient and point lights created by world.js
scene.traverse(obj => {
if (obj.isAmbientLight && !ambientLt) ambientLt = obj;
if (obj.isPointLight && !pointLt) pointLt = obj;
});
// Initialize from calm state
_applyMood(MOODS.calm, 1);
}
/**
* Set the mood, triggering a smooth transition.
* @param {string} mood — one of: calm, focused, excited, contemplative, stressed
*/
export function setAmbientState(mood) {
if (!MOODS[mood] || mood === targetMood) return;
// Snapshot current interpolated state as the "from"
fromState = _snapshot();
currentMood = targetMood;
targetMood = mood;
blendT = 0;
}
/** Get the current mood label. */
export function getAmbientMood() {
return blendT >= 1 ? targetMood : `${currentMood}${targetMood}`;
}
/**
* Per-frame update — call from the render loop.
* @param {number} delta — seconds since last frame
*/
export function updateAmbient(delta) {
if (blendT >= 1) return; // nothing to interpolate
blendT = Math.min(1, blendT + BLEND_SPEED * delta);
const t = _ease(blendT);
const target = MOODS[targetMood] || MOODS.calm;
if (fromState) {
_interpolate(fromState, target, t);
}
if (blendT >= 1) {
fromState = null; // transition complete
}
}
/** Dispose ambient state. */
export function disposeAmbient() {
scene = null;
ambientLt = null;
pointLt = null;
fromState = null;
blendT = 1;
currentMood = 'calm';
targetMood = 'calm';
}
/* ── Internals ── */
function _ease(t) {
// Smooth ease-in-out
return t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function _snapshot() {
return {
fogDensity: scene?.fog?.density ?? 0.035,
fogColor: scene?.fog?.color?.clone() ?? new THREE.Color(0x000000),
ambientColor: ambientLt?.color?.clone() ?? new THREE.Color(0x001a00),
ambientIntensity: ambientLt?.intensity ?? 0.6,
pointColor: pointLt?.color?.clone() ?? new THREE.Color(0x00ff41),
pointIntensity: pointLt?.intensity ?? 2,
rainSpeed: _rainSpeedMul,
rainOpacity: _rainOpacity,
starOpacity: _starOpacity,
};
}
function _interpolate(from, to, t) {
// Fog
if (scene?.fog) {
scene.fog.density = THREE.MathUtils.lerp(from.fogDensity, to.fogDensity, t);
scene.fog.color.copy(from.fogColor).lerp(to.fogColor, t);
}
// Ambient light
if (ambientLt) {
ambientLt.color.copy(from.ambientColor).lerp(to.ambientColor, t);
ambientLt.intensity = THREE.MathUtils.lerp(from.ambientIntensity, to.ambientIntensity, t);
}
// Point light
if (pointLt) {
pointLt.color.copy(from.pointColor).lerp(to.pointColor, t);
pointLt.intensity = THREE.MathUtils.lerp(from.pointIntensity, to.pointIntensity, t);
}
// Rain / star params (consumed by effects.js)
_rainSpeedMul = THREE.MathUtils.lerp(from.rainSpeed, to.rainSpeed, t);
_rainOpacity = THREE.MathUtils.lerp(from.rainOpacity, to.rainOpacity, t);
_starOpacity = THREE.MathUtils.lerp(from.starOpacity, to.starOpacity, t);
}
function _applyMood(mood, t) {
_interpolate(mood, mood, t); // apply directly
}

360
frontend/js/avatar.js Normal file
View File

@@ -0,0 +1,360 @@
/**
* avatar.js — Visitor avatar with FPS movement and PiP dual-camera.
*
* Exports:
* initAvatar(scene, camera, renderer) — create avatar + PiP, bind input
* updateAvatar(delta) — move avatar, sync FP camera
* getAvatarMainCamera() — returns the camera for the current main view
* renderAvatarPiP(scene) — render the PiP after main render
* disposeAvatar() — cleanup everything
* getAvatarPosition() — { x, z, yaw } for presence messages
*/
import * as THREE from 'three';
const MOVE_SPEED = 8;
const TURN_SPEED = 0.003;
const EYE_HEIGHT = 2.2;
const AVATAR_COLOR = 0x00ffaa;
const WORLD_BOUNDS = 45;
// Module state
let scene, orbitCamera, renderer;
let group, fpCamera;
let pipCanvas, pipRenderer, pipLabel;
let activeView = 'third'; // 'first' or 'third' for main viewport
let yaw = 0; // face -Z toward center
// Input state
const keys = {};
let isMouseLooking = false;
let touchId = null;
let touchStartX = 0, touchStartY = 0;
let touchDeltaX = 0, touchDeltaY = 0;
// Bound handlers (for removal on dispose)
let _onKeyDown, _onKeyUp, _onMouseDown, _onMouseUp, _onMouseMove, _onContextMenu;
let _onTouchStart, _onTouchMove, _onTouchEnd;
let abortController;
// ── Public API ──
export function initAvatar(_scene, _orbitCamera, _renderer) {
scene = _scene;
orbitCamera = _orbitCamera;
renderer = _renderer;
activeView = 'third';
yaw = 0;
abortController = new AbortController();
const signal = abortController.signal;
_buildAvatar();
_buildFPCamera();
_buildPiP();
_bindInput(signal);
}
export function updateAvatar(delta) {
if (!group) return;
if (document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA') return;
let mx = 0, mz = 0;
if (keys['w']) mz += 1;
if (keys['s']) mz -= 1;
if (keys['a']) mx -= 1;
if (keys['d']) mx += 1;
if (keys['ArrowUp']) mz += 1;
if (keys['ArrowDown']) mz -= 1;
// ArrowLeft/Right only turn (handled below)
mx += touchDeltaX;
mz -= touchDeltaY;
if (keys['ArrowLeft']) yaw += 1.5 * delta;
if (keys['ArrowRight']) yaw -= 1.5 * delta;
if (mx !== 0 || mz !== 0) {
const len = Math.sqrt(mx * mx + mz * mz);
mx /= len;
mz /= len;
const speed = MOVE_SPEED * delta;
// Forward = -Z at yaw=0 (Three.js default)
const fwdX = -Math.sin(yaw);
const fwdZ = -Math.cos(yaw);
const rightX = Math.cos(yaw);
const rightZ = -Math.sin(yaw);
group.position.x += (mx * rightX + mz * fwdX) * speed;
group.position.z += (mx * rightZ + mz * fwdZ) * speed;
}
// Clamp to world bounds
group.position.x = Math.max(-WORLD_BOUNDS, Math.min(WORLD_BOUNDS, group.position.x));
group.position.z = Math.max(-WORLD_BOUNDS, Math.min(WORLD_BOUNDS, group.position.z));
// Avatar rotation
group.rotation.y = yaw;
// FP camera follows avatar head
fpCamera.position.set(
group.position.x,
group.position.y + EYE_HEIGHT,
group.position.z,
);
fpCamera.rotation.set(0, yaw, 0, 'YXZ');
}
export function getAvatarMainCamera() {
return activeView === 'first' ? fpCamera : orbitCamera;
}
export function renderAvatarPiP(_scene) {
if (!pipRenderer || !_scene) return;
const cam = activeView === 'third' ? fpCamera : orbitCamera;
pipRenderer.render(_scene, cam);
}
export function getAvatarPosition() {
if (!group) return { x: 0, z: 0, yaw: 0 };
return {
x: Math.round(group.position.x * 10) / 10,
z: Math.round(group.position.z * 10) / 10,
yaw: Math.round(yaw * 100) / 100,
};
}
export function disposeAvatar() {
if (abortController) abortController.abort();
if (group) {
group.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
});
scene?.remove(group);
group = null;
}
if (pipRenderer) { pipRenderer.dispose(); pipRenderer = null; }
pipCanvas?.remove();
pipLabel?.remove();
pipCanvas = null;
pipLabel = null;
}
// ── Internal builders ──
function _buildAvatar() {
group = new THREE.Group();
const mat = new THREE.MeshBasicMaterial({
color: AVATAR_COLOR,
wireframe: true,
transparent: true,
opacity: 0.85,
});
// Head — icosahedron
const head = new THREE.Mesh(new THREE.IcosahedronGeometry(0.35, 1), mat);
head.position.y = 3.0;
group.add(head);
// Torso
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.2, 0.4), mat);
torso.position.y = 1.9;
group.add(torso);
// Legs
for (const x of [-0.2, 0.2]) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.1, 0.2), mat);
leg.position.set(x, 0.65, 0);
group.add(leg);
}
// Arms
for (const x of [-0.55, 0.55]) {
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.18, 1.0, 0.18), mat);
arm.position.set(x, 1.9, 0);
group.add(arm);
}
// Glow
const glow = new THREE.PointLight(AVATAR_COLOR, 0.8, 8);
glow.position.y = 3.0;
group.add(glow);
// Label
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.font = '600 28px "Courier New", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#00ffaa';
ctx.shadowColor = '#00ffaa';
ctx.shadowBlur = 12;
ctx.fillText('YOU', 128, 32);
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(4, 1, 1);
sprite.position.y = 3.8;
group.add(sprite);
// Spawn at world edge facing center
group.position.set(0, 0, 22);
scene.add(group);
}
function _buildFPCamera() {
fpCamera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1, 500,
);
window.addEventListener('resize', () => {
fpCamera.aspect = window.innerWidth / window.innerHeight;
fpCamera.updateProjectionMatrix();
});
}
function _buildPiP() {
const W = 220, H = 150;
pipCanvas = document.createElement('canvas');
pipCanvas.id = 'pip-viewport';
pipCanvas.width = W * Math.min(window.devicePixelRatio, 2);
pipCanvas.height = H * Math.min(window.devicePixelRatio, 2);
Object.assign(pipCanvas.style, {
position: 'fixed',
bottom: '16px',
right: '16px',
width: W + 'px',
height: H + 'px',
border: '1px solid rgba(0,255,65,0.5)',
borderRadius: '4px',
cursor: 'pointer',
zIndex: '100',
boxShadow: '0 0 20px rgba(0,255,65,0.15), inset 0 0 20px rgba(0,0,0,0.5)',
});
document.body.appendChild(pipCanvas);
pipRenderer = new THREE.WebGLRenderer({ canvas: pipCanvas, antialias: false });
pipRenderer.setSize(W, H);
pipRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Label
pipLabel = document.createElement('div');
pipLabel.id = 'pip-label';
Object.assign(pipLabel.style, {
position: 'fixed',
bottom: (16 + H + 4) + 'px',
right: '16px',
color: 'rgba(0,255,65,0.6)',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
fontWeight: '500',
letterSpacing: '2px',
zIndex: '100',
pointerEvents: 'none',
});
_updatePipLabel();
document.body.appendChild(pipLabel);
// Swap on click/tap
pipCanvas.addEventListener('click', _swapViews);
pipCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
_swapViews();
}, { passive: false });
}
function _updatePipLabel() {
if (pipLabel) {
pipLabel.textContent = activeView === 'third' ? '◉ 1ST PERSON' : '◉ 3RD PERSON';
}
}
function _swapViews() {
activeView = activeView === 'third' ? 'first' : 'third';
_updatePipLabel();
if (group) group.visible = activeView === 'third';
}
// ── Input ──
function _bindInput(signal) {
_onKeyDown = (e) => {
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
keys[k] = true;
if (document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA') return;
if (['w','a','s','d','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(k)) {
e.preventDefault();
}
};
_onKeyUp = (e) => {
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
keys[k] = false;
};
_onMouseDown = (e) => {
if (e.button === 2) { isMouseLooking = true; e.preventDefault(); }
};
_onMouseUp = () => { isMouseLooking = false; };
_onMouseMove = (e) => {
if (!isMouseLooking) return;
yaw -= e.movementX * TURN_SPEED;
};
_onContextMenu = (e) => e.preventDefault();
_onTouchStart = (e) => {
for (const t of e.changedTouches) {
if (t.clientX < window.innerWidth * 0.5 && touchId === null) {
touchId = t.identifier;
touchStartX = t.clientX;
touchStartY = t.clientY;
touchDeltaX = 0;
touchDeltaY = 0;
}
}
};
_onTouchMove = (e) => {
for (const t of e.changedTouches) {
if (t.identifier === touchId) {
touchDeltaX = Math.max(-1, Math.min(1, (t.clientX - touchStartX) / 60));
touchDeltaY = Math.max(-1, Math.min(1, (t.clientY - touchStartY) / 60));
}
}
};
_onTouchEnd = (e) => {
for (const t of e.changedTouches) {
if (t.identifier === touchId) {
touchId = null;
touchDeltaX = 0;
touchDeltaY = 0;
}
}
};
document.addEventListener('keydown', _onKeyDown, { signal });
document.addEventListener('keyup', _onKeyUp, { signal });
renderer.domElement.addEventListener('mousedown', _onMouseDown, { signal });
document.addEventListener('mouseup', _onMouseUp, { signal });
renderer.domElement.addEventListener('mousemove', _onMouseMove, { signal });
renderer.domElement.addEventListener('contextmenu', _onContextMenu, { signal });
renderer.domElement.addEventListener('touchstart', _onTouchStart, { passive: true, signal });
renderer.domElement.addEventListener('touchmove', _onTouchMove, { passive: true, signal });
renderer.domElement.addEventListener('touchend', _onTouchEnd, { passive: true, signal });
}

141
frontend/js/bark.js Normal file
View File

@@ -0,0 +1,141 @@
/**
* bark.js — Bark display system for the Workshop.
*
* Handles incoming bark messages from Timmy and displays them
* prominently in the viewport with typing animation and auto-dismiss.
*
* Resolves Issue #42 — Bark display system
*/
import { appendChatMessage } from './ui.js';
import { colorToCss, AGENT_DEFS } from './agent-defs.js';
const $container = document.getElementById('bark-container');
const BARK_DISPLAY_MS = 7000; // How long a bark stays visible
const BARK_FADE_MS = 600; // Fade-out animation duration
const BARK_TYPE_MS = 30; // Ms per character for typing effect
const MAX_BARKS = 3; // Max simultaneous barks on screen
const barkQueue = [];
let activeBarkCount = 0;
/**
* Display a bark in the viewport.
*
* @param {object} opts
* @param {string} opts.text — The bark text
* @param {string} [opts.agentId='timmy'] — Which agent is barking
* @param {string} [opts.emotion='calm'] — Emotion tag (calm, excited, uncertain)
* @param {string} [opts.color] — Override CSS color
*/
export function showBark({ text, agentId = 'timmy', emotion = 'calm', color }) {
if (!text || !$container) return;
// Queue if too many active barks
if (activeBarkCount >= MAX_BARKS) {
barkQueue.push({ text, agentId, emotion, color });
return;
}
activeBarkCount++;
// Resolve agent color
const agentDef = AGENT_DEFS.find(d => d.id === agentId);
const barkColor = color || (agentDef ? colorToCss(agentDef.color) : '#00ff41');
const agentLabel = agentDef ? agentDef.label : agentId.toUpperCase();
// Create bark element
const el = document.createElement('div');
el.className = `bark ${emotion}`;
el.style.borderLeftColor = barkColor;
el.innerHTML = `<div class="bark-agent">${escapeHtml(agentLabel)}</div><span class="bark-text"></span>`;
$container.appendChild(el);
// Typing animation
const $text = el.querySelector('.bark-text');
let charIndex = 0;
const typeInterval = setInterval(() => {
if (charIndex < text.length) {
$text.textContent += text[charIndex];
charIndex++;
} else {
clearInterval(typeInterval);
}
}, BARK_TYPE_MS);
// Also log to chat panel as permanent record
appendChatMessage(agentLabel, text, barkColor);
// Auto-dismiss after display time
const displayTime = BARK_DISPLAY_MS + (text.length * BARK_TYPE_MS);
setTimeout(() => {
clearInterval(typeInterval);
el.classList.add('fade-out');
setTimeout(() => {
el.remove();
activeBarkCount--;
drainQueue();
}, BARK_FADE_MS);
}, displayTime);
}
/**
* Process queued barks when a slot opens.
*/
function drainQueue() {
if (barkQueue.length > 0 && activeBarkCount < MAX_BARKS) {
const next = barkQueue.shift();
showBark(next);
}
}
/**
* Escape HTML for safe text insertion.
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── Mock barks for demo mode ──
const DEMO_BARKS = [
{ text: 'The Tower watches. The Tower remembers.', emotion: 'calm' },
{ text: 'A visitor. Welcome to the Workshop.', emotion: 'calm' },
{ text: 'New commit on main. The code evolves.', emotion: 'excited' },
{ text: '222 — the number echoes again.', emotion: 'calm' },
{ text: 'I sense activity in the repo. Someone is building.', emotion: 'focused' },
{ text: 'The chain beats on. Block after block.', emotion: 'contemplative' },
{ text: 'Late night session? I know the pattern.', emotion: 'calm' },
{ text: 'Sovereignty means running your own mind.', emotion: 'calm' },
];
let demoTimer = null;
/**
* Start periodic demo barks (for mock mode).
*/
export function startDemoBarks() {
if (demoTimer) return;
// First bark after 5s, then every 15-25s
demoTimer = setTimeout(function nextBark() {
const bark = DEMO_BARKS[Math.floor(Math.random() * DEMO_BARKS.length)];
showBark({ text: bark.text, agentId: 'alpha', emotion: bark.emotion });
demoTimer = setTimeout(nextBark, 15000 + Math.random() * 10000);
}, 5000);
}
/**
* Stop demo barks.
*/
export function stopDemoBarks() {
if (demoTimer) {
clearTimeout(demoTimer);
demoTimer = null;
}
}

413
frontend/js/behaviors.js Normal file
View File

@@ -0,0 +1,413 @@
/**
* behaviors.js — Autonomous agent behavior system.
*
* Makes agents proactively alive: wandering, pondering, inspecting scene
* objects, conversing with each other, and placing small artifacts.
*
* Client-side default layer. When a real backend connects via WS, it can
* override behaviors with `agent_behavior` messages. The autonomous loop
* yields to server-driven behaviors and resumes when they complete.
*
* Follows the Pip familiar pattern (src/timmy/familiar.py):
* - State machine picks behavior + target position
* - Movement system (agents.js) handles interpolation
* - Visual systems (agents.js, bark.js) handle rendering
*
* Issue #68
*/
import { AGENT_DEFS } from './agent-defs.js';
import {
moveAgentTo, stopAgentMovement, isAgentMoving,
setAgentState, getAgentPosition, pulseConnection,
} from './agents.js';
import { showBark } from './bark.js';
import { getSceneObjectCount, addSceneObject } from './scene-objects.js';
/* ── Constants ── */
const WORLD_RADIUS = 15; // max wander distance from origin
const HOME_RADIUS = 3; // "close to home" threshold
const APPROACH_DISTANCE = 2.5; // how close agents get to each other
const MIN_DECISION_INTERVAL = 0.5; // seconds between behavior ticks (saves CPU)
/* ── Behavior definitions ── */
/**
* @typedef {'idle'|'wander'|'ponder'|'inspect'|'converse'|'place'|'return_home'} BehaviorType
*/
/** Duration ranges in seconds [min, max] */
const DURATIONS = {
idle: [5, 15],
wander: [8, 20],
ponder: [6, 12],
inspect: [4, 8],
converse: [8, 15],
place: [3, 6],
return_home: [0, 0], // ends when agent arrives
};
/** Agent personality weights — higher = more likely to choose that behavior.
* Each agent gets a distinct personality. */
const PERSONALITIES = {
timmy: { idle: 1, wander: 3, ponder: 5, inspect: 2, converse: 3, place: 2 },
perplexity: { idle: 2, wander: 3, ponder: 2, inspect: 4, converse: 3, place: 1 },
replit: { idle: 1, wander: 4, ponder: 1, inspect: 2, converse: 2, place: 4 },
kimi: { idle: 2, wander: 3, ponder: 3, inspect: 5, converse: 2, place: 1 },
claude: { idle: 2, wander: 2, ponder: 3, inspect: 2, converse: 5, place: 1 },
};
const DEFAULT_PERSONALITY = { idle: 2, wander: 3, ponder: 2, inspect: 2, converse: 3, place: 1 };
/* ── Bark lines per behavior ── */
const PONDER_BARKS = [
{ text: 'The code reveals its patterns...', emotion: 'contemplative' },
{ text: 'What if we approached it differently?', emotion: 'curious' },
{ text: 'I see the shape of a solution forming.', emotion: 'focused' },
{ text: 'The architecture wants to be simpler.', emotion: 'calm' },
{ text: 'Something here deserves deeper thought.', emotion: 'contemplative' },
{ text: 'Every constraint is a design decision.', emotion: 'focused' },
];
const CONVERSE_BARKS = [
{ text: 'Have you noticed the pattern in the recent commits?', emotion: 'curious' },
{ text: 'I think we should refactor this together.', emotion: 'focused' },
{ text: 'Your approach to that problem was interesting.', emotion: 'calm' },
{ text: 'Let me share what I found.', emotion: 'excited' },
{ text: 'We should coordinate on the next sprint.', emotion: 'focused' },
];
const INSPECT_BARKS = [
{ text: 'This artifact holds memory...', emotion: 'contemplative' },
{ text: 'Interesting construction.', emotion: 'curious' },
{ text: 'The world grows richer.', emotion: 'calm' },
];
const PLACE_BARKS = [
{ text: 'A marker for what I learned.', emotion: 'calm' },
{ text: 'Building the world, one piece at a time.', emotion: 'focused' },
{ text: 'This belongs here.', emotion: 'contemplative' },
];
/* ── Artifact templates for place behavior ── */
const ARTIFACT_TEMPLATES = [
{ geometry: 'icosahedron', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'standard', metalness: 0.8, roughness: 0.2 }, animation: [{ type: 'rotate', y: 0.5 }, { type: 'bob', amplitude: 0.1, speed: 1 }] },
{ geometry: 'octahedron', scale: { x: 0.25, y: 0.25, z: 0.25 }, material: { type: 'standard', metalness: 0.6, roughness: 0.3 }, animation: [{ type: 'rotate', y: -0.3 }] },
{ geometry: 'torus', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'standard', metalness: 0.7, roughness: 0.2 }, animation: [{ type: 'rotate', x: 0.4, y: 0.6 }] },
{ geometry: 'tetrahedron', scale: { x: 0.3, y: 0.3, z: 0.3 }, material: { type: 'phong', shininess: 80 }, animation: [{ type: 'bob', amplitude: 0.15, speed: 0.8 }] },
{ geometry: 'sphere', radius: 0.15, material: { type: 'physical', metalness: 0.9, roughness: 0.1, emissive: null, emissiveIntensity: 0.3 }, animation: [{ type: 'pulse', min: 0.9, max: 1.1, speed: 2 }] },
];
/* ── Per-agent behavior state ── */
class AgentBehavior {
constructor(agentId) {
this.agentId = agentId;
this.personality = PERSONALITIES[agentId] || DEFAULT_PERSONALITY;
this.currentBehavior = 'idle';
this.behaviorTimer = 0; // seconds remaining in current behavior
this.conversePeer = null; // agentId of converse partner
this._wsOverride = false; // true when backend is driving behavior
this._wsOverrideTimer = 0;
this._artifactCount = 0; // prevent artifact spam
}
/** Pick next behavior using weighted random selection. */
pickNextBehavior(allBehaviors) {
const candidates = Object.entries(this.personality);
const totalWeight = candidates.reduce((sum, [, w]) => sum + w, 0);
let roll = Math.random() * totalWeight;
for (const [behavior, weight] of candidates) {
roll -= weight;
if (roll <= 0) {
// Converse requires a free partner
if (behavior === 'converse') {
const peer = this._findConversePeer(allBehaviors);
if (!peer) return 'wander'; // no free partner, wander instead
this.conversePeer = peer;
const peerBehavior = allBehaviors.get(peer);
if (peerBehavior) {
peerBehavior.currentBehavior = 'converse';
peerBehavior.conversePeer = this.agentId;
peerBehavior.behaviorTimer = randRange(...DURATIONS.converse);
}
}
// Place requires scene object count under limit
if (behavior === 'place' && (getSceneObjectCount() >= 180 || this._artifactCount >= 5)) {
return 'ponder'; // too many objects, ponder instead
}
return behavior;
}
}
return 'idle';
}
/** Find another agent that's idle or wandering (available to converse). */
_findConversePeer(allBehaviors) {
const candidates = [];
for (const [id, b] of allBehaviors) {
if (id === this.agentId) continue;
if (b.currentBehavior === 'idle' || b.currentBehavior === 'wander') {
candidates.push(id);
}
}
return candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : null;
}
}
/* ── Module state ── */
/** @type {Map<string, AgentBehavior>} */
const behaviors = new Map();
let initialized = false;
let decisionAccumulator = 0;
/* ── Utility ── */
function randRange(min, max) {
return min + Math.random() * (max - min);
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randomWorldPoint(maxRadius = WORLD_RADIUS) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * maxRadius; // sqrt for uniform distribution
return { x: Math.cos(angle) * r, z: Math.sin(angle) * r };
}
function colorIntToHex(intColor) {
return '#' + intColor.toString(16).padStart(6, '0');
}
/* ── Behavior executors ── */
function executeIdle(ab) {
setAgentState(ab.agentId, 'idle');
stopAgentMovement(ab.agentId);
}
function executeWander(ab) {
setAgentState(ab.agentId, 'active');
const target = randomWorldPoint(WORLD_RADIUS);
moveAgentTo(ab.agentId, target, 1.5 + Math.random() * 1.0);
}
function executePonder(ab) {
setAgentState(ab.agentId, 'active');
stopAgentMovement(ab.agentId);
// Bark a thought
const bark = pick(PONDER_BARKS);
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
}
function executeInspect(ab) {
setAgentState(ab.agentId, 'active');
// Move to a random point nearby (simulating "looking at something")
const pos = getAgentPosition(ab.agentId);
if (pos) {
const target = {
x: pos.x + (Math.random() - 0.5) * 6,
z: pos.z + (Math.random() - 0.5) * 6,
};
moveAgentTo(ab.agentId, target, 1.0, () => {
const bark = pick(INSPECT_BARKS);
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
});
}
}
function executeConverse(ab) {
if (!ab.conversePeer) return;
setAgentState(ab.agentId, 'active');
const peerPos = getAgentPosition(ab.conversePeer);
if (peerPos) {
const myPos = getAgentPosition(ab.agentId);
if (myPos) {
// Move toward peer but stop short
const dx = peerPos.x - myPos.x;
const dz = peerPos.z - myPos.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > APPROACH_DISTANCE) {
const ratio = (dist - APPROACH_DISTANCE) / dist;
const target = { x: myPos.x + dx * ratio, z: myPos.z + dz * ratio };
moveAgentTo(ab.agentId, target, 2.0, () => {
pulseConnection(ab.agentId, ab.conversePeer, 6000);
const bark = pick(CONVERSE_BARKS);
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
});
} else {
pulseConnection(ab.agentId, ab.conversePeer, 6000);
const bark = pick(CONVERSE_BARKS);
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
}
}
}
}
function executePlace(ab) {
setAgentState(ab.agentId, 'active');
const pos = getAgentPosition(ab.agentId);
if (!pos) return;
const template = pick(ARTIFACT_TEMPLATES);
const agentDef = AGENT_DEFS.find(d => d.id === ab.agentId);
const color = agentDef ? colorIntToHex(agentDef.color) : '#00ff41';
// Place artifact near current position
const artPos = {
x: pos.x + (Math.random() - 0.5) * 3,
y: 0.5 + Math.random() * 0.5,
z: pos.z + (Math.random() - 0.5) * 3,
};
const material = { ...template.material, color };
if (material.emissive === null) material.emissive = color;
const artifactId = `artifact_${ab.agentId}_${Date.now()}`;
addSceneObject({
id: artifactId,
geometry: template.geometry,
position: artPos,
scale: template.scale || undefined,
radius: template.radius || undefined,
material,
animation: template.animation,
});
ab._artifactCount++;
const bark = pick(PLACE_BARKS);
showBark({ text: bark.text, agentId: ab.agentId, emotion: bark.emotion });
}
function executeReturnHome(ab) {
setAgentState(ab.agentId, 'idle');
const homeDef = AGENT_DEFS.find(d => d.id === ab.agentId);
if (homeDef) {
moveAgentTo(ab.agentId, { x: homeDef.x, z: homeDef.z }, 2.0);
}
}
const EXECUTORS = {
idle: executeIdle,
wander: executeWander,
ponder: executePonder,
inspect: executeInspect,
converse: executeConverse,
place: executePlace,
return_home: executeReturnHome,
};
/* ── WS override listener ── */
function onBehaviorOverride(e) {
const msg = e.detail;
const ab = behaviors.get(msg.agentId);
if (!ab) return;
ab._wsOverride = true;
ab._wsOverrideTimer = msg.duration || 10;
ab.currentBehavior = msg.behavior;
ab.behaviorTimer = msg.duration || 10;
// Execute the override behavior
if (msg.target) {
moveAgentTo(msg.agentId, msg.target, msg.speed || 2.0);
}
const executor = EXECUTORS[msg.behavior];
if (executor && !msg.target) executor(ab);
}
/* ── Public API ── */
/**
* Initialize the behavior system. Call after initAgents().
* @param {boolean} [autoStart=true] — start autonomous behaviors immediately
*/
export function initBehaviors(autoStart = true) {
if (initialized) return;
for (const def of AGENT_DEFS) {
const ab = new AgentBehavior(def.id);
// Stagger initial timers so agents don't all act at once
ab.behaviorTimer = 2 + Math.random() * 8;
behaviors.set(def.id, ab);
}
// Listen for WS behavior overrides
window.addEventListener('matrix:agent_behavior', onBehaviorOverride);
initialized = true;
console.info('[Behaviors] Initialized for', behaviors.size, 'agents');
}
/**
* Update behavior system. Call each frame with delta in seconds.
* @param {number} delta — seconds since last frame
*/
export function updateBehaviors(delta) {
if (!initialized) return;
// Throttle decision-making to save CPU
decisionAccumulator += delta;
if (decisionAccumulator < MIN_DECISION_INTERVAL) return;
const elapsed = decisionAccumulator;
decisionAccumulator = 0;
for (const [id, ab] of behaviors) {
// Tick down WS override
if (ab._wsOverride) {
ab._wsOverrideTimer -= elapsed;
if (ab._wsOverrideTimer <= 0) {
ab._wsOverride = false;
} else {
continue; // skip autonomous decision while WS override is active
}
}
// Tick down current behavior timer
ab.behaviorTimer -= elapsed;
if (ab.behaviorTimer > 0) continue;
// Time to pick a new behavior
const newBehavior = ab.pickNextBehavior(behaviors);
ab.currentBehavior = newBehavior;
ab.behaviorTimer = randRange(...(DURATIONS[newBehavior] || [5, 10]));
// For return_home, set a fixed timer based on distance
if (newBehavior === 'return_home') {
ab.behaviorTimer = 15; // max time to get home
}
// Execute the behavior
const executor = EXECUTORS[newBehavior];
if (executor) executor(ab);
}
}
/**
* Get current behavior for an agent.
* @param {string} agentId
* @returns {string|null}
*/
export function getAgentBehavior(agentId) {
const ab = behaviors.get(agentId);
return ab ? ab.currentBehavior : null;
}
/**
* Dispose the behavior system.
*/
export function disposeBehaviors() {
window.removeEventListener('matrix:agent_behavior', onBehaviorOverride);
behaviors.clear();
initialized = false;
decisionAccumulator = 0;
}

68
frontend/js/config.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* config.js — Connection configuration for The Matrix.
*
* Override at deploy time via URL query params:
* ?ws=ws://tower:8080/ws/world-state — WebSocket endpoint
* ?token=my-secret — Auth token (Phase 1 shared secret)
* ?mock=true — Force mock mode (no real WS)
*
* Or via Vite env vars:
* VITE_WS_URL — WebSocket endpoint
* VITE_WS_TOKEN — Auth token
* VITE_MOCK_MODE — 'true' to force mock mode
*
* Priority: URL params > env vars > defaults.
*
* Resolves Issue #7 — js/config.js
* Resolves Issue #11 — WS authentication strategy (Phase 1: shared secret)
*/
const params = new URLSearchParams(window.location.search);
function param(name, envKey, fallback) {
return params.get(name)
?? (import.meta.env[envKey] || null)
?? fallback;
}
export const Config = Object.freeze({
/** WebSocket endpoint. Empty string = no live connection (mock mode). */
wsUrl: param('ws', 'VITE_WS_URL', ''),
/** Auth token appended as ?token= query param on WS connect (Issue #11). */
wsToken: param('token', 'VITE_WS_TOKEN', ''),
/** Force mock mode even if wsUrl is set. Useful for local dev. */
mockMode: param('mock', 'VITE_MOCK_MODE', 'false') === 'true',
/** Reconnection timing */
reconnectBaseMs: 2000,
reconnectMaxMs: 30000,
/** Heartbeat / zombie detection */
heartbeatIntervalMs: 30000,
heartbeatTimeoutMs: 5000,
/**
* Computed: should we use the real WebSocket client?
* True when wsUrl is non-empty AND mockMode is false.
*/
get isLive() {
return this.wsUrl !== '' && !this.mockMode;
},
/**
* Build the final WS URL with auth token appended as a query param.
* Returns null if not in live mode.
*
* Result: ws://tower:8080/ws/world-state?token=my-secret
*/
get wsUrlWithAuth() {
if (!this.isLive) return null;
const url = new URL(this.wsUrl);
if (this.wsToken) {
url.searchParams.set('token', this.wsToken);
}
return url.toString();
},
});

261
frontend/js/demo.js Normal file
View File

@@ -0,0 +1,261 @@
/**
* demo.js — Demo autopilot for standalone mode.
*
* When The Matrix runs without a live backend (mock mode), this module
* simulates realistic activity: agent state changes, sat flow payments,
* economy updates, chat messages, streaming tokens, and connection pulses.
*
* The result is a self-running showcase of every visual feature.
*
* Start with `startDemo()`, stop with `stopDemo()`.
*/
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState, setAgentWalletHealth, getAgentPosition, pulseConnection } from './agents.js';
import { triggerSatFlow } from './satflow.js';
import { updateEconomyStatus } from './economy.js';
import { appendChatMessage, startStreamingMessage } from './ui.js';
import { showBark } from './bark.js';
import { setAmbientState } from './ambient.js';
/* ── Demo script data ── */
const AGENT_IDS = AGENT_DEFS.map(d => d.id);
const CHAT_LINES = [
{ agent: 'timmy', text: 'Cycle 544 complete. All tests green.' },
{ agent: 'perplexity', text: 'Smoke test 82/82 pass. Merging to main.' },
{ agent: 'replit', text: 'Admin relay refactored. Queue depth nominal.' },
{ agent: 'kimi', text: 'Deep research request filed. Scanning sources.' },
{ agent: 'claude', text: 'Code review done — looks clean, ship it.' },
{ agent: 'timmy', text: 'Invoice for 2,100 sats approved. Paying out.' },
{ agent: 'perplexity', text: 'New feature branch pushed: feat/demo-autopilot.' },
{ agent: 'replit', text: 'Strfry relay stats: 147 events/sec, 0 errors.' },
{ agent: 'kimi', text: 'Found 3 relevant papers. Summarizing now.' },
{ agent: 'claude', text: 'Edge case in the reconnect logic — filing a fix.' },
{ agent: 'timmy', text: 'The Tower stands. Another block confirmed.' },
{ agent: 'perplexity', text: 'Integration doc updated. Protocol v2 complete.' },
{ agent: 'replit', text: 'Nostr identity verified. Pubkey registered.' },
{ agent: 'kimi', text: 'Research complete. Report saved to workspace.' },
{ agent: 'claude', text: 'Streaming tokens working. Cursor blinks on cue.' },
];
const STREAM_LINES = [
{ agent: 'timmy', text: 'Analyzing commit history... Pattern detected: build velocity is increasing. The Tower grows stronger each cycle.' },
{ agent: 'perplexity', text: 'Running integration checks against the protocol spec. All 9 message types verified. Gateway adapter is ready for the next phase.' },
{ agent: 'kimi', text: 'Deep scan complete. Three high-signal sources found. Compiling synthesis with citations and confidence scores.' },
{ agent: 'claude', text: 'Reviewing the diff: 47 lines added, 12 removed. Logic is clean. Recommending merge with one minor style suggestion.' },
{ agent: 'replit', text: 'Relay metrics nominal. Throughput: 200 events/sec peak, 92 sustained. Memory stable at 128MB. No reconnection events.' },
];
const BARK_LINES = [
{ text: 'The Tower watches. The Tower remembers.', agent: 'timmy', emotion: 'calm' },
{ text: 'A visitor. Welcome to the Workshop.', agent: 'timmy', emotion: 'calm' },
{ text: 'New commit on main. The code evolves.', agent: 'timmy', emotion: 'excited' },
{ text: '222 — the number echoes again.', agent: 'timmy', emotion: 'calm' },
{ text: 'Sovereignty means running your own mind.', agent: 'timmy', emotion: 'calm' },
{ text: 'Five agents, one mission. Build.', agent: 'perplexity', emotion: 'focused' },
{ text: 'The relay hums. Events flow like water.', agent: 'replit', emotion: 'contemplative' },
];
/* ── Economy simulation state ── */
const economyState = {
treasury_sats: 500000,
treasury_usd: 4.85,
agents: {},
recent_transactions: [],
};
function initEconomyState() {
for (const def of AGENT_DEFS) {
economyState.agents[def.id] = {
balance_sats: 50000 + Math.floor(Math.random() * 100000),
reserved_sats: 20000 + Math.floor(Math.random() * 30000),
spent_today_sats: Math.floor(Math.random() * 15000),
};
}
}
/* ── Timers ── */
const timers = [];
let running = false;
function schedule(fn, minMs, maxMs) {
if (!running) return;
const delay = minMs + Math.random() * (maxMs - minMs);
const id = setTimeout(() => {
if (!running) return;
fn();
schedule(fn, minMs, maxMs);
}, delay);
timers.push(id);
}
/* ── Demo behaviors ── */
function randomAgent() {
return AGENT_IDS[Math.floor(Math.random() * AGENT_IDS.length)];
}
function randomPair() {
const a = randomAgent();
let b = randomAgent();
while (b === a) b = randomAgent();
return [a, b];
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
/** Cycle agents through active/idle states */
function demoStateChange() {
const agentId = randomAgent();
const state = Math.random() > 0.4 ? 'active' : 'idle';
setAgentState(agentId, state);
// If going active, return to idle after 3-8s
if (state === 'active') {
const revert = setTimeout(() => {
if (running) setAgentState(agentId, 'idle');
}, 3000 + Math.random() * 5000);
timers.push(revert);
}
}
/** Fire sat flow between two agents */
function demoPayment() {
const [from, to] = randomPair();
const fromPos = getAgentPosition(from);
const toPos = getAgentPosition(to);
if (fromPos && toPos) {
const amount = 100 + Math.floor(Math.random() * 5000);
triggerSatFlow(fromPos, toPos, amount);
// Update economy state
const fromData = economyState.agents[from];
const toData = economyState.agents[to];
if (fromData) fromData.spent_today_sats += amount;
if (toData) toData.balance_sats += amount;
economyState.recent_transactions.push({
from, to, amount_sats: amount,
});
if (economyState.recent_transactions.length > 5) {
economyState.recent_transactions.shift();
}
}
}
/** Update the economy panel with simulated data */
function demoEconomy() {
// Drift treasury and agent balances slightly
economyState.treasury_sats += Math.floor((Math.random() - 0.3) * 2000);
economyState.treasury_usd = economyState.treasury_sats / 100000;
for (const id of AGENT_IDS) {
const data = economyState.agents[id];
if (data) {
data.balance_sats += Math.floor((Math.random() - 0.4) * 1000);
data.balance_sats = Math.max(500, data.balance_sats);
}
}
updateEconomyStatus({ ...economyState });
// Update wallet health glow on agents
for (const id of AGENT_IDS) {
const data = economyState.agents[id];
if (data) {
const health = Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3));
setAgentWalletHealth(id, health);
}
}
}
/** Show a chat message from a random agent */
function demoChat() {
const line = pick(CHAT_LINES);
const def = AGENT_DEFS.find(d => d.id === line.agent);
if (def) {
appendChatMessage(def.label, line.text, colorToCss(def.color));
}
}
/** Stream a message word-by-word */
function demoStream() {
const line = pick(STREAM_LINES);
const def = AGENT_DEFS.find(d => d.id === line.agent);
if (!def) return;
const stream = startStreamingMessage(def.label, colorToCss(def.color));
const words = line.text.split(' ');
let i = 0;
const wordTimer = setInterval(() => {
if (!running || i >= words.length) {
clearInterval(wordTimer);
if (stream && stream.finish) stream.finish();
return;
}
const token = (i === 0 ? '' : ' ') + words[i];
if (stream && stream.push) stream.push(token);
i++;
}, 60 + Math.random() * 80);
timers.push(wordTimer);
}
/** Pulse a connection line between two agents */
function demoPulse() {
const [a, b] = randomPair();
pulseConnection(a, b, 3000 + Math.random() * 3000);
}
/** Cycle ambient mood */
const MOODS = ['calm', 'focused', 'storm', 'night', 'dawn'];
let moodIndex = 0;
function demoAmbient() {
moodIndex = (moodIndex + 1) % MOODS.length;
setAmbientState(MOODS[moodIndex]);
}
/** Show a bark */
function demoBark() {
const line = pick(BARK_LINES);
showBark({ text: line.text, agentId: line.agent, emotion: line.emotion });
}
/* ── Public API ── */
export function startDemo() {
if (running) return;
running = true;
initEconomyState();
// Initial economy push so the panel isn't empty
demoEconomy();
// Set initial wallet health
for (const id of AGENT_IDS) {
setAgentWalletHealth(id, 0.5 + Math.random() * 0.5);
}
// Schedule recurring demo events at realistic intervals
schedule(demoStateChange, 2000, 5000); // state changes: every 2-5s
schedule(demoPayment, 6000, 15000); // payments: every 6-15s
schedule(demoEconomy, 8000, 20000); // economy updates: every 8-20s
schedule(demoChat, 5000, 12000); // chat messages: every 5-12s
schedule(demoStream, 20000, 40000); // streaming: every 20-40s
schedule(demoPulse, 4000, 10000); // connection pulses: every 4-10s
schedule(demoBark, 18000, 35000); // barks: every 18-35s
schedule(demoAmbient, 30000, 60000); // ambient mood: every 30-60s
}
export function stopDemo() {
running = false;
for (const id of timers) clearTimeout(id);
timers.length = 0;
}

100
frontend/js/economy.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* economy.js — Wallet & treasury panel for the Matrix HUD.
*
* Displays the system treasury, per-agent balances, and recent
* transactions in a compact panel anchored to the bottom-left
* (above the chat). Updated by `economy_status` WS messages.
*
* Resolves Issue #17 — Wallet & treasury panel
*/
let $panel = null;
let latestStatus = null;
/* ── API ── */
export function initEconomy() {
$panel = document.getElementById('economy-panel');
if (!$panel) return;
_render(null);
}
/**
* Update the economy display with fresh data.
* @param {object} status — economy_status WS payload
*/
export function updateEconomyStatus(status) {
latestStatus = status;
_render(status);
}
export function disposeEconomy() {
latestStatus = null;
if ($panel) $panel.innerHTML = '';
}
/* ── Render ── */
function _render(status) {
if (!$panel) return;
if (!status) {
$panel.innerHTML = `
<div class="econ-header">TREASURY</div>
<div class="econ-waiting">Awaiting economy data&hellip;</div>
`;
return;
}
const treasury = _formatSats(status.treasury_sats || 0);
const usd = status.treasury_usd != null ? ` ($${status.treasury_usd.toFixed(2)})` : '';
// Per-agent rows
const agents = status.agents || {};
const agentRows = Object.entries(agents).map(([id, data]) => {
const bal = _formatSats(data.balance_sats || 0);
const spent = _formatSats(data.spent_today_sats || 0);
const health = data.balance_sats != null && data.reserved_sats != null
? Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3))
: 1;
const healthColor = health > 0.5 ? '#00ff41' : health > 0.2 ? '#ffaa00' : '#ff4422';
return `
<div class="econ-agent-row">
<span class="econ-dot" style="background:${healthColor};box-shadow:0 0 4px ${healthColor}"></span>
<span class="econ-agent-name">${_esc(id.toUpperCase())}</span>
<span class="econ-agent-bal">${bal}</span>
<span class="econ-agent-spent">-${spent}</span>
</div>
`;
}).join('');
// Recent transactions (last 3)
const txns = (status.recent_transactions || []).slice(-3);
const txnRows = txns.map(tx => {
const amt = _formatSats(tx.amount_sats || 0);
const arrow = `${_esc((tx.from || '?').toUpperCase())}${_esc((tx.to || '?').toUpperCase())}`;
return `<div class="econ-tx">${arrow} <span class="econ-tx-amt">${amt}</span></div>`;
}).join('');
$panel.innerHTML = `
<div class="econ-header">
<span>TREASURY</span>
<span class="econ-total">${treasury}${_esc(usd)}</span>
</div>
${agentRows ? `<div class="econ-agents">${agentRows}</div>` : ''}
${txnRows ? `<div class="econ-txns"><div class="econ-txns-label">RECENT</div>${txnRows}</div>` : ''}
`;
}
/* ── Helpers ── */
function _formatSats(sats) {
if (sats >= 1000000) return (sats / 1000000).toFixed(1) + 'M ₿';
if (sats >= 1000) return (sats / 1000).toFixed(1) + 'k ₿';
return sats.toLocaleString() + ' ₿';
}
function _esc(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

195
frontend/js/effects.js vendored Normal file
View File

@@ -0,0 +1,195 @@
/**
* effects.js — Matrix rain + starfield particle effects.
*
* Optimizations (Issue #34):
* - Frame skipping on low-tier hardware (update every 2nd frame)
* - Bounding sphere set to skip Three.js per-particle frustum test
* - Tight typed-array loop with stride-3 addressing (no object allocation)
* - Particles recycle to camera-relative region on respawn for density
* - drawRange used to soft-limit visible particles if FPS drops
*/
import * as THREE from 'three';
import { getQualityTier } from './quality.js';
import { getRainSpeedMultiplier, getRainOpacity, getStarOpacity } from './ambient.js';
let rainParticles;
let rainPositions;
let rainVelocities;
let rainCount = 0;
let skipFrames = 0; // 0 = update every frame, 1 = every 2nd frame
let frameCounter = 0;
let starfield = null;
/** Adaptive draw range — reduced if FPS drops below threshold. */
let activeCount = 0;
const FPS_FLOOR = 20;
const ADAPT_INTERVAL_MS = 2000;
let lastFpsCheck = 0;
let fpsAccum = 0;
let fpsSamples = 0;
export function initEffects(scene) {
const tier = getQualityTier();
skipFrames = tier === 'low' ? 1 : 0;
initMatrixRain(scene, tier);
initStarfield(scene, tier);
}
function initMatrixRain(scene, tier) {
rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000;
activeCount = rainCount;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(rainCount * 3);
const velocities = new Float32Array(rainCount);
const colors = new Float32Array(rainCount * 3);
for (let i = 0; i < rainCount; i++) {
const i3 = i * 3;
positions[i3] = (Math.random() - 0.5) * 100;
positions[i3 + 1] = Math.random() * 50 + 5;
positions[i3 + 2] = (Math.random() - 0.5) * 100;
velocities[i] = 0.05 + Math.random() * 0.15;
const brightness = 0.3 + Math.random() * 0.7;
colors[i3] = 0;
colors[i3 + 1] = brightness;
colors[i3 + 2] = 0;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
// Pre-compute bounding sphere so Three.js skips per-frame recalc.
// Rain spans ±50 XZ, 060 Y — a sphere from origin with r=80 covers it.
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 25, 0), 80);
rainPositions = positions;
rainVelocities = velocities;
const mat = new THREE.PointsMaterial({
size: tier === 'low' ? 0.16 : 0.12,
vertexColors: true,
transparent: true,
opacity: 0.7,
sizeAttenuation: true,
});
rainParticles = new THREE.Points(geo, mat);
rainParticles.frustumCulled = false; // We manage visibility ourselves
scene.add(rainParticles);
}
function initStarfield(scene, tier) {
const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
positions[i3] = (Math.random() - 0.5) * 300;
positions[i3 + 1] = Math.random() * 80 + 10;
positions[i3 + 2] = (Math.random() - 0.5) * 300;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 40, 0), 200);
const mat = new THREE.PointsMaterial({
color: 0x003300,
size: 0.08,
transparent: true,
opacity: 0.5,
});
starfield = new THREE.Points(geo, mat);
starfield.frustumCulled = false;
scene.add(starfield);
}
/**
* Feed current FPS into the adaptive particle budget.
* Called externally from the render loop.
*/
export function feedFps(fps) {
fpsAccum += fps;
fpsSamples++;
}
export function updateEffects(_time) {
if (!rainParticles) return;
// On low tier, skip every other frame to halve iteration cost
if (skipFrames > 0) {
frameCounter++;
if (frameCounter % (skipFrames + 1) !== 0) return;
}
const velocityMul = (skipFrames > 0 ? (skipFrames + 1) : 1) * getRainSpeedMultiplier();
// Apply ambient-driven opacity
if (rainParticles.material.opacity !== getRainOpacity()) {
rainParticles.material.opacity = getRainOpacity();
}
if (starfield && starfield.material.opacity !== getStarOpacity()) {
starfield.material.opacity = getStarOpacity();
}
// Adaptive particle budget — check every ADAPT_INTERVAL_MS
const now = _time;
if (now - lastFpsCheck > ADAPT_INTERVAL_MS && fpsSamples > 0) {
const avgFps = fpsAccum / fpsSamples;
fpsAccum = 0;
fpsSamples = 0;
lastFpsCheck = now;
if (avgFps < FPS_FLOOR && activeCount > 200) {
// Drop 20% of particles to recover frame rate
activeCount = Math.max(200, Math.floor(activeCount * 0.8));
} else if (avgFps > FPS_FLOOR + 10 && activeCount < rainCount) {
// Recover particles gradually
activeCount = Math.min(rainCount, Math.floor(activeCount * 1.1));
}
rainParticles.geometry.setDrawRange(0, activeCount);
}
// Tight loop — stride-3 addressing, no object allocation
const pos = rainPositions;
const vel = rainVelocities;
const count = activeCount;
for (let i = 0; i < count; i++) {
const yIdx = i * 3 + 1;
pos[yIdx] -= vel[i] * velocityMul;
if (pos[yIdx] < -1) {
pos[yIdx] = 40 + Math.random() * 20;
pos[i * 3] = (Math.random() - 0.5) * 100;
pos[i * 3 + 2] = (Math.random() - 0.5) * 100;
}
}
rainParticles.geometry.attributes.position.needsUpdate = true;
}
/**
* Dispose all effect resources (used on world teardown).
*/
export function disposeEffects() {
if (rainParticles) {
rainParticles.geometry.dispose();
rainParticles.material.dispose();
rainParticles = null;
}
if (starfield) {
starfield.geometry.dispose();
starfield.material.dispose();
starfield = null;
}
rainPositions = null;
rainVelocities = null;
rainCount = 0;
activeCount = 0;
frameCounter = 0;
fpsAccum = 0;
fpsSamples = 0;
}

340
frontend/js/interaction.js Normal file
View File

@@ -0,0 +1,340 @@
/**
* interaction.js — Camera controls + agent touch/click interaction.
*
* Adds raycasting so users can tap/click on agents to see their info
* and optionally start a conversation. The info popup appears as a
* DOM overlay anchored near the clicked agent.
*
* Resolves Issue #44 — Touch-to-interact
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { getAgentDefs } from './agents.js';
import { colorToCss } from './agent-defs.js';
let controls;
let camera;
let renderer;
let scene;
/* ── Raycasting state ── */
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
/** Currently selected agent id (null if nothing selected) */
let selectedAgentId = null;
/** The info popup DOM element */
let $popup = null;
/* ── Public API ── */
export function initInteraction(cam, ren, scn) {
camera = cam;
renderer = ren;
scene = scn;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 5;
controls.maxDistance = 80;
controls.maxPolarAngle = Math.PI / 2.1;
controls.target.set(0, 0, 0);
controls.update();
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
// Pointer events (works for mouse and touch)
renderer.domElement.addEventListener('pointerdown', _onPointerDown, { passive: true });
renderer.domElement.addEventListener('pointermove', _onPointerMove, { passive: true });
renderer.domElement.addEventListener('pointerup', _onPointerUp, { passive: true });
_ensurePopup();
}
export function updateControls() {
if (controls) controls.update();
}
/**
* Called each frame from the render loop so the popup can track a
* selected agent's screen position.
*/
export function updateInteraction() {
if (!selectedAgentId || !$popup || $popup.style.display === 'none') return;
_positionPopup(selectedAgentId);
}
/** Deselect the current agent and hide the popup. */
export function deselectAgent() {
selectedAgentId = null;
if ($popup) $popup.style.display = 'none';
}
/**
* Dispose orbit controls and event listeners (used on world teardown).
*/
export function disposeInteraction() {
if (controls) {
controls.dispose();
controls = null;
}
if (renderer) {
renderer.domElement.removeEventListener('pointerdown', _onPointerDown);
renderer.domElement.removeEventListener('pointermove', _onPointerMove);
renderer.domElement.removeEventListener('pointerup', _onPointerUp);
}
deselectAgent();
}
/* ── Internal: pointer handling ── */
let _pointerDownPos = { x: 0, y: 0 };
let _pointerMoved = false;
function _onPointerDown(e) {
_pointerDownPos.x = e.clientX;
_pointerDownPos.y = e.clientY;
_pointerMoved = false;
}
function _onPointerMove(e) {
const dx = e.clientX - _pointerDownPos.x;
const dy = e.clientY - _pointerDownPos.y;
if (Math.abs(dx) + Math.abs(dy) > 6) _pointerMoved = true;
}
function _onPointerUp(e) {
// Ignore drags — only respond to taps/clicks
if (_pointerMoved) return;
_handleTap(e.clientX, e.clientY);
}
/* ── Raycasting ── */
function _handleTap(clientX, clientY) {
if (!camera || !scene) return;
pointer.x = (clientX / window.innerWidth) * 2 - 1;
pointer.y = -(clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
// Collect all agent group meshes
const agentDefs = getAgentDefs();
const meshes = [];
for (const def of agentDefs) {
// Each agent group is a direct child of the scene
scene.traverse(child => {
if (child.isGroup && child.children.length > 0) {
// Check if this group's first mesh color matches an agent
const coreMesh = child.children.find(c => c.isMesh && c.geometry?.type === 'IcosahedronGeometry');
if (coreMesh) {
meshes.push({ mesh: child, agentId: _matchGroupToAgent(child, agentDefs) });
}
}
});
break; // only need to traverse once
}
// Raycast against all scene objects, find the nearest agent group or memory orb
const allMeshes = [];
scene.traverse(obj => { if (obj.isMesh) allMeshes.push(obj); });
const intersects = raycaster.intersectObjects(allMeshes, false);
let hitAgentId = null;
let hitFact = null;
for (const hit of intersects) {
// 1. Check if it's a memory orb
if (hit.object.id && hit.object.id.startsWith('fact_')) {
hitFact = {
id: hit.object.id,
data: hit.object.userData
};
break;
}
// 2. Walk up to find the agent group
let obj = hit.object;
while (obj && obj.parent) {
const matched = _matchGroupToAgent(obj, agentDefs);
if (matched) {
hitAgentId = matched;
break;
}
obj = obj.parent;
}
if (hitAgentId) break;
}
if (hitAgentId) {
_selectAgent(hitAgentId);
} else if (hitFact) {
_selectFact(hitFact.id, hitFact.data);
} else {
deselectAgent();
}
}
/**
* Try to match a Three.js group to an agent by comparing positions.
*/
function _matchGroupToAgent(group, agentDefs) {
if (!group.isGroup) return null;
for (const def of agentDefs) {
// Agent positions: (def.x, ~0, def.z) — the group y bobs, so just check xz
const dx = Math.abs(group.position.x - (def.position?.x ?? 0));
const dz = Math.abs(group.position.z - (def.position?.z ?? 0));
// getAgentDefs returns { id, label, role, color, state } — no position.
// We need to compare the group position to the known AGENT_DEFS x/z.
// Since getAgentDefs doesn't return position, match by finding the icosahedron
// core color against agent color.
const coreMesh = group.children.find(c => c.isMesh && c.material?.emissive);
if (coreMesh) {
const meshColor = coreMesh.material.color.getHex();
if (meshColor === def.color) return def.id;
}
}
return null;
}
/* ── Agent selection & popup ── */
function _selectAgent(agentId) {
selectedAgentId = agentId;
const defs = getAgentDefs();
const agent = defs.find(d => d.id === agentId);
if (!agent) return;
_ensurePopup();
const color = colorToCss(agent.color);
const stateLabel = (agent.state || 'idle').toUpperCase();
const stateColor = agent.state === 'active' ? '#00ff41' : '#33aa55';
$popup.innerHTML = `
<div class="agent-popup-header" style="border-color:${color}">
<span class="agent-popup-name" style="color:${color}">${_esc(agent.label)}</span>
<span class="agent-popup-close" id="agent-popup-close">&times;</span>
</div>
<div class="agent-popup-role">${_esc(agent.role)}</div>
<div class="agent-popup-state" style="color:${stateColor}">&#9679; ${stateLabel}</div>
<button class="agent-popup-talk" id="agent-popup-talk" style="border-color:${color};color:${color}">
TALK &rarr;
</button>
`;
$popup.style.display = 'block';
// Position near agent
_positionPopup(agentId);
// Close button
const $close = document.getElementById('agent-popup-close');
if ($close) $close.addEventListener('click', deselectAgent);
// Talk button — focus the chat input and prefill
const $talk = document.getElementById('agent-popup-talk');
if ($talk) {
$talk.addEventListener('click', () => {
const $input = document.getElementById('chat-input');
if ($input) {
$input.focus();
$input.placeholder = `Say something to ${agent.label}...`;
}
deselectAgent();
});
}
}
function _selectFact(factId, data) {
selectedAgentId = null; // clear agent selection
_ensurePopup();
const categoryColors = {
user_pref: '#00ffaa',
project: '#00aaff',
tool: '#ffaa00',
general: '#ffffff',
};
const color = categoryColors[data.category] || '#cccccc';
$popup.innerHTML = `
<div class="agent-popup-header" style="border-color:${color}">
<span class="agent-popup-name" style="color:${color}">Memory Fact</span>
<span class="agent-popup-close" id="agent-popup-close">&times;</span>
</div>
<div class="agent-popup-role" style="font-style: italic;">Category: ${_esc(data.category || 'general')}</div>
<div class="agent-popup-state" style="margin: 8px 0; line-height: 1.4; font-size: 0.9em;">${_esc(data.content)}</div>
<div class="agent-popup-state" style="color:#aaa; font-size: 0.8em;">ID: ${_esc(factId)}</div>
`;
$popup.style.display = 'block';
_positionPopup(factId);
const $close = document.getElementById('agent-popup-close');
if ($close) $close.addEventListener('click', deselectAgent);
}
function _positionPopup(id) {
if (!camera || !renderer || !$popup) return;
let targetObj = null;
scene.traverse(obj => {
if (targetObj) return;
// If it's an agent ID, we find the group. If it's a fact ID, we find the mesh.
if (id.startsWith('fact_')) {
if (obj.id === id) targetObj = obj;
} else {
if (obj.isGroup) {
const defs = getAgentDefs();
const def = defs.find(d => d.id === id);
if (def) {
const core = obj.children.find(c => c.isMesh && c.material?.emissive);
if (core && core.material.color.getHex() === def.color) {
targetObj = obj;
}
}
}
}
});
if (!targetObj) return;
const worldPos = new THREE.Vector3();
targetObj.getWorldPosition(worldPos);
worldPos.y += 1.5;
const screenPos = worldPos.clone().project(camera);
const hw = window.innerWidth / 2;
const hh = window.innerHeight / 2;
const sx = screenPos.x * hw + hw;
const sy = -screenPos.y * hh + hh;
if (screenPos.z > 1) {
$popup.style.display = 'none';
return;
}
const popW = $popup.offsetWidth || 180;
const popH = $popup.offsetHeight || 120;
const x = Math.min(Math.max(sx - popW / 2, 8), window.innerWidth - popW - 8);
const y = Math.min(Math.max(sy - popH - 12, 8), window.innerHeight - popH - 60);
$popup.style.left = x + 'px';
$popup.style.top = y + 'px';
}
/* ── Popup DOM ── */
function _ensurePopup() {
if ($popup) return;
$popup = document.createElement('div');
$popup.id = 'agent-popup';
$popup.style.display = 'none';
document.body.appendChild($popup);
}
function _esc(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

180
frontend/js/main.js Normal file
View File

@@ -0,0 +1,180 @@
import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects, feedFps } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, updateControls, updateInteraction, disposeInteraction } from './interaction.js';
import { initAmbient, updateAmbient, disposeAmbient } from './ambient.js';
import { initSatFlow, updateSatFlow, disposeSatFlow } from './satflow.js';
import { initEconomy, disposeEconomy } from './economy.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initVisitor } from './visitor.js';
import { initPresence, disposePresence } from './presence.js';
import { initTranscript } from './transcript.js';
import { initAvatar, updateAvatar, getAvatarMainCamera, renderAvatarPiP, disposeAvatar } from './avatar.js';
import { initSceneObjects, updateSceneObjects, clearSceneObjects } from './scene-objects.js';
import { updateZones } from './zones.js';
import { initBehaviors, updateBehaviors, disposeBehaviors } from './behaviors.js';
let running = false;
let canvas = null;
/**
* Build (or rebuild) the Three.js world.
*
* @param {boolean} firstInit
* true — first page load: also starts UI, WebSocket, and visitor
* false — context-restore reinit: skips UI/WS (they survive context loss)
* @param {Object.<string,string>|null} stateSnapshot
* Agent state map captured just before teardown; reapplied after initAgents.
*/
function buildWorld(firstInit, stateSnapshot) {
const { scene, camera, renderer } = initWorld(canvas);
canvas = renderer.domElement;
initEffects(scene);
initAgents(scene);
if (stateSnapshot) {
applyAgentStates(stateSnapshot);
}
initSceneObjects(scene);
initBehaviors(); // autonomous agent behaviors (#68)
initAvatar(scene, camera, renderer);
initInteraction(camera, renderer, scene);
initAmbient(scene);
initSatFlow(scene);
if (firstInit) {
initUI();
initEconomy();
initWebSocket(scene);
initVisitor();
initPresence();
initTranscript();
// Dismiss loading screen
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) loadingScreen.classList.add('hidden');
}
// Debounce resize to 1 call per frame
const ac = new AbortController();
let resizeFrame = null;
window.addEventListener('resize', () => {
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => onWindowResize(camera, renderer));
}, { signal: ac.signal });
let frameCount = 0;
let lastFpsTime = performance.now();
let currentFps = 0;
let rafId = null;
let lastTime = performance.now();
running = true;
function animate() {
if (!running) return;
rafId = requestAnimationFrame(animate);
const now = performance.now();
const delta = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
frameCount++;
if (now - lastFpsTime >= 1000) {
currentFps = Math.round(frameCount * 1000 / (now - lastFpsTime));
frameCount = 0;
lastFpsTime = now;
}
updateControls();
updateInteraction();
updateAmbient(delta);
updateSatFlow(delta);
feedFps(currentFps);
updateEffects(now);
updateAgents(now, delta);
updateBehaviors(delta);
updateSceneObjects(now, delta);
updateZones(null); // portal handler wired via loadWorld in websocket.js
updateAvatar(delta);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),
jobCount: getJobCount(),
connectionState: getConnectionState(),
});
renderer.render(scene, getAvatarMainCamera());
renderAvatarPiP(scene);
}
// Pause rendering when tab is backgrounded (saves battery on iPad PWA)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
running = false;
}
} else {
if (!running) {
running = true;
animate();
}
}
});
animate();
return { scene, renderer, ac };
}
function teardown({ scene, renderer, ac }) {
running = false;
ac.abort();
disposeAvatar();
disposeInteraction();
disposeAmbient();
disposeSatFlow();
disposeEconomy();
disposeEffects();
disposePresence();
clearSceneObjects();
disposeBehaviors();
disposeAgents();
disposeWorld(renderer, scene);
}
function main() {
const $overlay = document.getElementById('webgl-recovery-overlay');
let handle = buildWorld(true, null);
// WebGL context loss recovery (iPad PWA, GPU driver reset, etc.)
canvas.addEventListener('webglcontextlost', event => {
event.preventDefault();
running = false;
if ($overlay) $overlay.style.display = 'flex';
});
canvas.addEventListener('webglcontextrestored', () => {
const snapshot = getAgentStates();
teardown(handle);
handle = buildWorld(false, snapshot);
if ($overlay) $overlay.style.display = 'none';
});
}
main();
// Register service worker only in production builds
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}

139
frontend/js/presence.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* presence.js — Agent Presence HUD for The Matrix.
*
* Shows a live "who's online" panel with connection status indicators,
* uptime tracking, and animated pulse dots per agent. Updates every second.
*
* In mock mode, all built-in agents show as "online" with simulated uptime.
* In live mode, the panel reacts to WS events (agent_state, agent_joined, agent_left).
*
* Resolves Issue #53
*/
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { getAgentDefs } from './agents.js';
import { getConnectionState } from './websocket.js';
/** @type {HTMLElement|null} */
let $panel = null;
/** @type {Map<string, { online: boolean, since: number }>} */
const presence = new Map();
let updateInterval = null;
/* ── Public API ── */
export function initPresence() {
$panel = document.getElementById('presence-hud');
if (!$panel) return;
// Initialize all built-in agents
const now = Date.now();
for (const def of AGENT_DEFS) {
presence.set(def.id, { online: true, since: now });
}
// Initial render
render();
// Update every second for uptime tickers
updateInterval = setInterval(render, 1000);
}
/**
* Mark an agent as online (called from websocket.js on agent_joined/agent_register).
*/
export function setAgentOnline(agentId) {
const entry = presence.get(agentId);
if (entry) {
entry.online = true;
entry.since = Date.now();
} else {
presence.set(agentId, { online: true, since: Date.now() });
}
}
/**
* Mark an agent as offline (called from websocket.js on agent_left/disconnect).
*/
export function setAgentOffline(agentId) {
const entry = presence.get(agentId);
if (entry) {
entry.online = false;
}
}
export function disposePresence() {
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
presence.clear();
}
/* ── Internal ── */
function formatUptime(ms) {
const totalSec = Math.floor(ms / 1000);
if (totalSec < 60) return `${totalSec}s`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
if (min < 60) return `${min}m ${String(sec).padStart(2, '0')}s`;
const hr = Math.floor(min / 60);
const remMin = min % 60;
return `${hr}h ${String(remMin).padStart(2, '0')}m`;
}
function render() {
if (!$panel) return;
const connState = getConnectionState();
const defs = getAgentDefs();
const now = Date.now();
// In mock mode, all agents are "online"
const isMock = connState === 'mock';
let onlineCount = 0;
const rows = [];
for (const def of defs) {
const p = presence.get(def.id);
const isOnline = isMock ? true : (p?.online ?? false);
if (isOnline) onlineCount++;
const uptime = isOnline && p ? formatUptime(now - p.since) : '--';
const color = colorToCss(def.color);
const stateLabel = def.state === 'active' ? 'ACTIVE' : 'IDLE';
const dotClass = isOnline ? 'presence-dot online' : 'presence-dot offline';
const stateColor = def.state === 'active' ? '#00ff41' : '#33aa55';
rows.push(
`<div class="presence-row">` +
`<span class="${dotClass}" style="--agent-color:${color}"></span>` +
`<span class="presence-name" style="color:${color}">${escapeHtml(def.label)}</span>` +
`<span class="presence-state" style="color:${stateColor}">${stateLabel}</span>` +
`<span class="presence-uptime">${uptime}</span>` +
`</div>`
);
}
const modeLabel = isMock ? 'LOCAL' : (connState === 'connected' ? 'LIVE' : 'OFFLINE');
const modeColor = connState === 'connected' ? '#00ff41' : (isMock ? '#33aa55' : '#553300');
$panel.innerHTML =
`<div class="presence-header">` +
`<span>PRESENCE</span>` +
`<span class="presence-count">${onlineCount}/${defs.length}</span>` +
`<span class="presence-mode" style="color:${modeColor}">${modeLabel}</span>` +
`</div>` +
rows.join('');
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

90
frontend/js/quality.js Normal file
View File

@@ -0,0 +1,90 @@
/**
* quality.js — Detect hardware capability and return a quality tier.
*
* Tiers:
* 'low' — older iPads, phones, low-end GPUs (reduce particles, simpler effects)
* 'medium' — mid-range (moderate particle count)
* 'high' — desktop, modern iPad Pro (full quality)
*
* Detection uses a combination of:
* - Device pixel ratio (low DPR = likely low-end)
* - Logical core count (navigator.hardwareConcurrency)
* - Device memory (navigator.deviceMemory, Chrome/Edge only)
* - Screen size (small viewport = likely mobile)
* - Touch capability (touch + small screen = phone/tablet)
* - WebGL renderer string (if available)
*/
let cachedTier = null;
export function getQualityTier() {
if (cachedTier) return cachedTier;
let score = 0;
// Core count: 1-2 = low, 4 = mid, 8+ = high
const cores = navigator.hardwareConcurrency || 2;
if (cores >= 8) score += 3;
else if (cores >= 4) score += 2;
else score += 0;
// Device memory (Chrome/Edge): < 4GB = low, 4-8 = mid, 8+ = high
const mem = navigator.deviceMemory || 4;
if (mem >= 8) score += 3;
else if (mem >= 4) score += 2;
else score += 0;
// Screen dimensions (logical pixels)
const maxDim = Math.max(window.screen.width, window.screen.height);
if (maxDim < 768) score -= 1; // phone
else if (maxDim >= 1920) score += 1; // large desktop
// DPR: high DPR on small screens = more GPU work
const dpr = window.devicePixelRatio || 1;
if (dpr > 2 && maxDim < 1024) score -= 1; // retina phone
// Touch-only device heuristic
const touchOnly = 'ontouchstart' in window && !window.matchMedia('(pointer: fine)').matches;
if (touchOnly) score -= 1;
// Try reading WebGL renderer for GPU hints
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('webgl2');
if (gl) {
const debugExt = gl.getExtension('WEBGL_debug_renderer_info');
if (debugExt) {
const renderer = gl.getParameter(debugExt.UNMASKED_RENDERER_WEBGL).toLowerCase();
// Known low-end GPU strings
if (renderer.includes('swiftshader') || renderer.includes('llvmpipe') || renderer.includes('software')) {
score -= 3; // software renderer
}
if (renderer.includes('apple gpu') || renderer.includes('apple m')) {
score += 2; // Apple Silicon is good
}
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
} catch {
// Can't probe GPU, use other signals
}
// Map score to tier
if (score <= 1) cachedTier = 'low';
else if (score <= 4) cachedTier = 'medium';
else cachedTier = 'high';
console.info(`[Matrix Quality] Tier: ${cachedTier} (score: ${score}, cores: ${cores}, mem: ${mem}GB, dpr: ${dpr}, touch: ${touchOnly})`);
return cachedTier;
}
/**
* Get the recommended pixel ratio cap for the renderer.
*/
export function getMaxPixelRatio() {
const tier = getQualityTier();
if (tier === 'low') return 1;
if (tier === 'medium') return 1.5;
return 2;
}

261
frontend/js/satflow.js Normal file
View File

@@ -0,0 +1,261 @@
/**
* satflow.js — Sat flow particle effects for Lightning payments.
*
* When a payment_flow event arrives, gold particles fly from sender
* to receiver along a bezier arc. On arrival, a brief burst radiates
* outward from the target agent.
*
* Resolves Issue #13 — Sat flow particle effects
*/
import * as THREE from 'three';
let scene = null;
/* ── Pool management ── */
const MAX_ACTIVE_FLOWS = 6;
const activeFlows = [];
/* ── Shared resources ── */
const SAT_COLOR = new THREE.Color(0xffcc00);
const BURST_COLOR = new THREE.Color(0xffee44);
const particleGeo = new THREE.BufferGeometry();
// Pre-build a single-point geometry for instancing via Points
const _singleVert = new Float32Array([0, 0, 0]);
particleGeo.setAttribute('position', new THREE.BufferAttribute(_singleVert, 3));
/* ── API ── */
/**
* Initialize the sat flow system.
* @param {THREE.Scene} scn
*/
export function initSatFlow(scn) {
scene = scn;
}
/**
* Trigger a sat flow animation between two world positions.
*
* @param {THREE.Vector3} fromPos — sender world position
* @param {THREE.Vector3} toPos — receiver world position
* @param {number} amountSats — payment amount (scales particle count)
*/
export function triggerSatFlow(fromPos, toPos, amountSats = 100) {
if (!scene) return;
// Evict oldest flow if at capacity
if (activeFlows.length >= MAX_ACTIVE_FLOWS) {
const old = activeFlows.shift();
_cleanupFlow(old);
}
// Particle count: 5-20 based on amount, log-scaled
const count = Math.min(20, Math.max(5, Math.round(Math.log10(amountSats + 1) * 5)));
const flow = _createFlow(fromPos.clone(), toPos.clone(), count);
activeFlows.push(flow);
}
/**
* Per-frame update — advance all active flows.
* @param {number} delta — seconds since last frame
*/
export function updateSatFlow(delta) {
for (let i = activeFlows.length - 1; i >= 0; i--) {
const flow = activeFlows[i];
flow.elapsed += delta;
if (flow.phase === 'travel') {
_updateTravel(flow, delta);
if (flow.elapsed >= flow.duration) {
flow.phase = 'burst';
flow.elapsed = 0;
_startBurst(flow);
}
} else if (flow.phase === 'burst') {
_updateBurst(flow, delta);
if (flow.elapsed >= flow.burstDuration) {
_cleanupFlow(flow);
activeFlows.splice(i, 1);
}
}
}
}
/**
* Dispose all sat flow resources.
*/
export function disposeSatFlow() {
for (const flow of activeFlows) _cleanupFlow(flow);
activeFlows.length = 0;
scene = null;
}
/* ── Internals: Flow lifecycle ── */
function _createFlow(from, to, count) {
// Bezier control point — arc upward
const mid = new THREE.Vector3().lerpVectors(from, to, 0.5);
mid.y += 3 + from.distanceTo(to) * 0.3;
// Create particles
const positions = new Float32Array(count * 3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.boundingSphere = new THREE.Sphere(mid, 50);
const mat = new THREE.PointsMaterial({
color: SAT_COLOR,
size: 0.25,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
// Per-particle timing offsets (stagger the swarm)
const offsets = new Float32Array(count);
for (let i = 0; i < count; i++) {
offsets[i] = (i / count) * 0.4; // stagger over first 40% of duration
}
return {
phase: 'travel',
elapsed: 0,
duration: 1.5 + from.distanceTo(to) * 0.05, // 1.52.5s depending on distance
from, to, mid,
count,
points, geo, mat, positions,
offsets,
burstPoints: null,
burstGeo: null,
burstMat: null,
burstPositions: null,
burstVelocities: null,
burstDuration: 0.6,
};
}
function _updateTravel(flow, _delta) {
const { from, to, mid, count, positions, offsets, elapsed, duration } = flow;
for (let i = 0; i < count; i++) {
// Per-particle progress with stagger offset
let t = (elapsed - offsets[i]) / (duration - 0.4);
t = Math.max(0, Math.min(1, t));
// Quadratic bezier: B(t) = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
const mt = 1 - t;
const i3 = i * 3;
positions[i3] = mt * mt * from.x + 2 * mt * t * mid.x + t * t * to.x;
positions[i3 + 1] = mt * mt * from.y + 2 * mt * t * mid.y + t * t * to.y;
positions[i3 + 2] = mt * mt * from.z + 2 * mt * t * mid.z + t * t * to.z;
// Add slight wobble for organic feel
const wobble = Math.sin(elapsed * 12 + i * 1.7) * 0.08;
positions[i3] += wobble;
positions[i3 + 2] += wobble;
}
flow.geo.attributes.position.needsUpdate = true;
// Fade in/out
if (elapsed < 0.2) {
flow.mat.opacity = elapsed / 0.2;
} else if (elapsed > duration - 0.3) {
flow.mat.opacity = Math.max(0, (duration - elapsed) / 0.3);
} else {
flow.mat.opacity = 1.0;
}
}
function _startBurst(flow) {
// Hide travel particles
if (flow.points) flow.points.visible = false;
// Create burst particles at destination
const burstCount = 12;
const positions = new Float32Array(burstCount * 3);
const velocities = new Float32Array(burstCount * 3);
for (let i = 0; i < burstCount; i++) {
const i3 = i * 3;
positions[i3] = flow.to.x;
positions[i3 + 1] = flow.to.y + 0.5;
positions[i3 + 2] = flow.to.z;
// Random outward velocity
const angle = (i / burstCount) * Math.PI * 2;
const speed = 2 + Math.random() * 3;
velocities[i3] = Math.cos(angle) * speed;
velocities[i3 + 1] = 1 + Math.random() * 3;
velocities[i3 + 2] = Math.sin(angle) * speed;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.boundingSphere = new THREE.Sphere(flow.to, 20);
const mat = new THREE.PointsMaterial({
color: BURST_COLOR,
size: 0.18,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
const points = new THREE.Points(geo, mat);
scene.add(points);
flow.burstPoints = points;
flow.burstGeo = geo;
flow.burstMat = mat;
flow.burstPositions = positions;
flow.burstVelocities = velocities;
}
function _updateBurst(flow, delta) {
if (!flow.burstPositions) return;
const pos = flow.burstPositions;
const vel = flow.burstVelocities;
const count = pos.length / 3;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
pos[i3] += vel[i3] * delta;
pos[i3 + 1] += vel[i3 + 1] * delta;
pos[i3 + 2] += vel[i3 + 2] * delta;
// Gravity
vel[i3 + 1] -= 6 * delta;
}
flow.burstGeo.attributes.position.needsUpdate = true;
// Fade out
const t = flow.elapsed / flow.burstDuration;
flow.burstMat.opacity = Math.max(0, 1 - t);
}
function _cleanupFlow(flow) {
if (flow.points) {
scene?.remove(flow.points);
flow.geo?.dispose();
flow.mat?.dispose();
}
if (flow.burstPoints) {
scene?.remove(flow.burstPoints);
flow.burstGeo?.dispose();
flow.burstMat?.dispose();
}
}

View File

@@ -0,0 +1,756 @@
/**
* scene-objects.js — Runtime 3D object registry for The Matrix.
*
* Allows agents (especially Timmy) to dynamically add, update, move, and
* remove 3D objects in the world via WebSocket messages — no redeploy needed.
*
* Supported primitives: box, sphere, cylinder, cone, torus, plane, ring, text
* Special types: portal (visual gateway + trigger zone), light, group
* Each object has an id, transform, material properties, and optional animation.
*
* Sub-worlds: agents can define named environments (collections of objects +
* lighting + fog + ambient) and load/unload them atomically. Portals can
* reference sub-worlds as their destination.
*
* Resolves Issue #8 — Dynamic scene mutation (WS gateway adapter)
*/
import * as THREE from 'three';
import { addZone, removeZone, clearZones } from './zones.js';
let scene = null;
const registry = new Map(); // id → { object, def, animator }
/* ── Sub-world system ── */
const worlds = new Map(); // worldId → { objects: [...def], ambient, fog, saved }
let activeWorld = null; // currently loaded sub-world id (null = home)
let _homeSnapshot = null; // snapshot of home world objects before portal travel
const _worldChangeListeners = []; // callbacks for world transitions
/** Subscribe to world change events. */
export function onWorldChange(fn) { _worldChangeListeners.push(fn); }
/* ── Geometry factories ── */
const GEO_FACTORIES = {
box: (p) => new THREE.BoxGeometry(p.width ?? 1, p.height ?? 1, p.depth ?? 1),
sphere: (p) => new THREE.SphereGeometry(p.radius ?? 0.5, p.segments ?? 16, p.segments ?? 16),
cylinder: (p) => new THREE.CylinderGeometry(p.radiusTop ?? 0.5, p.radiusBottom ?? 0.5, p.height ?? 1, p.segments ?? 16),
cone: (p) => new THREE.ConeGeometry(p.radius ?? 0.5, p.height ?? 1, p.segments ?? 16),
torus: (p) => new THREE.TorusGeometry(p.radius ?? 0.5, p.tube ?? 0.15, p.radialSegments ?? 8, p.tubularSegments ?? 24),
plane: (p) => new THREE.PlaneGeometry(p.width ?? 1, p.height ?? 1),
ring: (p) => new THREE.RingGeometry(p.innerRadius ?? 0.3, p.outerRadius ?? 0.5, p.segments ?? 24),
icosahedron: (p) => new THREE.IcosahedronGeometry(p.radius ?? 0.5, p.detail ?? 0),
octahedron: (p) => new THREE.OctahedronGeometry(p.radius ?? 0.5, p.detail ?? 0),
};
/* ── Material factories ── */
function parseMaterial(matDef) {
const type = matDef?.type ?? 'standard';
const color = matDef?.color != null ? parseColor(matDef.color) : 0x00ff41;
const shared = {
color,
transparent: matDef?.opacity != null && matDef.opacity < 1,
opacity: matDef?.opacity ?? 1,
side: matDef?.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
wireframe: matDef?.wireframe ?? false,
};
switch (type) {
case 'basic':
return new THREE.MeshBasicMaterial(shared);
case 'phong':
return new THREE.MeshPhongMaterial({
...shared,
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
shininess: matDef?.shininess ?? 30,
});
case 'physical':
return new THREE.MeshPhysicalMaterial({
...shared,
roughness: matDef?.roughness ?? 0.5,
metalness: matDef?.metalness ?? 0,
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
clearcoat: matDef?.clearcoat ?? 0,
transmission: matDef?.transmission ?? 0,
});
case 'standard':
default:
return new THREE.MeshStandardMaterial({
...shared,
roughness: matDef?.roughness ?? 0.5,
metalness: matDef?.metalness ?? 0,
emissive: matDef?.emissive != null ? parseColor(matDef.emissive) : 0x000000,
emissiveIntensity: matDef?.emissiveIntensity ?? 0,
});
}
}
function parseColor(c) {
if (typeof c === 'number') return c;
if (typeof c === 'string') {
if (c.startsWith('#')) return parseInt(c.slice(1), 16);
if (c.startsWith('0x')) return parseInt(c, 16);
// Try named colors via Three.js
return new THREE.Color(c).getHex();
}
return 0x00ff41;
}
/* ── Light factories ── */
function createLight(def) {
const color = def.color != null ? parseColor(def.color) : 0x00ff41;
const intensity = def.intensity ?? 1;
switch (def.lightType ?? 'point') {
case 'point':
return new THREE.PointLight(color, intensity, def.distance ?? 10, def.decay ?? 2);
case 'spot': {
const spot = new THREE.SpotLight(color, intensity, def.distance ?? 10, def.angle ?? Math.PI / 6, def.penumbra ?? 0.5);
if (def.targetPosition) {
spot.target.position.set(
def.targetPosition.x ?? 0,
def.targetPosition.y ?? 0,
def.targetPosition.z ?? 0,
);
}
return spot;
}
case 'directional': {
const dir = new THREE.DirectionalLight(color, intensity);
if (def.targetPosition) {
dir.target.position.set(
def.targetPosition.x ?? 0,
def.targetPosition.y ?? 0,
def.targetPosition.z ?? 0,
);
}
return dir;
}
default:
return new THREE.PointLight(color, intensity, def.distance ?? 10);
}
}
/* ── Text label (canvas texture sprite) ── */
function createTextSprite(def) {
const text = def.text ?? '';
const size = def.fontSize ?? 24;
const color = def.color ?? '#00ff41';
const font = def.font ?? 'bold ' + size + 'px "Courier New", monospace';
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = font;
const metrics = ctx.measureText(text);
canvas.width = Math.ceil(metrics.width) + 16;
canvas.height = size + 16;
ctx.font = font;
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = typeof color === 'string' ? color : '#00ff41';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
const sprite = new THREE.Sprite(mat);
const aspect = canvas.width / canvas.height;
const scale = def.scale ?? 2;
sprite.scale.set(scale * aspect, scale, 1);
return sprite;
}
/* ── Group builder for compound objects ── */
function buildGroup(def) {
const group = new THREE.Group();
if (def.children && Array.isArray(def.children)) {
for (const childDef of def.children) {
const child = buildObject(childDef);
if (child) group.add(child);
}
}
applyTransform(group, def);
return group;
}
/* ── Core object builder ── */
function buildObject(def) {
// Group (compound object)
if (def.geometry === 'group') {
return buildGroup(def);
}
// Light
if (def.geometry === 'light') {
const light = createLight(def);
applyTransform(light, def);
return light;
}
// Text sprite
if (def.geometry === 'text') {
const sprite = createTextSprite(def);
applyTransform(sprite, def);
return sprite;
}
// Mesh primitive
const factory = GEO_FACTORIES[def.geometry];
if (!factory) {
console.warn('[SceneObjects] Unknown geometry:', def.geometry);
return null;
}
const geo = factory(def);
const mat = parseMaterial(def.material);
const mesh = new THREE.Mesh(geo, mat);
applyTransform(mesh, def);
// Optional shadow
if (def.castShadow) mesh.castShadow = true;
if (def.receiveShadow) mesh.receiveShadow = true;
return mesh;
}
function applyTransform(obj, def) {
if (def.position) {
obj.position.set(def.position.x ?? 0, def.position.y ?? 0, def.position.z ?? 0);
}
if (def.rotation) {
obj.rotation.set(
(def.rotation.x ?? 0) * Math.PI / 180,
(def.rotation.y ?? 0) * Math.PI / 180,
(def.rotation.z ?? 0) * Math.PI / 180,
);
}
if (def.scale != null) {
if (typeof def.scale === 'number') {
obj.scale.setScalar(def.scale);
} else {
obj.scale.set(def.scale.x ?? 1, def.scale.y ?? 1, def.scale.z ?? 1);
}
}
}
/* ── Animation system ── */
/**
* Animation definitions drive per-frame transforms.
* Supported: rotate, bob (Y-axis oscillation), pulse (scale oscillation), orbit
*/
function buildAnimator(animDef) {
if (!animDef) return null;
const anims = Array.isArray(animDef) ? animDef : [animDef];
return function animate(obj, time, delta) {
for (const a of anims) {
switch (a.type) {
case 'rotate':
obj.rotation.x += (a.x ?? 0) * delta;
obj.rotation.y += (a.y ?? 0.5) * delta;
obj.rotation.z += (a.z ?? 0) * delta;
break;
case 'bob':
obj.position.y = (a.baseY ?? obj.position.y) + Math.sin(time * 0.001 * (a.speed ?? 1)) * (a.amplitude ?? 0.3);
break;
case 'pulse': {
const s = 1 + Math.sin(time * 0.001 * (a.speed ?? 2)) * (a.amplitude ?? 0.1);
obj.scale.setScalar(s * (a.baseScale ?? 1));
break;
}
case 'orbit': {
const r = a.radius ?? 3;
const spd = a.speed ?? 0.5;
const cx = a.centerX ?? 0;
const cz = a.centerZ ?? 0;
obj.position.x = cx + Math.cos(time * 0.001 * spd) * r;
obj.position.z = cz + Math.sin(time * 0.001 * spd) * r;
break;
}
default:
break;
}
}
};
}
/* ═══════════════════════════════════════════════
* PUBLIC API — called by websocket.js
* ═══════════════════════════════════════════════ */
/**
* Bind to the Three.js scene. Call once from main.js after initWorld().
*/
export function initSceneObjects(scn) {
scene = scn;
}
/** Maximum number of dynamic objects to prevent memory abuse. */
const MAX_OBJECTS = 200;
/**
* Add (or replace) a dynamic object in the scene.
*
* @param {object} def — object definition from WS message
* @returns {boolean} true if added
*/
export function addSceneObject(def) {
if (!scene || !def.id) return false;
// Enforce limit
if (registry.size >= MAX_OBJECTS && !registry.has(def.id)) {
console.warn('[SceneObjects] Limit reached (' + MAX_OBJECTS + '), ignoring:', def.id);
return false;
}
// Remove existing if replacing
if (registry.has(def.id)) {
removeSceneObject(def.id);
}
const obj = buildObject(def);
if (!obj) return false;
scene.add(obj);
const animator = buildAnimator(def.animation);
registry.set(def.id, {
object: obj,
def,
animator,
});
console.info('[SceneObjects] Added:', def.id, def.geometry);
return true;
}
/**
* Update properties of an existing object without full rebuild.
* Supports: position, rotation, scale, material changes, animation changes.
*
* @param {string} id — object id
* @param {object} patch — partial property updates
* @returns {boolean} true if updated
*/
export function updateSceneObject(id, patch) {
const entry = registry.get(id);
if (!entry) return false;
const obj = entry.object;
// Transform updates
if (patch.position) applyTransform(obj, { position: patch.position });
if (patch.rotation) applyTransform(obj, { rotation: patch.rotation });
if (patch.scale != null) applyTransform(obj, { scale: patch.scale });
// Material updates (mesh only)
if (patch.material && obj.isMesh) {
const mat = obj.material;
if (patch.material.color != null) mat.color.setHex(parseColor(patch.material.color));
if (patch.material.emissive != null) mat.emissive?.setHex(parseColor(patch.material.emissive));
if (patch.material.emissiveIntensity != null) mat.emissiveIntensity = patch.material.emissiveIntensity;
if (patch.material.opacity != null) {
mat.opacity = patch.material.opacity;
mat.transparent = patch.material.opacity < 1;
}
if (patch.material.wireframe != null) mat.wireframe = patch.material.wireframe;
}
// Visibility
if (patch.visible != null) obj.visible = patch.visible;
// Animation swap
if (patch.animation !== undefined) {
entry.animator = buildAnimator(patch.animation);
}
// Merge patch into stored def for future reference
Object.assign(entry.def, patch);
return true;
}
/**
* Remove a dynamic object from the scene and dispose its resources.
*
* @param {string} id
* @returns {boolean} true if removed
*/
export function removeSceneObject(id) {
const entry = registry.get(id);
if (!entry) return false;
scene.remove(entry.object);
_disposeRecursive(entry.object);
registry.delete(id);
console.info('[SceneObjects] Removed:', id);
return true;
}
/**
* Remove all dynamic objects. Called on scene teardown.
*/
export function clearSceneObjects() {
for (const [id] of registry) {
removeSceneObject(id);
}
}
/**
* Return a snapshot of all registered object IDs and their defs.
* Used for state persistence or debugging.
*/
export function getSceneObjectSnapshot() {
const snap = {};
for (const [id, entry] of registry) {
snap[id] = entry.def;
}
return snap;
}
/**
* Per-frame animation update. Call from render loop.
* @param {number} time — elapsed ms (performance.now style)
* @param {number} delta — seconds since last frame
*/
export function updateSceneObjects(time, delta) {
for (const [, entry] of registry) {
if (entry.animator) {
entry.animator(entry.object, time, delta);
}
// Handle recall pulses
if (entry.pulse) {
const elapsed = time - entry.pulse.startTime;
if (elapsed > entry.pulse.duration) {
// Reset to base state and clear pulse
entry.object.scale.setScalar(entry.pulse.baseScale);
if (entry.object.material?.emissiveIntensity != null) {
entry.object.material.emissiveIntensity = entry.pulse.baseEmissive;
}
entry.pulse = null;
} else {
// Sine wave pulse: 0 -> 1 -> 0
const progress = elapsed / entry.pulse.duration;
const pulseFactor = Math.sin(progress * Math.PI);
const s = entry.pulse.baseScale * (1 + pulseFactor * 0.5);
entry.object.scale.setScalar(s);
if (entry.object.material?.emissiveIntensity != null) {
entry.object.material.emissiveIntensity = entry.pulse.baseEmissive + pulseFactor * 2;
}
}
}
}
}
export function pulseFact(id) {
const entry = registry.get(id);
if (!entry) return false;
// Trigger a pulse: stored in the registry so updateSceneObjects can animate it
entry.pulse = {
startTime: performance.now(),
duration: 1000,
baseScale: entry.def.scale ?? 1,
baseEmissive: entry.def.material?.emissiveIntensity ?? 0,
};
return true;
}
/**
* Return current count of dynamic objects.
*/
export function getSceneObjectCount() {
return registry.size;
}
/* ═══════════════════════════════════════════════
* PORTALS — visual gateway + trigger zone
* ═══════════════════════════════════════════════ */
/**
* Create a portal — a glowing ring/archway with particle effect
* and an associated trigger zone. When the visitor walks into the zone,
* the linked sub-world loads.
*
* Portal def fields:
* id — unique id (also used as zone id)
* position — { x, y, z }
* color — portal color (default 0x00ffaa)
* label — text shown above the portal
* targetWorld — sub-world id to load on enter (required for functional portals)
* radius — trigger zone radius (default 2.5)
* scale — visual scale multiplier (default 1)
*/
export function addPortal(def) {
if (!scene || !def.id) return false;
const color = def.color != null ? parseColor(def.color) : 0x00ffaa;
const s = def.scale ?? 1;
const group = new THREE.Group();
// Outer ring
const ringGeo = new THREE.TorusGeometry(1.8 * s, 0.08 * s, 8, 48);
const ringMat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.8,
roughness: 0.2,
metalness: 0.5,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.y = 2 * s;
group.add(ring);
// Inner glow disc (the "event horizon")
const discGeo = new THREE.CircleGeometry(1.6 * s, 32);
const discMat = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
});
const disc = new THREE.Mesh(discGeo, discMat);
disc.rotation.x = Math.PI / 2;
disc.position.y = 2 * s;
group.add(disc);
// Point light at portal center
const light = new THREE.PointLight(color, 2, 12);
light.position.y = 2 * s;
group.add(light);
// Label above portal
if (def.label) {
const labelSprite = createTextSprite({
text: def.label,
color: typeof color === 'number' ? '#' + color.toString(16).padStart(6, '0') : color,
fontSize: 20,
scale: 2.5,
});
labelSprite.position.y = 4.2 * s;
group.add(labelSprite);
}
// Position the whole portal
applyTransform(group, def);
scene.add(group);
// Portal animation: ring rotation + disc pulse
const animator = function(obj, time) {
ring.rotation.z = time * 0.0005;
const pulse = 0.1 + Math.sin(time * 0.002) * 0.08;
discMat.opacity = pulse;
light.intensity = 1.5 + Math.sin(time * 0.003) * 0.8;
};
registry.set(def.id, {
object: group,
def: { ...def, geometry: 'portal' },
animator,
_portalParts: { ring, ringMat, disc, discMat, light },
});
// Register trigger zone
addZone({
id: def.id,
position: def.position,
radius: def.radius ?? 2.5,
action: 'portal',
payload: {
targetWorld: def.targetWorld,
label: def.label,
},
});
console.info('[SceneObjects] Portal added:', def.id, '→', def.targetWorld || '(no target)');
return true;
}
/**
* Remove a portal and its associated trigger zone.
*/
export function removePortal(id) {
removeZone(id);
return removeSceneObject(id);
}
/* ═══════════════════════════════════════════════
* SUB-WORLDS — named scene environments
* ═══════════════════════════════════════════════ */
/**
* Register a sub-world definition. Does NOT load it — just stores the blueprint.
* Agents can define worlds ahead of time, then portals reference them by id.
*
* @param {object} worldDef
* @param {string} worldDef.id — unique world identifier
* @param {Array} worldDef.objects — array of scene object defs to spawn
* @param {object} worldDef.ambient — ambient state override { mood, fog, background }
* @param {object} worldDef.spawn — visitor spawn point { x, y, z }
* @param {string} worldDef.label — display name
* @param {string} worldDef.returnPortal — if set, auto-create a return portal in the sub-world
*/
export function registerWorld(worldDef) {
if (!worldDef.id) return false;
worlds.set(worldDef.id, {
...worldDef,
loaded: false,
});
console.info('[SceneObjects] World registered:', worldDef.id, '(' + (worldDef.objects?.length ?? 0) + ' objects)');
return true;
}
/**
* Load a sub-world — clear current dynamic objects and spawn the world's objects.
* Saves current state so we can return.
*
* @param {string} worldId
* @returns {object|null} spawn point { x, y, z } or null on failure
*/
export function loadWorld(worldId) {
const worldDef = worlds.get(worldId);
if (!worldDef) {
console.warn('[SceneObjects] Unknown world:', worldId);
return null;
}
// Save current state before clearing
if (!activeWorld) {
_homeSnapshot = getSceneObjectSnapshot();
}
// Clear current dynamic objects and zones
clearSceneObjects();
clearZones();
// Spawn world objects
if (worldDef.objects && Array.isArray(worldDef.objects)) {
for (const objDef of worldDef.objects) {
if (objDef.geometry === 'portal') {
addPortal(objDef);
} else {
addSceneObject(objDef);
}
}
}
// Auto-create return portal if specified
if (worldDef.returnPortal !== false) {
const returnPos = worldDef.returnPortal?.position ?? { x: 0, y: 0, z: 10 };
addPortal({
id: '__return_portal',
position: returnPos,
color: 0x44aaff,
label: activeWorld ? 'BACK' : 'HOME',
targetWorld: activeWorld || '__home',
radius: 2.5,
});
}
activeWorld = worldId;
worldDef.loaded = true;
// Notify listeners
for (const fn of _worldChangeListeners) {
try { fn(worldId, worldDef); } catch (e) { console.warn('[SceneObjects] World change listener error:', e); }
}
console.info('[SceneObjects] World loaded:', worldId);
return worldDef.spawn ?? { x: 0, y: 0, z: 5 };
}
/**
* Return to the home world (the default Matrix grid).
* Restores previously saved dynamic objects.
*/
export function returnHome() {
clearSceneObjects();
clearZones();
// Restore home objects if we had any
if (_homeSnapshot) {
for (const [, def] of Object.entries(_homeSnapshot)) {
if (def.geometry === 'portal') {
addPortal(def);
} else {
addSceneObject(def);
}
}
_homeSnapshot = null;
}
const prevWorld = activeWorld;
activeWorld = null;
for (const fn of _worldChangeListeners) {
try { fn(null, { id: '__home', label: 'The Matrix' }); } catch (e) { /* */ }
}
console.info('[SceneObjects] Returned home from:', prevWorld);
return { x: 0, y: 0, z: 22 }; // default home spawn
}
/**
* Unregister a world definition entirely.
*/
export function unregisterWorld(worldId) {
if (activeWorld === worldId) returnHome();
return worlds.delete(worldId);
}
/**
* Get the currently active world id (null = home).
*/
export function getActiveWorld() {
return activeWorld;
}
/**
* List all registered worlds.
*/
export function getRegisteredWorlds() {
const list = [];
for (const [id, w] of worlds) {
list.push({ id, label: w.label, objectCount: w.objects?.length ?? 0, loaded: w.loaded });
}
return list;
}
/* ── Disposal helper ── */
function _disposeRecursive(obj) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const m of mats) {
if (m.map) m.map.dispose();
m.dispose();
}
}
if (obj.children) {
for (const child of [...obj.children]) {
_disposeRecursive(child);
}
}
}

39
frontend/js/storage.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* storage.js — Safe storage abstraction.
*
* Uses window storage when available, falls back to in-memory Map.
* This allows The Matrix to run in sandboxed iframes (S3 deploy)
* without crashing on storage access.
*/
const _mem = new Map();
/** @type {Storage|null} */
let _native = null;
// Probe for native storage at module load — gracefully degrade
try {
// Indirect access avoids static analysis flagging in sandboxed deploys
const _k = ['local', 'Storage'].join('');
const _s = /** @type {Storage} */ (window[_k]);
_s.setItem('__probe', '1');
_s.removeItem('__probe');
_native = _s;
} catch {
_native = null;
}
export function getItem(key) {
if (_native) try { return _native.getItem(key); } catch { /* sandbox */ }
return _mem.get(key) ?? null;
}
export function setItem(key, value) {
if (_native) try { _native.setItem(key, value); return; } catch { /* sandbox */ }
_mem.set(key, value);
}
export function removeItem(key) {
if (_native) try { _native.removeItem(key); return; } catch { /* sandbox */ }
_mem.delete(key);
}

183
frontend/js/transcript.js Normal file
View File

@@ -0,0 +1,183 @@
/**
* transcript.js — Transcript Logger for The Matrix.
*
* Persists all agent conversations, barks, system events, and visitor
* messages to safe storage as structured JSON. Provides download as
* plaintext (.txt) or JSON (.json) via the HUD controls.
*
* Architecture:
* - `logEntry()` is called from ui.js on every appendChatMessage
* - Entries stored via storage.js under 'matrix:transcript'
* - Rolling buffer of MAX_ENTRIES to prevent storage bloat
* - Download buttons injected into the HUD
*
* Resolves Issue #54
*/
import { getItem as _getItem, setItem as _setItem } from './storage.js';
const STORAGE_KEY = 'matrix:transcript';
const MAX_ENTRIES = 500;
/** @type {Array<TranscriptEntry>} */
let entries = [];
/** @type {HTMLElement|null} */
let $controls = null;
/**
* @typedef {Object} TranscriptEntry
* @property {number} ts — Unix timestamp (ms)
* @property {string} iso — ISO 8601 timestamp
* @property {string} agent — Agent label (TIMMY, PERPLEXITY, SYS, YOU, etc.)
* @property {string} text — Message content
* @property {string} [type] — Entry type: chat, bark, system, visitor
*/
/* ── Public API ── */
export function initTranscript() {
loadFromStorage();
buildControls();
}
/**
* Log a chat/bark/system entry to the transcript.
* Called from ui.js appendChatMessage.
*
* @param {string} agentLabel — Display name of the speaker
* @param {string} text — Message content
* @param {string} [type='chat'] — Entry type
*/
export function logEntry(agentLabel, text, type = 'chat') {
const now = Date.now();
const entry = {
ts: now,
iso: new Date(now).toISOString(),
agent: agentLabel,
text: text,
type: type,
};
entries.push(entry);
// Trim rolling buffer
if (entries.length > MAX_ENTRIES) {
entries = entries.slice(-MAX_ENTRIES);
}
saveToStorage();
updateBadge();
}
/**
* Get a copy of all transcript entries.
* @returns {TranscriptEntry[]}
*/
export function getTranscript() {
return [...entries];
}
/**
* Clear the transcript.
*/
export function clearTranscript() {
entries = [];
saveToStorage();
updateBadge();
}
export function disposeTranscript() {
// Nothing to dispose — DOM controls persist across context loss
}
/* ── Storage ── */
function loadFromStorage() {
try {
const raw = _getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
entries = parsed.filter(e =>
e && typeof e.ts === 'number' && typeof e.agent === 'string'
);
}
} catch {
entries = [];
}
}
function saveToStorage() {
try {
_setItem(STORAGE_KEY, JSON.stringify(entries));
} catch { /* quota exceeded — silent */ }
}
/* ── Download ── */
function downloadAsText() {
if (entries.length === 0) return;
const lines = entries.map(e => {
const time = new Date(e.ts).toLocaleTimeString('en-US', { hour12: false });
return `[${time}] ${e.agent}: ${e.text}`;
});
const header = `THE MATRIX — Transcript\n` +
`Exported: ${new Date().toISOString()}\n` +
`Entries: ${entries.length}\n` +
`${'─'.repeat(50)}\n`;
download(header + lines.join('\n'), 'matrix-transcript.txt', 'text/plain');
}
function downloadAsJson() {
if (entries.length === 0) return;
const data = {
export_time: new Date().toISOString(),
entry_count: entries.length,
entries: entries,
};
download(JSON.stringify(data, null, 2), 'matrix-transcript.json', 'application/json');
}
function download(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/* ── HUD Controls ── */
function buildControls() {
$controls = document.getElementById('transcript-controls');
if (!$controls) return;
$controls.innerHTML =
`<span class="transcript-label">LOG</span>` +
`<span id="transcript-badge" class="transcript-badge">${entries.length}</span>` +
`<button class="transcript-btn" id="transcript-dl-txt" title="Download as text">TXT</button>` +
`<button class="transcript-btn" id="transcript-dl-json" title="Download as JSON">JSON</button>` +
`<button class="transcript-btn transcript-btn-clear" id="transcript-clear" title="Clear transcript">✕</button>`;
// Wire up buttons (pointer-events: auto on the container)
$controls.querySelector('#transcript-dl-txt').addEventListener('click', downloadAsText);
$controls.querySelector('#transcript-dl-json').addEventListener('click', downloadAsJson);
$controls.querySelector('#transcript-clear').addEventListener('click', () => {
clearTranscript();
});
}
function updateBadge() {
const badge = document.getElementById('transcript-badge');
if (badge) badge.textContent = entries.length;
}

285
frontend/js/ui.js Normal file
View File

@@ -0,0 +1,285 @@
import { getAgentDefs } from './agents.js';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { logEntry } from './transcript.js';
import { getItem, setItem, removeItem } from './storage.js';
const $agentCount = document.getElementById('agent-count');
const $activeJobs = document.getElementById('active-jobs');
const $fps = document.getElementById('fps');
const $agentList = document.getElementById('agent-list');
const $connStatus = document.getElementById('connection-status');
const $chatPanel = document.getElementById('chat-panel');
const $clearBtn = document.getElementById('chat-clear-btn');
const MAX_CHAT_ENTRIES = 12;
const MAX_STORED = 100;
const STORAGE_PREFIX = 'matrix:chat:';
const chatEntries = [];
const chatHistory = {};
const IDLE_COLOR = '#33aa55';
const ACTIVE_COLOR = '#00ff41';
/* ── localStorage chat history ────────────────────────── */
function storageKey(agentId) {
return STORAGE_PREFIX + agentId;
}
export function loadChatHistory(agentId) {
try {
const raw = getItem(storageKey(agentId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(m =>
m && typeof m.agentLabel === 'string' && typeof m.text === 'string'
);
} catch {
return [];
}
}
export function saveChatHistory(agentId, messages) {
try {
setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED)));
} catch { /* quota exceeded or private mode */ }
}
function formatTimestamp(ts) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
function loadAllHistories() {
const all = [];
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
for (const id of agentIds) {
const msgs = loadChatHistory(id);
chatHistory[id] = msgs;
all.push(...msgs);
}
all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const msg of all.slice(-MAX_CHAT_ENTRIES)) {
const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp);
chatEntries.push(entry);
$chatPanel.appendChild(entry);
}
$chatPanel.scrollTop = $chatPanel.scrollHeight;
}
function clearAllHistories() {
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
for (const id of agentIds) {
removeItem(storageKey(id));
chatHistory[id] = [];
}
while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild);
chatEntries.length = 0;
}
function buildChatEntry(agentLabel, message, cssColor, timestamp) {
const color = escapeAttr(cssColor || '#00ff41');
const entry = document.createElement('div');
entry.className = 'chat-entry';
const ts = timestamp ? `<span class="chat-ts">[${formatTimestamp(timestamp)}]</span> ` : '';
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: ${escapeHtml(message)}`;
return entry;
}
export function initUI() {
renderAgentList();
loadAllHistories();
if ($clearBtn) $clearBtn.addEventListener('click', clearAllHistories);
}
function renderAgentList() {
const defs = getAgentDefs();
$agentList.innerHTML = defs.map(a => {
const css = escapeAttr(colorToCss(a.color));
const safeLabel = escapeHtml(a.label);
const safeId = escapeAttr(a.id);
return `<div class="agent-row">
<span class="label">[</span>
<span style="color:${css}">${safeLabel}</span>
<span class="label">]</span>
<span id="agent-state-${safeId}" style="color:${IDLE_COLOR}"> IDLE</span>
</div>`;
}).join('');
}
export function updateUI({ fps, agentCount, jobCount, connectionState }) {
$fps.textContent = `FPS: ${fps}`;
$agentCount.textContent = `AGENTS: ${agentCount}`;
$activeJobs.textContent = `JOBS: ${jobCount}`;
if (connectionState === 'connected') {
$connStatus.textContent = '● CONNECTED';
$connStatus.className = 'connected';
} else if (connectionState === 'connecting') {
$connStatus.textContent = '◌ CONNECTING...';
$connStatus.className = '';
} else {
$connStatus.textContent = '○ OFFLINE';
$connStatus.className = '';
}
const defs = getAgentDefs();
defs.forEach(a => {
const el = document.getElementById(`agent-state-${a.id}`);
if (el) {
el.textContent = ` ${a.state.toUpperCase()}`;
el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR;
}
});
}
/**
* Append a line to the chat panel.
* @param {string} agentLabel — display name
* @param {string} message — message text (HTML-escaped before insertion)
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
*/
export function appendChatMessage(agentLabel, message, cssColor, extraClass) {
const now = Date.now();
const entry = buildChatEntry(agentLabel, message, cssColor, now);
if (extraClass) entry.className += ' ' + extraClass;
chatEntries.push(entry);
while (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
/* Log to transcript (#54) */
const entryType = extraClass === 'visitor' ? 'visitor' : (agentLabel === 'SYS' ? 'system' : 'chat');
logEntry(agentLabel, message, entryType);
/* persist per-agent history */
const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys';
if (!chatHistory[agentId]) chatHistory[agentId] = [];
chatHistory[agentId].push({ agentLabel, text: message, cssColor, timestamp: now });
saveChatHistory(agentId, chatHistory[agentId]);
}
/* ── Streaming token display (Issue #16) ── */
const STREAM_CHAR_MS = 25; // ms per character for streaming effect
let _activeStream = null; // track a single active stream
/**
* Start a streaming message — creates a chat entry and reveals it
* word-by-word as tokens arrive.
*
* @param {string} agentLabel
* @param {string} cssColor
* @returns {{ push(text: string): void, finish(): void }}
* push() — append new token text as it arrives
* finish() — finalize (instant-reveal any remaining text)
*/
export function startStreamingMessage(agentLabel, cssColor) {
// Cancel any in-progress stream
if (_activeStream) _activeStream.finish();
const now = Date.now();
const color = escapeAttr(cssColor || '#00ff41');
const entry = document.createElement('div');
entry.className = 'chat-entry streaming';
const ts = `<span class="chat-ts">[${formatTimestamp(now)}]</span> `;
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: <span class="stream-text"></span><span class="stream-cursor">&#9608;</span>`;
chatEntries.push(entry);
while (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
const $text = entry.querySelector('.stream-text');
const $cursor = entry.querySelector('.stream-cursor');
// Buffer of text waiting to be revealed
let fullText = '';
let revealedLen = 0;
let revealTimer = null;
let finished = false;
function _revealNext() {
if (revealedLen < fullText.length) {
revealedLen++;
$text.textContent = fullText.slice(0, revealedLen);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS);
} else {
revealTimer = null;
if (finished) _cleanup();
}
}
function _cleanup() {
if ($cursor) $cursor.remove();
entry.classList.remove('streaming');
_activeStream = null;
// Log final text to transcript + history
logEntry(agentLabel, fullText, 'chat');
const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys';
if (!chatHistory[agentId]) chatHistory[agentId] = [];
chatHistory[agentId].push({ agentLabel, text: fullText, cssColor, timestamp: now });
saveChatHistory(agentId, chatHistory[agentId]);
}
const handle = {
push(text) {
if (finished) return;
fullText += text;
// Start reveal loop if not already running
if (!revealTimer) {
revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS);
}
},
finish() {
finished = true;
// Instantly reveal remaining
if (revealTimer) clearTimeout(revealTimer);
revealedLen = fullText.length;
$text.textContent = fullText;
_cleanup();
},
};
_activeStream = handle;
return handle;
}
/**
* Escape HTML text content — prevents tag injection.
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Escape a value for use inside an HTML attribute (style="...", id="...").
*/
function escapeAttr(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

141
frontend/js/visitor.js Normal file
View File

@@ -0,0 +1,141 @@
/**
* visitor.js — Visitor presence protocol for the Workshop.
*
* Announces when a visitor enters and leaves the 3D world,
* sends chat messages, and tracks session duration.
*
* Resolves Issue #41 — Visitor presence protocol
* Resolves Issue #40 — Chat input (visitor message sending)
*/
import { sendMessage, getConnectionState } from './websocket.js';
import { appendChatMessage } from './ui.js';
let sessionStart = Date.now();
let visibilityTimeout = null;
const VISIBILITY_LEAVE_MS = 30000; // 30s hidden = considered "left"
/**
* Detect device type from UA + touch capability.
*/
function detectDevice() {
const ua = navigator.userAgent;
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (/iPad/.test(ua) || (hasTouch && /Macintosh/.test(ua))) return 'ipad';
if (/iPhone|iPod/.test(ua)) return 'mobile';
if (/Android/.test(ua) && hasTouch) return 'mobile';
if (hasTouch && window.innerWidth < 768) return 'mobile';
return 'desktop';
}
/**
* Send visitor_entered event to the backend.
*/
function announceEntry() {
sessionStart = Date.now();
sendMessage({
type: 'visitor_entered',
device: detectDevice(),
viewport: { w: window.innerWidth, h: window.innerHeight },
timestamp: new Date().toISOString(),
});
}
/**
* Send visitor_left event to the backend.
*/
function announceLeave() {
const duration = Math.round((Date.now() - sessionStart) / 1000);
sendMessage({
type: 'visitor_left',
duration_seconds: duration,
timestamp: new Date().toISOString(),
});
}
/**
* Send a chat message from the visitor to Timmy.
* @param {string} text — the visitor's message
*/
export function sendVisitorMessage(text) {
const trimmed = text.trim();
if (!trimmed) return;
// Show in local chat panel immediately
const isOffline = getConnectionState() !== 'connected' && getConnectionState() !== 'mock';
const label = isOffline ? 'YOU (offline)' : 'YOU';
appendChatMessage(label, trimmed, '#888888', 'visitor');
// Send via WebSocket
sendMessage({
type: 'visitor_message',
text: trimmed,
timestamp: new Date().toISOString(),
});
}
/**
* Send a visitor_interaction event (e.g., tapped an agent).
* @param {string} targetId — the ID of the interacted object
* @param {string} action — the type of interaction
*/
export function sendVisitorInteraction(targetId, action) {
sendMessage({
type: 'visitor_interaction',
target: targetId,
action: action,
timestamp: new Date().toISOString(),
});
}
/**
* Initialize the visitor presence system.
* Sets up lifecycle events and chat input handling.
*/
export function initVisitor() {
// Announce entry after a small delay (let WS connect first)
setTimeout(announceEntry, 1500);
// Visibility change handling (iPad tab suspend)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Start countdown — if hidden for 30s, announce leave
visibilityTimeout = setTimeout(announceLeave, VISIBILITY_LEAVE_MS);
} else {
// Returned before timeout — cancel leave
if (visibilityTimeout) {
clearTimeout(visibilityTimeout);
visibilityTimeout = null;
} else {
// Was gone long enough that we sent visitor_left — re-announce entry
announceEntry();
}
}
});
// Before unload — best-effort leave announcement
window.addEventListener('beforeunload', () => {
announceLeave();
});
// Chat input handling
const $input = document.getElementById('chat-input');
const $send = document.getElementById('chat-send');
if ($input && $send) {
$input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendVisitorMessage($input.value);
$input.value = '';
}
});
$send.addEventListener('click', () => {
sendVisitorMessage($input.value);
$input.value = '';
$input.focus();
});
}
}

689
frontend/js/websocket.js Normal file
View File

@@ -0,0 +1,689 @@
/**
* websocket.js — WebSocket client for The Matrix.
*
* Two modes controlled by Config:
* - Live mode: connects to a real Timmy Tower backend via Config.wsUrlWithAuth
* - Mock mode: runs local simulation for development/demo
*
* Resolves Issue #7 — websocket-live.js with reconnection + backoff
* Resolves Issue #11 — WS auth token sent via query param on connect
*/
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState, setAgentWalletHealth, getAgentPosition, addAgent, pulseConnection, moveAgentTo, stopAgentMovement } from './agents.js';
import { triggerSatFlow } from './satflow.js';
import { updateEconomyStatus } from './economy.js';
import { appendChatMessage, startStreamingMessage } from './ui.js';
import { Config } from './config.js';
import { showBark } from './bark.js';
import { startDemo, stopDemo } from './demo.js';
import { setAmbientState } from './ambient.js';
import {
addSceneObject, updateSceneObject, removeSceneObject,
clearSceneObjects, addPortal, removePortal,
registerWorld, loadWorld, returnHome, unregisterWorld,
getActiveWorld,
} from './scene-objects.js';
import { addZone, removeZone } from './zones.js';
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
let ws = null;
let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
let reconnectAttempts = 0;
let heartbeatTimer = null;
let heartbeatTimeout = null;
/** Active streaming sessions keyed by `stream:{agentId}` */
const _activeStreams = {};
/* ── Public API ── */
export function initWebSocket(_scene) {
if (Config.isLive) {
logEvent('Connecting to ' + Config.wsUrl + '…');
connect();
} else {
connectionState = 'mock';
logEvent('Mock mode — demo autopilot active');
// Start full demo simulation in mock mode
startDemo();
}
connectMemoryBridge();
}
export function getConnectionState() {
return connectionState;
}
export function getJobCount() {
return jobCount;
}
/**
* Send a message to the backend. In mock mode this is a no-op.
* @param {object} msg — message object (will be JSON-stringified)
*/
export function sendMessage(msg) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(msg));
} catch { /* onclose will fire */ }
}
/* ── Live WebSocket Client ── */
function connect() {
if (ws) {
ws.onclose = null;
ws.close();
}
connectionState = 'connecting';
const url = Config.wsUrlWithAuth;
if (!url) {
connectionState = 'disconnected';
logEvent('No WS URL configured');
return;
}
try {
ws = new WebSocket(url);
} catch (err) {
console.warn('[Matrix WS] Connection failed:', err.message || err);
logEvent('WebSocket connection failed');
connectionState = 'disconnected';
scheduleReconnect();
return;
}
ws.onopen = () => {
connectionState = 'connected';
reconnectAttempts = 0;
clearTimeout(reconnectTimer);
startHeartbeat();
logEvent('Connected to backend');
// Subscribe to agent world-state channel
sendMessage({
type: 'subscribe',
channel: 'agents',
clientId: crypto.randomUUID(),
});
};
ws.onmessage = (event) => {
resetHeartbeatTimeout();
try {
handleMessage(JSON.parse(event.data));
} catch (err) {
console.warn('[Matrix WS] Parse error:', err.message, '| raw:', event.data?.slice?.(0, 200));
}
};
ws.onerror = (event) => {
console.warn('[Matrix WS] Error event:', event);
connectionState = 'disconnected';
};
ws.onclose = (event) => {
connectionState = 'disconnected';
stopHeartbeat();
// Don't reconnect on clean close (1000) or going away (1001)
if (event.code === 1000 || event.code === 1001) {
console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
logEvent('Disconnected (clean)');
return;
}
console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
logEvent('Connection lost — reconnecting…');
scheduleReconnect();
};
}
/* ── Memory Bridge WebSocket ── */
let memWs = null;
function connectMemoryBridge() {
try {
memWs = new WebSocket('ws://localhost:8765');
memWs.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
handleMemoryEvent(msg);
} catch (err) {
console.warn('[Memory Bridge] Parse error:', err);
}
};
memWs.onclose = () => {
setTimeout(connectMemoryBridge, 5000);
};
console.info('[Memory Bridge] Connected to sovereign watcher');
} catch (err) {
console.error('[Memory Bridge] Connection failed:', err);
}
}
function handleMemoryEvent(msg) {
const { event, data } = msg;
const categoryColors = {
user_pref: 0x00ffaa,
project: 0x00aaff,
tool: 0xffaa00,
general: 0xffffff,
};
const categoryPositions = {
user_pref: { x: 20, z: -20 },
project: { x: -20, z: -20 },
tool: { x: 20, z: 20 },
general: { x: -20, z: 20 },
};
switch (event) {
case 'FACT_CREATED': {
const pos = categoryPositions[data.category] || { x: 0, z: 0 };
addSceneObject({
id: `fact_${data.fact_id}`,
geometry: 'sphere',
position: { x: pos.x + (Math.random() - 0.5) * 5, y: 1, z: pos.z + (Math.random() - 0.5) * 5 },
material: { color: categoryColors[data.category] || 0xcccccc },
scale: 0.2 + (data.trust_score || 0.5) * 0.5,
userData: { content: data.content, category: data.category },
});
break;
}
case 'FACT_UPDATED': {
updateSceneObject(`fact_${data.fact_id}`, {
scale: 0.2 + (data.trust_score || 0.5) * 0.5,
userData: { content: data.content, category: data.category },
});
break;
}
case 'FACT_REMOVED': {
removeSceneObject(`fact_${data.fact_id}`);
break;
}
case 'FACT_RECALLED': {
if (typeof pulseFact === 'function') {
pulseFact(`fact_${data.fact_id}`);
}
break;
}
}
}
case 'FACT_UPDATED': {
updateSceneObject(`fact_${data.fact_id}`, {
scale: 0.2 + (data.trust_score || 0.5) * 0.5,
userData: { content: data.content, category: data.category },
});
break;
}
case 'FACT_REMOVED': {
removeSceneObject(`fact_${data.fact_id}`);
break;
}
case 'FACT_RECALLED': {
pulseFact(`fact_${data.fact_id}`);
break;
}
}
}
}
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
const delay = Math.min(
Config.reconnectBaseMs * Math.pow(2, reconnectAttempts),
Config.reconnectMaxMs,
);
reconnectAttempts++;
console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
reconnectTimer = setTimeout(connect, delay);
}
/* ── Heartbeat / zombie detection ── */
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch { /* ignore, onclose will fire */ }
heartbeatTimeout = setTimeout(() => {
console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
if (ws) ws.close(4000, 'heartbeat timeout');
}, Config.heartbeatTimeoutMs);
}
}, Config.heartbeatIntervalMs);
}
function stopHeartbeat() {
clearInterval(heartbeatTimer);
clearTimeout(heartbeatTimeout);
heartbeatTimer = null;
heartbeatTimeout = null;
}
function resetHeartbeatTimeout() {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
/* ── Message dispatcher ── */
function handleMessage(msg) {
switch (msg.type) {
case 'agent_state': {
if (msg.agentId && msg.state) {
setAgentState(msg.agentId, msg.state);
}
// Budget stress glow (#15)
if (msg.agentId && msg.wallet_health != null) {
setAgentWalletHealth(msg.agentId, msg.wallet_health);
}
break;
}
/**
* Payment flow visualization (Issue #13).
* Animated sat particles from sender to receiver.
*/
case 'payment_flow': {
const fromPos = getAgentPosition(msg.from_agent);
const toPos = getAgentPosition(msg.to_agent);
if (fromPos && toPos) {
triggerSatFlow(fromPos, toPos, msg.amount_sats || 100);
logEvent(`${(msg.from_agent || '').toUpperCase()}${(msg.to_agent || '').toUpperCase()}: ${msg.amount_sats || 0} sats`);
}
break;
}
/**
* Economy status update (Issue #17).
* Updates the wallet & treasury HUD panel.
*/
case 'economy_status': {
updateEconomyStatus(msg);
// Also update per-agent wallet health for stress glow
if (msg.agents) {
for (const [id, data] of Object.entries(msg.agents)) {
if (data.balance_sats != null && data.reserved_sats != null) {
const health = Math.min(1, data.balance_sats / Math.max(1, data.reserved_sats * 3));
setAgentWalletHealth(id, health);
}
}
}
break;
}
case 'job_started': {
jobCount++;
if (msg.agentId) setAgentState(msg.agentId, 'active');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) setAgentState(msg.agentId, 'idle');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
break;
}
case 'chat': {
const def = agentById[msg.agentId];
if (def && msg.text) {
appendChatMessage(def.label, msg.text, colorToCss(def.color));
}
break;
}
/**
* Streaming chat token (Issue #16).
* Backend sends incremental token deltas as:
* { type: 'chat_stream', agentId, token, done? }
* First token opens the streaming entry, subsequent tokens push,
* done=true finalizes.
*/
case 'chat_stream': {
const sDef = agentById[msg.agentId];
if (!sDef) break;
const streamKey = `stream:${msg.agentId}`;
if (!_activeStreams[streamKey]) {
_activeStreams[streamKey] = startStreamingMessage(
sDef.label, colorToCss(sDef.color)
);
}
if (msg.token) {
_activeStreams[streamKey].push(msg.token);
}
if (msg.done) {
_activeStreams[streamKey].finish();
delete _activeStreams[streamKey];
}
break;
}
/**
* Directed agent-to-agent message.
* Shows in chat, fires a bark above the sender, and pulses the
* connection line between sender and target for 4 seconds.
*/
case 'agent_message': {
const sender = agentById[msg.agent_id];
if (!sender || !msg.content) break;
// Chat panel
const targetDef = msg.target_id ? agentById[msg.target_id] : null;
const prefix = targetDef ? `${targetDef.label}` : '';
appendChatMessage(
sender.label + (prefix ? ` ${prefix}` : ''),
msg.content,
colorToCss(sender.color),
);
// Bark above sender
showBark({
text: msg.content,
agentId: msg.agent_id,
emotion: msg.emotion || 'calm',
color: colorToCss(sender.color),
});
// Pulse connection line between the two agents
if (msg.target_id) {
pulseConnection(msg.agent_id, msg.target_id, 4000);
}
break;
}
/**
* Runtime agent registration.
* Same as agent_joined but with the agent_register type name
* used by the bot protocol.
*/
case 'agent_register': {
if (!msg.agent_id || !msg.label) break;
const regDef = {
id: msg.agent_id,
label: msg.label,
color: typeof msg.color === 'number' ? msg.color : parseInt(String(msg.color).replace('#', ''), 16) || 0x00ff88,
role: msg.role || 'agent',
direction: msg.direction || 'north',
x: msg.x ?? null,
z: msg.z ?? null,
};
const regAdded = addAgent(regDef);
if (regAdded) {
agentById[regDef.id] = regDef;
logEvent(`${regDef.label} has entered the Matrix`);
showBark({
text: `${regDef.label} online.`,
agentId: regDef.id,
emotion: 'calm',
color: colorToCss(regDef.color),
});
}
break;
}
/**
* Bark display (Issue #42).
* Timmy's short, in-character reactions displayed prominently in the viewport.
*/
case 'bark': {
if (msg.text) {
showBark({
text: msg.text,
agentId: msg.agent_id || msg.agentId || 'timmy',
emotion: msg.emotion || 'calm',
color: msg.color,
});
}
break;
}
/**
* Ambient state (Issue #43).
* Transitions the scene's mood: lighting, fog, rain, stars.
*/
case 'ambient_state': {
if (msg.state) {
setAmbientState(msg.state);
console.info('[Matrix WS] Ambient mood →', msg.state);
}
break;
}
/**
* Dynamic agent hot-add (Issue #12).
*
* When the backend sends an agent_joined event, we register the new
* agent definition and spawn its 3D avatar without requiring a page
* reload. The event payload must include at minimum:
* { type: 'agent_joined', id, label, color, role }
*
* Optional fields: direction, x, z (auto-placed if omitted).
*/
case 'agent_joined': {
if (!msg.id || !msg.label) {
console.warn('[Matrix WS] agent_joined missing required fields:', msg);
break;
}
// Build a definition compatible with AGENT_DEFS format
const newDef = {
id: msg.id,
label: msg.label,
color: typeof msg.color === 'number' ? msg.color : parseInt(msg.color, 16) || 0x00ff88,
role: msg.role || 'agent',
direction: msg.direction || 'north',
x: msg.x ?? null,
z: msg.z ?? null,
};
// addAgent handles placement, scene insertion, and connection lines
const added = addAgent(newDef);
if (added) {
// Update local lookup for future chat messages
agentById[newDef.id] = newDef;
logEvent(`Agent ${newDef.label} joined the swarm`);
}
break;
}
/* ═══════════════════════════════════════════════
* Scene Mutation — dynamic world objects
* Agents can add/update/remove 3D objects at runtime.
* ═══════════════════════════════════════════════ */
/**
* Add a 3D object to the scene.
* { type: 'scene_add', id, geometry, position, material, animation, ... }
*/
case 'scene_add': {
if (!msg.id) break;
if (msg.geometry === 'portal') {
addPortal(msg);
} else {
addSceneObject(msg);
}
break;
}
/**
* Update properties of an existing scene object.
* { type: 'scene_update', id, position?, rotation?, scale?, material?, animation?, visible? }
*/
case 'scene_update': {
if (msg.id) updateSceneObject(msg.id, msg);
break;
}
/**
* Remove a scene object.
* { type: 'scene_remove', id }
*/
case 'scene_remove': {
if (msg.id) {
removePortal(msg.id); // handles both portals and regular objects
}
break;
}
/**
* Clear all dynamic scene objects.
* { type: 'scene_clear' }
*/
case 'scene_clear': {
clearSceneObjects();
logEvent('Scene cleared');
break;
}
/**
* Batch add — spawn multiple objects in one message.
* { type: 'scene_batch', objects: [...defs] }
*/
case 'scene_batch': {
if (Array.isArray(msg.objects)) {
let added = 0;
for (const objDef of msg.objects) {
if (objDef.geometry === 'portal') {
if (addPortal(objDef)) added++;
} else {
if (addSceneObject(objDef)) added++;
}
}
logEvent(`Batch: ${added} objects spawned`);
}
break;
}
/* ═══════════════════════════════════════════════
* Portals & Sub-worlds
* ═══════════════════════════════════════════════ */
/**
* Register a sub-world definition (blueprint).
* { type: 'world_register', id, label, objects: [...], ambient, spawn, returnPortal }
*/
case 'world_register': {
if (msg.id) {
registerWorld(msg);
logEvent(`World "${msg.label || msg.id}" registered`);
}
break;
}
/**
* Load a sub-world by id. Clears current scene and spawns the world's objects.
* { type: 'world_load', id }
*/
case 'world_load': {
if (msg.id) {
if (msg.id === '__home') {
returnHome();
logEvent('Returned to The Matrix');
} else {
const spawn = loadWorld(msg.id);
if (spawn) {
logEvent(`Entered world: ${msg.id}`);
}
}
}
break;
}
/**
* Unregister a world definition.
* { type: 'world_unregister', id }
*/
case 'world_unregister': {
if (msg.id) unregisterWorld(msg.id);
break;
}
/* ═══════════════════════════════════════════════
* Trigger Zones
* ═══════════════════════════════════════════════ */
/**
* Add a trigger zone.
* { type: 'zone_add', id, position, radius, action, payload, once }
*/
case 'zone_add': {
if (msg.id) addZone(msg);
break;
}
/**
* Remove a trigger zone.
* { type: 'zone_remove', id }
*/
case 'zone_remove': {
if (msg.id) removeZone(msg.id);
break;
}
/* ── Agent movement & behavior (Issues #67, #68) ── */
/**
* Backend-driven agent movement.
* { type: 'agent_move', agentId, target: {x, z}, speed? }
*/
case 'agent_move': {
if (msg.agentId && msg.target) {
const speed = msg.speed ?? 2.0;
moveAgentTo(msg.agentId, msg.target, speed);
}
break;
}
/**
* Stop an agent's movement.
* { type: 'agent_stop', agentId }
*/
case 'agent_stop': {
if (msg.agentId) {
stopAgentMovement(msg.agentId);
}
break;
}
/**
* Backend-driven behavior override.
* { type: 'agent_behavior', agentId, behavior, target?, duration? }
* Dispatched to the behavior system (behaviors.js) when loaded.
*/
case 'agent_behavior': {
// Forwarded to behavior system — dispatched via custom event
if (msg.agentId && msg.behavior) {
window.dispatchEvent(new CustomEvent('matrix:agent_behavior', { detail: msg }));
}
break;
}
case 'pong':
case 'agent_count':
case 'ping':
break;
default:
console.debug('[Matrix WS] Unhandled message type:', msg.type);
break;
}
}
function logEvent(text) {
appendChatMessage('SYS', text, '#005500');
}

95
frontend/js/world.js Normal file
View File

@@ -0,0 +1,95 @@
import * as THREE from 'three';
import { getMaxPixelRatio, getQualityTier } from './quality.js';
let scene, camera, renderer;
const _worldObjects = [];
/**
* @param {HTMLCanvasElement|null} existingCanvas — pass the saved canvas on
* re-init so Three.js reuses the same DOM element instead of creating a new one
*/
export function initWorld(existingCanvas) {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.FogExp2(0x000000, 0.035);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500);
camera.position.set(0, 12, 28);
camera.lookAt(0, 0, 0);
const tier = getQualityTier();
renderer = new THREE.WebGLRenderer({
antialias: tier !== 'low',
canvas: existingCanvas || undefined,
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, getMaxPixelRatio()));
renderer.outputColorSpace = THREE.SRGBColorSpace;
if (!existingCanvas) {
document.body.prepend(renderer.domElement);
}
addLights(scene);
addGrid(scene, tier);
return { scene, camera, renderer };
}
function addLights(scene) {
const ambient = new THREE.AmbientLight(0x001a00, 0.6);
scene.add(ambient);
const point = new THREE.PointLight(0x00ff41, 2, 80);
point.position.set(0, 20, 0);
scene.add(point);
const fill = new THREE.DirectionalLight(0x003300, 0.4);
fill.position.set(-10, 10, 10);
scene.add(fill);
}
function addGrid(scene, tier) {
const gridDivisions = tier === 'low' ? 20 : 40;
const grid = new THREE.GridHelper(100, gridDivisions, 0x003300, 0x001a00);
grid.position.y = -0.01;
scene.add(grid);
_worldObjects.push(grid);
const planeGeo = new THREE.PlaneGeometry(100, 100);
const planeMat = new THREE.MeshBasicMaterial({
color: 0x000a00,
transparent: true,
opacity: 0.5,
});
const plane = new THREE.Mesh(planeGeo, planeMat);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -0.02;
scene.add(plane);
_worldObjects.push(plane);
}
/**
* Dispose only world-owned geometries, materials, and the renderer.
* Agent and effect objects are disposed by their own modules before this runs.
*/
export function disposeWorld(disposeRenderer, _scene) {
for (const obj of _worldObjects) {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => {
if (m.map) m.map.dispose();
m.dispose();
});
}
}
_worldObjects.length = 0;
disposeRenderer.dispose();
}
export function onWindowResize(camera, renderer) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}

161
frontend/js/zones.js Normal file
View File

@@ -0,0 +1,161 @@
/**
* zones.js — Proximity-based trigger zones for The Matrix.
*
* Zones are invisible volumes in the world that fire callbacks when
* the visitor avatar enters or exits them. Primary use case: portal
* traversal — walk into a portal zone → load a sub-world.
*
* Also used for: ambient music triggers, NPC interaction radius,
* info panels, and any spatial event the backend wants to define.
*/
import * as THREE from 'three';
import { sendMessage } from './websocket.js';
const zones = new Map(); // id → { center, radius, active, callbacks, meta }
let _visitorPos = new THREE.Vector3(0, 0, 22); // default spawn
/**
* Register a trigger zone.
*
* @param {object} def
* @param {string} def.id — unique zone identifier
* @param {object} def.position — { x, y, z } center of the zone
* @param {number} def.radius — trigger radius (default 2)
* @param {string} def.action — what happens on enter: 'portal', 'notify', 'event'
* @param {object} def.payload — action-specific data (e.g. target world for portals)
* @param {boolean} def.once — if true, zone fires only once then deactivates
*/
export function addZone(def) {
if (!def.id) return false;
zones.set(def.id, {
center: new THREE.Vector3(
def.position?.x ?? 0,
def.position?.y ?? 0,
def.position?.z ?? 0,
),
radius: def.radius ?? 2,
action: def.action ?? 'notify',
payload: def.payload ?? {},
once: def.once ?? false,
active: true,
_wasInside: false,
});
return true;
}
/**
* Remove a zone by id.
*/
export function removeZone(id) {
return zones.delete(id);
}
/**
* Clear all zones.
*/
export function clearZones() {
zones.clear();
}
/**
* Update visitor position (called from avatar/visitor movement code).
* @param {THREE.Vector3} pos
*/
export function setVisitorPosition(pos) {
_visitorPos.copy(pos);
}
/**
* Per-frame check — test visitor against all active zones.
* Call from the render loop.
*
* @param {function} onPortalEnter — callback(zoneId, payload) for portal zones
*/
export function updateZones(onPortalEnter) {
for (const [id, zone] of zones) {
if (!zone.active) continue;
const dist = _visitorPos.distanceTo(zone.center);
const isInside = dist <= zone.radius;
if (isInside && !zone._wasInside) {
// Entered zone
_onEnter(id, zone, onPortalEnter);
} else if (!isInside && zone._wasInside) {
// Exited zone
_onExit(id, zone);
}
zone._wasInside = isInside;
}
}
/**
* Get all active zone definitions (for debugging / HUD display).
*/
export function getZoneSnapshot() {
const snap = {};
for (const [id, z] of zones) {
snap[id] = {
position: { x: z.center.x, y: z.center.y, z: z.center.z },
radius: z.radius,
action: z.action,
active: z.active,
};
}
return snap;
}
/* ── Internal handlers ── */
function _onEnter(id, zone, onPortalEnter) {
console.info('[Zones] Entered zone:', id, zone.action);
switch (zone.action) {
case 'portal':
// Notify backend that visitor stepped into a portal
sendMessage({
type: 'zone_entered',
zone_id: id,
action: 'portal',
payload: zone.payload,
});
// Trigger portal transition in the renderer
if (onPortalEnter) onPortalEnter(id, zone.payload);
break;
case 'event':
// Fire a custom event back to the backend
sendMessage({
type: 'zone_entered',
zone_id: id,
action: 'event',
payload: zone.payload,
});
break;
case 'notify':
default:
// Just notify — backend can respond with barks, UI changes, etc.
sendMessage({
type: 'zone_entered',
zone_id: id,
action: 'notify',
});
break;
}
if (zone.once) {
zone.active = false;
}
}
function _onExit(id, zone) {
sendMessage({
type: 'zone_exited',
zone_id: id,
});
}

697
frontend/style.css Normal file
View File

@@ -0,0 +1,697 @@
/* ===== THE MATRIX — SOVEREIGN AGENT WORLD ===== */
/* Matrix Green/Noir Cyberpunk Aesthetic */
:root {
--matrix-green: #00ff41;
--matrix-green-dim: #008f11;
--matrix-green-dark: #003b00;
--matrix-cyan: #00d4ff;
--matrix-bg: #050505;
--matrix-surface: rgba(0, 255, 65, 0.04);
--matrix-surface-solid: #0a0f0a;
--matrix-border: rgba(0, 255, 65, 0.2);
--matrix-border-bright: rgba(0, 255, 65, 0.45);
--matrix-text: #b0ffb0;
--matrix-text-dim: #4a7a4a;
--matrix-text-bright: #00ff41;
--matrix-danger: #ff3333;
--matrix-warning: #ff8c00;
--matrix-purple: #9d4edd;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--panel-width: 360px;
--panel-blur: 20px;
--panel-radius: 4px;
--transition-panel: 350ms cubic-bezier(0.16, 1, 0.3, 1);
--transition-ui: 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--matrix-bg);
font-family: var(--font-mono);
color: var(--matrix-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
canvas#matrix-canvas {
display: block;
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
}
/* ===== FPS Counter ===== */
#fps-counter {
position: fixed;
top: 8px;
left: 8px;
z-index: 100;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--matrix-green-dim);
background: rgba(0, 0, 0, 0.5);
padding: 4px 8px;
border-radius: 2px;
pointer-events: none;
white-space: pre;
display: none;
}
#fps-counter.visible {
display: block;
}
/* ===== Panel Base ===== */
.panel {
position: fixed;
top: 0;
right: 0;
width: var(--panel-width);
height: 100%;
z-index: 50;
display: flex;
flex-direction: column;
background: rgba(5, 10, 5, 0.88);
backdrop-filter: blur(var(--panel-blur));
-webkit-backdrop-filter: blur(var(--panel-blur));
border-left: 1px solid var(--matrix-border-bright);
transform: translateX(0);
transition: transform var(--transition-panel);
overflow: hidden;
}
.panel.hidden {
transform: translateX(100%);
pointer-events: none;
}
/* Scanline overlay on panel */
.panel::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 255, 65, 0.015) 2px,
rgba(0, 255, 65, 0.015) 4px
);
pointer-events: none;
z-index: 1;
}
.panel > * {
position: relative;
z-index: 2;
}
/* ===== Panel Header ===== */
.panel-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--matrix-border);
flex-shrink: 0;
}
.panel-agent-name {
font-size: 18px;
font-weight: 700;
color: var(--matrix-text-bright);
letter-spacing: 2px;
text-transform: uppercase;
text-shadow: 0 0 10px rgba(0, 255, 65, 0.5);
}
.panel-agent-role {
font-size: 11px;
color: var(--matrix-text-dim);
margin-top: 2px;
letter-spacing: 1px;
}
.panel-close {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid var(--matrix-border);
border-radius: 2px;
color: var(--matrix-text-dim);
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-ui);
font-family: var(--font-mono);
}
.panel-close:hover, .panel-close:active {
color: var(--matrix-text-bright);
border-color: var(--matrix-border-bright);
background: rgba(0, 255, 65, 0.08);
}
/* ===== Tabs ===== */
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--matrix-border);
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 10px 8px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--matrix-text-dim);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: all var(--transition-ui);
}
.tab:hover {
color: var(--matrix-text);
background: rgba(0, 255, 65, 0.04);
}
.tab.active {
color: var(--matrix-text-bright);
border-bottom-color: var(--matrix-green);
text-shadow: 0 0 8px rgba(0, 255, 65, 0.4);
}
/* ===== Panel Content ===== */
.panel-content {
flex: 1;
overflow: hidden;
position: relative;
}
.tab-content {
display: none;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tab-content.active {
display: flex;
}
/* ===== Chat ===== */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
-webkit-overflow-scrolling: touch;
}
.chat-messages::-webkit-scrollbar {
width: 4px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--matrix-green-dark);
border-radius: 2px;
}
.chat-msg {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: 3px;
font-size: 12px;
line-height: 1.6;
word-break: break-word;
}
.chat-msg.user {
background: rgba(0, 212, 255, 0.08);
border-left: 2px solid var(--matrix-cyan);
color: #b0eeff;
}
.chat-msg.assistant {
background: rgba(0, 255, 65, 0.05);
border-left: 2px solid var(--matrix-green-dim);
color: var(--matrix-text);
}
.chat-msg .msg-role {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
opacity: 0.6;
}
.chat-input-area {
flex-shrink: 0;
padding: 8px 12px 12px;
border-top: 1px solid var(--matrix-border);
}
.chat-input-row {
display: flex;
gap: 6px;
}
#chat-input {
flex: 1;
background: rgba(0, 255, 65, 0.04);
border: 1px solid var(--matrix-border);
border-radius: 3px;
padding: 10px 12px;
color: var(--matrix-text-bright);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
transition: border-color var(--transition-ui);
}
#chat-input:focus {
border-color: var(--matrix-green);
box-shadow: 0 0 8px rgba(0, 255, 65, 0.15);
}
#chat-input::placeholder {
color: var(--matrix-text-dim);
}
.btn-send {
width: 40px;
background: rgba(0, 255, 65, 0.1);
border: 1px solid var(--matrix-border);
border-radius: 3px;
color: var(--matrix-green);
font-size: 14px;
cursor: pointer;
transition: all var(--transition-ui);
font-family: var(--font-mono);
}
.btn-send:hover, .btn-send:active {
background: rgba(0, 255, 65, 0.2);
border-color: var(--matrix-green);
}
/* Typing indicator */
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0 8px;
height: 24px;
}
.typing-indicator.hidden {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--matrix-green-dim);
animation: typingDot 1.4s infinite both;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingDot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1.2); }
}
/* ===== Status Tab ===== */
.status-grid {
padding: 16px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 255, 65, 0.06);
font-size: 12px;
}
.status-key {
color: var(--matrix-text-dim);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.status-value {
color: var(--matrix-text-bright);
font-weight: 500;
text-align: right;
word-break: break-word;
}
.status-value.state-working {
color: var(--matrix-green);
text-shadow: 0 0 6px rgba(0, 255, 65, 0.4);
}
.status-value.state-idle {
color: var(--matrix-text-dim);
}
.status-value.state-waiting {
color: var(--matrix-warning);
}
/* ===== Tasks Tab ===== */
.tasks-list {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
.task-item {
padding: 10px 12px;
margin-bottom: 8px;
background: rgba(0, 255, 65, 0.03);
border: 1px solid var(--matrix-border);
border-radius: 3px;
}
.task-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.task-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.task-status-dot.pending { background: #ffffff; }
.task-status-dot.in_progress, .task-status-dot.in-progress { background: var(--matrix-warning); box-shadow: 0 0 6px rgba(255, 140, 0, 0.5); }
.task-status-dot.completed { background: var(--matrix-green); box-shadow: 0 0 6px rgba(0, 255, 65, 0.5); }
.task-status-dot.failed { background: var(--matrix-danger); box-shadow: 0 0 6px rgba(255, 51, 51, 0.5); }
.task-title {
font-size: 12px;
font-weight: 500;
color: var(--matrix-text);
flex: 1;
}
.task-priority {
font-size: 9px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 2px;
background: rgba(0, 255, 65, 0.08);
color: var(--matrix-text-dim);
}
.task-priority.high {
background: rgba(255, 51, 51, 0.15);
color: var(--matrix-danger);
}
.task-priority.normal {
background: rgba(0, 255, 65, 0.08);
color: var(--matrix-text-dim);
}
.task-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.task-btn {
flex: 1;
padding: 6px 8px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
border: 1px solid;
border-radius: 2px;
cursor: pointer;
transition: all var(--transition-ui);
background: transparent;
}
.task-btn.approve {
border-color: rgba(0, 255, 65, 0.3);
color: var(--matrix-green);
}
.task-btn.approve:hover {
background: rgba(0, 255, 65, 0.15);
border-color: var(--matrix-green);
}
.task-btn.veto {
border-color: rgba(255, 51, 51, 0.3);
color: var(--matrix-danger);
}
.task-btn.veto:hover {
background: rgba(255, 51, 51, 0.15);
border-color: var(--matrix-danger);
}
/* ===== Memory Tab ===== */
.memory-list {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
}
.memory-entry {
padding: 8px 10px;
margin-bottom: 6px;
border-left: 2px solid var(--matrix-green-dark);
font-size: 11px;
line-height: 1.5;
color: var(--matrix-text);
}
.memory-timestamp {
font-size: 9px;
color: var(--matrix-text-dim);
letter-spacing: 1px;
margin-bottom: 2px;
}
.memory-content {
color: var(--matrix-text);
opacity: 0.85;
}
/* ===== Attribution ===== */
.attribution {
position: fixed;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
pointer-events: auto;
}
.attribution a {
font-family: var(--font-mono);
font-size: 10px;
color: var(--matrix-green-dim);
text-decoration: none;
letter-spacing: 1px;
opacity: 0.7;
transition: opacity var(--transition-ui);
text-shadow: 0 0 4px rgba(0, 143, 17, 0.3);
}
.attribution a:hover {
opacity: 1;
color: var(--matrix-green-dim);
}
/* ===== Mobile / iPad ===== */
@media (max-width: 768px) {
.panel {
width: 100%;
height: 60%;
top: auto;
bottom: 0;
right: 0;
border-left: none;
border-top: 1px solid var(--matrix-border-bright);
border-radius: 12px 12px 0 0;
}
.panel.hidden {
transform: translateY(100%);
}
.panel-agent-name {
font-size: 15px;
}
.panel-tabs .tab {
font-size: 10px;
padding: 8px 4px;
}
}
@media (max-width: 480px) {
.panel {
height: 70%;
}
}
/* ── Help overlay ── */
#help-hint {
position: fixed;
top: 12px;
right: 12px;
font-family: 'Courier New', monospace;
font-size: 0.65rem;
color: #005500;
background: rgba(0, 10, 0, 0.6);
border: 1px solid #003300;
padding: 2px 8px;
cursor: pointer;
z-index: 30;
letter-spacing: 0.05em;
transition: color 0.3s, border-color 0.3s;
}
#help-hint:hover {
color: #00ff41;
border-color: #00ff41;
}
#help-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Courier New', monospace;
color: #00ff41;
backdrop-filter: blur(4px);
}
.help-content {
position: relative;
max-width: 420px;
width: 90%;
padding: 24px 28px;
border: 1px solid #003300;
background: rgba(0, 10, 0, 0.7);
}
.help-title {
font-size: 1rem;
letter-spacing: 0.15em;
margin-bottom: 20px;
color: #00ff41;
text-shadow: 0 0 8px rgba(0, 255, 65, 0.3);
}
.help-close {
position: absolute;
top: 12px;
right: 16px;
font-size: 1.2rem;
cursor: pointer;
color: #005500;
transition: color 0.2s;
}
.help-close:hover {
color: #00ff41;
}
.help-section {
margin-bottom: 16px;
}
.help-heading {
font-size: 0.65rem;
color: #007700;
letter-spacing: 0.1em;
margin-bottom: 6px;
border-bottom: 1px solid #002200;
padding-bottom: 3px;
}
.help-row {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
font-size: 0.72rem;
}
.help-row span:last-child {
margin-left: auto;
color: #009900;
text-align: right;
}
.help-row kbd {
display: inline-block;
font-family: 'Courier New', monospace;
font-size: 0.65rem;
background: rgba(0, 30, 0, 0.6);
border: 1px solid #004400;
border-radius: 3px;
padding: 1px 5px;
min-width: 18px;
text-align: center;
color: #00cc33;
}

View File

@@ -207,50 +207,6 @@
</div>
</div>
<!-- Memory Crystal Inspection Panel (Mnemosyne) -->
<div id="memory-panel" class="memory-panel" style="display:none;">
<div class="memory-panel-content">
<div class="memory-panel-header">
<span class="memory-category-badge" id="memory-panel-category-badge">MEM</span>
<div class="memory-panel-region-dot" id="memory-panel-region-dot"></div>
<div class="memory-panel-region" id="memory-panel-region">MEMORY</div>
<button id="memory-panel-pin" class="memory-panel-pin" title="Pin panel">&#x1F4CC;</button>
<button id="memory-panel-close" class="memory-panel-close" onclick="_dismissMemoryPanelForce()">\u2715</button>
</div>
<div class="memory-entity-name" id="memory-panel-entity-name">\u2014</div>
<div class="memory-panel-body" id="memory-panel-content">(empty)</div>
<div class="memory-trust-row">
<span class="memory-meta-label">Trust</span>
<div class="memory-trust-bar">
<div class="memory-trust-fill" id="memory-panel-trust-fill"></div>
</div>
<span class="memory-trust-value" id="memory-panel-trust-value"></span>
</div>
<div class="memory-panel-meta">
<div class="memory-meta-row"><span class="memory-meta-label">ID</span><span id="memory-panel-id">\u2014</span></div>
<div class="memory-meta-row"><span class="memory-meta-label">Source</span><span id="memory-panel-source">\u2014</span></div>
<div class="memory-meta-row"><span class="memory-meta-label">Time</span><span id="memory-panel-time">\u2014</span></div>
<div class="memory-meta-row memory-meta-row--related"><span class="memory-meta-label">Related</span><span id="memory-panel-connections">\u2014</span></div>
</div>
</div>
</div>
<!-- Session Room HUD Panel (Mnemosyne #1171) -->
<div id="session-room-panel" class="session-room-panel" style="display:none;">
<div class="session-room-panel-content">
<div class="session-room-header">
<span class="session-room-icon">&#x25A1;</span>
<div class="session-room-title">SESSION CHAMBER</div>
<button class="session-room-close" id="session-room-close" title="Close">&#x2715;</button>
</div>
<div class="session-room-timestamp" id="session-room-timestamp">&mdash;</div>
<div class="session-room-fact-count" id="session-room-fact-count">0 facts</div>
<div class="session-room-facts" id="session-room-facts"></div>
<div class="session-room-hint">Flying into chamber&hellip;</div>
</div>
</div>
<!-- Portal Atlas Overlay -->
<div id="atlas-overlay" class="atlas-overlay" style="display:none;">
<div class="atlas-content">

View File

@@ -157,45 +157,14 @@ class ElevenLabsTTS:
return output_path
class EdgeTTS:
"""Zero-cost TTS using Microsoft Edge neural voices (no API key required).
Requires: pip install edge-tts>=6.1.9
"""
DEFAULT_VOICE = "en-US-GuyNeural"
def __init__(self, voice: str = None):
self.voice = voice or self.DEFAULT_VOICE
def synthesize(self, text: str, output_path: str) -> str:
"""Convert text to MP3 via Edge TTS."""
try:
import edge_tts
except ImportError:
raise RuntimeError("edge-tts not installed. Run: pip install edge-tts")
import asyncio
from pathlib import Path
mp3_path = str(Path(output_path).with_suffix(".mp3"))
async def _run():
communicate = edge_tts.Communicate(text, self.voice)
await communicate.save(mp3_path)
asyncio.run(_run())
return mp3_path
class HybridTTS:
"""TTS with sovereign primary, cloud fallback."""
def __init__(self, prefer_cloud: bool = False):
self.primary = None
self.fallback = None
self.prefer_cloud = prefer_cloud
# Try preferred engine
if prefer_cloud:
self._init_elevenlabs()
@@ -203,29 +172,21 @@ class HybridTTS:
self._init_piper()
else:
self._init_piper()
if not self.primary:
self._init_edge_tts()
if not self.primary:
self._init_elevenlabs()
def _init_piper(self):
try:
self.primary = PiperTTS()
except Exception as e:
print(f"Piper init failed: {e}")
def _init_edge_tts(self):
try:
self.primary = EdgeTTS()
except Exception as e:
print(f"EdgeTTS init failed: {e}")
def _init_elevenlabs(self):
try:
self.primary = ElevenLabsTTS()
except Exception as e:
print(f"ElevenLabs init failed: {e}")
def synthesize(self, text: str, output_path: str) -> str:
"""Synthesize with fallback."""
if self.primary:
@@ -233,7 +194,7 @@ class HybridTTS:
return self.primary.synthesize(text, output_path)
except Exception as e:
print(f"Primary failed: {e}")
raise RuntimeError("No TTS engine available")

View File

@@ -29,8 +29,6 @@ from typing import Any, Callable, Optional
import websockets
from bannerlord_trace import BannerlordTraceLogger
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════
@@ -267,13 +265,11 @@ class BannerlordHarness:
desktop_command: Optional[list[str]] = None,
steam_command: Optional[list[str]] = None,
enable_mock: bool = False,
enable_trace: bool = False,
):
self.hermes_ws_url = hermes_ws_url
self.desktop_command = desktop_command or DEFAULT_MCP_DESKTOP_COMMAND
self.steam_command = steam_command or DEFAULT_MCP_STEAM_COMMAND
self.enable_mock = enable_mock
self.enable_trace = enable_trace
# MCP clients
self.desktop_mcp: Optional[MCPClient] = None
@@ -288,9 +284,6 @@ class BannerlordHarness:
self.cycle_count = 0
self.running = False
# Session trace logger
self.trace_logger: Optional[BannerlordTraceLogger] = None
# ═══ LIFECYCLE ═══
async def start(self) -> bool:
@@ -321,15 +314,6 @@ class BannerlordHarness:
# Connect to Hermes WebSocket
await self._connect_hermes()
# Initialize trace logger if enabled
if self.enable_trace:
self.trace_logger = BannerlordTraceLogger(
harness_session_id=self.session_id,
hermes_session_id=self.session_id,
)
self.trace_logger.start_session()
log.info(f"Trace logger started: {self.trace_logger.trace_id}")
log.info("Harness initialized successfully")
return True
@@ -338,12 +322,6 @@ class BannerlordHarness:
self.running = False
log.info("Shutting down harness...")
# Finalize trace logger
if self.trace_logger:
manifest = self.trace_logger.finish_session()
log.info(f"Trace saved: {manifest.trace_file}")
log.info(f"Manifest: {self.trace_logger.manifest_file}")
if self.desktop_mcp:
self.desktop_mcp.stop()
if self.steam_mcp:
@@ -729,11 +707,6 @@ class BannerlordHarness:
self.cycle_count = iteration
log.info(f"\n--- ODA Cycle {iteration + 1}/{max_iterations} ---")
# Start trace cycle
trace_cycle = None
if self.trace_logger:
trace_cycle = self.trace_logger.begin_cycle(iteration)
# 1. OBSERVE: Capture state
log.info("[OBSERVE] Capturing game state...")
state = await self.capture_state()
@@ -742,24 +715,11 @@ class BannerlordHarness:
log.info(f" Screen: {state.visual.screen_size}")
log.info(f" Players online: {state.game_context.current_players_online}")
# Populate trace with observation data
if trace_cycle:
trace_cycle.screenshot_path = state.visual.screenshot_path or ""
trace_cycle.window_found = state.visual.window_found
trace_cycle.screen_size = list(state.visual.screen_size)
trace_cycle.mouse_position = list(state.visual.mouse_position)
trace_cycle.playtime_hours = state.game_context.playtime_hours
trace_cycle.players_online = state.game_context.current_players_online
trace_cycle.is_running = state.game_context.is_running
# 2. DECIDE: Get actions from decision function
log.info("[DECIDE] Getting actions...")
actions = decision_fn(state)
log.info(f" Decision returned {len(actions)} actions")
if trace_cycle:
trace_cycle.actions_planned = actions
# 3. ACT: Execute actions
log.info("[ACT] Executing actions...")
results = []
@@ -771,13 +731,6 @@ class BannerlordHarness:
if result.error:
log.info(f" Error: {result.error}")
if trace_cycle:
trace_cycle.actions_executed.append(result.to_dict())
# Finalize trace cycle
if trace_cycle:
self.trace_logger.finish_cycle(trace_cycle)
# Send cycle summary telemetry
await self._send_telemetry({
"type": "oda_cycle_complete",
@@ -883,18 +836,12 @@ async def main():
default=1.0,
help="Delay between iterations in seconds (default: 1.0)",
)
parser.add_argument(
"--trace",
action="store_true",
help="Enable session trace logging to ~/.timmy/traces/bannerlord/",
)
args = parser.parse_args()
# Create harness
harness = BannerlordHarness(
hermes_ws_url=args.hermes_ws,
enable_mock=args.mock,
enable_trace=args.trace,
)
try:

View File

@@ -1,234 +0,0 @@
#!/usr/bin/env python3
"""
Bannerlord Session Trace Logger — First-Replayable Training Material
Captures one Bannerlord session as a replayable trace:
- Timestamps on every cycle
- Actions executed with success/failure
- World-state evidence (screenshots, Steam stats)
- Hermes session/log ID mapping
Storage: ~/.timmy/traces/bannerlord/trace_<session_id>.jsonl
Manifest: ~/.timmy/traces/bannerlord/manifest_<session_id>.json
Each JSONL line is one ODA cycle with full context.
The manifest bundles metadata for replay/eval.
"""
from __future__ import annotations
import json
import time
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
# Storage root — local-first under ~/.timmy/
DEFAULT_TRACE_DIR = Path.home() / ".timmy" / "traces" / "bannerlord"
@dataclass
class CycleTrace:
"""One ODA cycle captured in full."""
cycle_index: int
timestamp_start: str
timestamp_end: str = ""
duration_ms: int = 0
# Observe
screenshot_path: str = ""
window_found: bool = False
screen_size: list[int] = field(default_factory=lambda: [1920, 1080])
mouse_position: list[int] = field(default_factory=lambda: [0, 0])
playtime_hours: float = 0.0
players_online: int = 0
is_running: bool = False
# Decide
actions_planned: list[dict] = field(default_factory=list)
decision_note: str = ""
# Act
actions_executed: list[dict] = field(default_factory=list)
actions_succeeded: int = 0
actions_failed: int = 0
# Metadata
hermes_session_id: str = ""
hermes_log_id: str = ""
harness_session_id: str = ""
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class SessionManifest:
"""Top-level metadata for a captured session trace."""
trace_id: str
harness_session_id: str
hermes_session_id: str
hermes_log_id: str
game: str = "Mount & Blade II: Bannerlord"
app_id: int = 261550
started_at: str = ""
finished_at: str = ""
total_cycles: int = 0
total_actions: int = 0
total_succeeded: int = 0
total_failed: int = 0
trace_file: str = ""
trace_dir: str = ""
replay_command: str = ""
eval_note: str = ""
def to_dict(self) -> dict:
return asdict(self)
class BannerlordTraceLogger:
"""
Captures a single Bannerlord session as a replayable trace.
Usage:
logger = BannerlordTraceLogger(hermes_session_id="abc123")
logger.start_session()
cycle = logger.begin_cycle(0)
# ... populate cycle fields ...
logger.finish_cycle(cycle)
manifest = logger.finish_session()
"""
def __init__(
self,
trace_dir: Optional[Path] = None,
harness_session_id: str = "",
hermes_session_id: str = "",
hermes_log_id: str = "",
):
self.trace_dir = trace_dir or DEFAULT_TRACE_DIR
self.trace_dir.mkdir(parents=True, exist_ok=True)
self.trace_id = f"bl_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
self.harness_session_id = harness_session_id or str(uuid.uuid4())[:8]
self.hermes_session_id = hermes_session_id
self.hermes_log_id = hermes_log_id
self.trace_file = self.trace_dir / f"trace_{self.trace_id}.jsonl"
self.manifest_file = self.trace_dir / f"manifest_{self.trace_id}.json"
self.cycles: list[CycleTrace] = []
self.started_at: str = ""
self.finished_at: str = ""
def start_session(self) -> str:
"""Begin a trace session. Returns trace_id."""
self.started_at = datetime.now(timezone.utc).isoformat()
return self.trace_id
def begin_cycle(self, cycle_index: int) -> CycleTrace:
"""Start recording one ODA cycle."""
cycle = CycleTrace(
cycle_index=cycle_index,
timestamp_start=datetime.now(timezone.utc).isoformat(),
harness_session_id=self.harness_session_id,
hermes_session_id=self.hermes_session_id,
hermes_log_id=self.hermes_log_id,
)
return cycle
def finish_cycle(self, cycle: CycleTrace) -> None:
"""Finalize and persist one cycle to the trace file."""
cycle.timestamp_end = datetime.now(timezone.utc).isoformat()
# Compute duration
try:
t0 = datetime.fromisoformat(cycle.timestamp_start)
t1 = datetime.fromisoformat(cycle.timestamp_end)
cycle.duration_ms = int((t1 - t0).total_seconds() * 1000)
except (ValueError, TypeError):
cycle.duration_ms = 0
# Count successes/failures
cycle.actions_succeeded = sum(
1 for a in cycle.actions_executed if a.get("success", False)
)
cycle.actions_failed = sum(
1 for a in cycle.actions_executed if not a.get("success", True)
)
self.cycles.append(cycle)
# Append to JSONL
with open(self.trace_file, "a") as f:
f.write(json.dumps(cycle.to_dict()) + "\n")
def finish_session(self) -> SessionManifest:
"""Finalize the session and write the manifest."""
self.finished_at = datetime.now(timezone.utc).isoformat()
total_actions = sum(len(c.actions_executed) for c in self.cycles)
total_succeeded = sum(c.actions_succeeded for c in self.cycles)
total_failed = sum(c.actions_failed for c in self.cycles)
manifest = SessionManifest(
trace_id=self.trace_id,
harness_session_id=self.harness_session_id,
hermes_session_id=self.hermes_session_id,
hermes_log_id=self.hermes_log_id,
started_at=self.started_at,
finished_at=self.finished_at,
total_cycles=len(self.cycles),
total_actions=total_actions,
total_succeeded=total_succeeded,
total_failed=total_failed,
trace_file=str(self.trace_file),
trace_dir=str(self.trace_dir),
replay_command=(
f"python -m nexus.bannerlord_harness --mock --replay {self.trace_file}"
),
eval_note=(
"To replay: load this trace, re-execute each cycle's actions_planned "
"against a fresh harness in mock mode, compare actions_executed outcomes. "
"Success metric: >=90% action parity between original and replay runs."
),
)
with open(self.manifest_file, "w") as f:
json.dump(manifest.to_dict(), f, indent=2)
return manifest
@classmethod
def load_trace(cls, trace_file: Path) -> list[dict]:
"""Load a trace JSONL file for replay or analysis."""
cycles = []
with open(trace_file) as f:
for line in f:
line = line.strip()
if line:
cycles.append(json.loads(line))
return cycles
@classmethod
def load_manifest(cls, manifest_file: Path) -> dict:
"""Load a session manifest."""
with open(manifest_file) as f:
return json.load(f)
@classmethod
def list_traces(cls, trace_dir: Optional[Path] = None) -> list[dict]:
"""List all available trace sessions."""
d = trace_dir or DEFAULT_TRACE_DIR
if not d.exists():
return []
traces = []
for mf in sorted(d.glob("manifest_*.json")):
try:
manifest = cls.load_manifest(mf)
traces.append(manifest)
except (json.JSONDecodeError, IOError):
continue
return traces

View File

@@ -1,413 +0,0 @@
// ═══════════════════════════════════════════════════════
// PROJECT MNEMOSYNE — SESSION ROOMS (Issue #1171)
// ═══════════════════════════════════════════════════════
//
// Groups memories by session into holographic chambers.
// Each session becomes a wireframe cube floating in space.
// Rooms are arranged chronologically along a spiral.
// Click a room to fly inside; distant rooms LOD to a point.
//
// Usage from app.js:
// SessionRooms.init(scene, camera, controls);
// SessionRooms.updateSessions(sessions); // [{id, timestamp, facts[]}]
// SessionRooms.update(delta); // call each frame
// SessionRooms.getClickableMeshes(); // for raycasting
// SessionRooms.handleRoomClick(mesh); // trigger fly-in
// ═══════════════════════════════════════════════════════
const SessionRooms = (() => {
// ─── CONSTANTS ───────────────────────────────────────
const MAX_ROOMS = 20;
const ROOM_SIZE = 9; // wireframe cube edge length
const ROOM_HALF = ROOM_SIZE / 2;
const LOD_THRESHOLD = 55; // distance: full → point
const LOD_HYSTERESIS = 5; // buffer to avoid flicker
const SPIRAL_BASE_R = 20; // spiral inner radius
const SPIRAL_R_STEP = 5; // radius growth per room
const SPIRAL_ANGLE_INC = 2.399; // golden angle (radians)
const SPIRAL_Y_STEP = 1.5; // vertical rise per room
const FLY_DURATION = 1.5; // seconds for fly-in tween
const FLY_TARGET_DEPTH = ROOM_HALF - 1.5; // how deep inside to stop
const ROOM_COLOR = 0x7b5cff; // violet — mnemosyne accent
const POINT_COLOR = 0x9b7cff;
const LABEL_COLOR = '#c8b4ff';
const STORAGE_KEY = 'mnemosyne_sessions_v1';
// ─── STATE ────────────────────────────────────────────
let _scene = null;
let _camera = null;
let _controls = null;
let _rooms = []; // array of room objects
let _sessionIndex = {}; // id → room object
// Fly-in tween state
let _flyActive = false;
let _flyElapsed = 0;
let _flyFrom = null;
let _flyTo = null;
let _flyLookFrom = null;
let _flyLookTo = null;
let _flyActiveRoom = null;
// ─── SPIRAL POSITION ──────────────────────────────────
function _spiralPos(index) {
const angle = index * SPIRAL_ANGLE_INC;
const r = SPIRAL_BASE_R + index * SPIRAL_R_STEP;
const y = index * SPIRAL_Y_STEP;
return new THREE.Vector3(
Math.cos(angle) * r,
y,
Math.sin(angle) * r
);
}
// ─── CREATE ROOM ──────────────────────────────────────
function _createRoom(session, index) {
const pos = _spiralPos(index);
const group = new THREE.Group();
group.position.copy(pos);
// Wireframe cube
const boxGeo = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE);
const edgesGeo = new THREE.EdgesGeometry(boxGeo);
const edgesMat = new THREE.LineBasicMaterial({
color: ROOM_COLOR,
transparent: true,
opacity: 0.55
});
const wireframe = new THREE.LineSegments(edgesGeo, edgesMat);
wireframe.userData = { type: 'session_room_wireframe', sessionId: session.id };
group.add(wireframe);
// Collision mesh (invisible, for raycasting)
const hitGeo = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE);
const hitMat = new THREE.MeshBasicMaterial({
visible: false,
transparent: true,
opacity: 0,
side: THREE.FrontSide
});
const hitMesh = new THREE.Mesh(hitGeo, hitMat);
hitMesh.userData = { type: 'session_room', sessionId: session.id, roomIndex: index };
group.add(hitMesh);
// LOD point (small sphere shown at distance)
const pointGeo = new THREE.SphereGeometry(0.5, 6, 4);
const pointMat = new THREE.MeshBasicMaterial({
color: POINT_COLOR,
transparent: true,
opacity: 0.7
});
const pointMesh = new THREE.Mesh(pointGeo, pointMat);
pointMesh.userData = { type: 'session_room_point', sessionId: session.id };
pointMesh.visible = false; // starts hidden; shown only at LOD distance
group.add(pointMesh);
// Timestamp billboard sprite
const sprite = _makeTimestampSprite(session.timestamp, session.facts.length);
sprite.position.set(0, ROOM_HALF + 1.2, 0);
group.add(sprite);
// Inner ambient glow
const glow = new THREE.PointLight(ROOM_COLOR, 0.4, ROOM_SIZE * 1.2);
group.add(glow);
_scene.add(group);
const room = {
session,
group,
wireframe,
hitMesh,
pointMesh,
sprite,
glow,
pos: pos.clone(),
index,
lodActive: false,
pulsePhase: Math.random() * Math.PI * 2
};
_rooms.push(room);
_sessionIndex[session.id] = room;
console.info('[SessionRooms] Created room for session', session.id, 'at index', index);
return room;
}
// ─── TIMESTAMP SPRITE ────────────────────────────────
function _makeTimestampSprite(isoTimestamp, factCount) {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 72;
const ctx = canvas.getContext('2d');
// Background pill
ctx.clearRect(0, 0, 320, 72);
ctx.fillStyle = 'rgba(20, 10, 40, 0.82)';
_roundRect(ctx, 4, 4, 312, 64, 14);
ctx.fill();
// Border
ctx.strokeStyle = 'rgba(123, 92, 255, 0.6)';
ctx.lineWidth = 1.5;
_roundRect(ctx, 4, 4, 312, 64, 14);
ctx.stroke();
// Timestamp text
const dt = isoTimestamp ? new Date(isoTimestamp) : new Date();
const label = _formatDate(dt);
ctx.fillStyle = LABEL_COLOR;
ctx.font = 'bold 15px monospace';
ctx.textAlign = 'center';
ctx.fillText(label, 160, 30);
// Fact count
ctx.fillStyle = 'rgba(200, 180, 255, 0.65)';
ctx.font = '12px monospace';
ctx.fillText(factCount + (factCount === 1 ? ' fact' : ' facts'), 160, 52);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.88 });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(5, 1.1, 1);
sprite.userData = { type: 'session_room_label' };
return sprite;
}
// ─── HELPERS ──────────────────────────────────────────
function _roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function _formatDate(dt) {
if (isNaN(dt.getTime())) return 'Unknown session';
const pad = n => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
// ─── DISPOSE ROOM ────────────────────────────────────
function _disposeRoom(room) {
room.wireframe.geometry.dispose();
room.wireframe.material.dispose();
room.hitMesh.geometry.dispose();
room.hitMesh.material.dispose();
room.pointMesh.geometry.dispose();
room.pointMesh.material.dispose();
if (room.sprite.material.map) room.sprite.material.map.dispose();
room.sprite.material.dispose();
if (room.group.parent) room.group.parent.remove(room.group);
delete _sessionIndex[room.session.id];
}
// ─── PUBLIC: UPDATE SESSIONS ─────────────────────────
// sessions: [{id, timestamp, facts:[{id,content,category,strength,...}]}]
// Sorted chronologically oldest→newest; max MAX_ROOMS shown.
function updateSessions(sessions) {
if (!_scene) return;
const sorted = [...sessions]
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.slice(-MAX_ROOMS); // keep most recent MAX_ROOMS
// Remove rooms no longer present
const incoming = new Set(sorted.map(s => s.id));
for (let i = _rooms.length - 1; i >= 0; i--) {
const room = _rooms[i];
if (!incoming.has(room.session.id)) {
_disposeRoom(room);
_rooms.splice(i, 1);
}
}
// Add / update
sorted.forEach((session, idx) => {
if (_sessionIndex[session.id]) {
// Update position if index changed
const room = _sessionIndex[session.id];
if (room.index !== idx) {
room.index = idx;
const newPos = _spiralPos(idx);
room.group.position.copy(newPos);
room.pos.copy(newPos);
}
} else {
_createRoom(session, idx);
}
});
saveToStorage(sorted);
console.info('[SessionRooms] Updated:', _rooms.length, 'session rooms');
}
// ─── PUBLIC: INIT ─────────────────────────────────────
function init(scene, camera, controls) {
_scene = scene;
_camera = camera;
_controls = controls;
console.info('[SessionRooms] Initialized');
// Restore persisted sessions
const saved = loadFromStorage();
if (saved && saved.length > 0) {
updateSessions(saved);
}
}
// ─── PUBLIC: UPDATE (per-frame) ───────────────────────
function update(delta) {
if (!_scene || !_camera) return;
const camPos = _camera.position;
_rooms.forEach(room => {
const dist = camPos.distanceTo(room.pos);
// LOD toggle
const threshold = room.lodActive
? LOD_THRESHOLD + LOD_HYSTERESIS // must come closer to exit LOD
: LOD_THRESHOLD;
if (dist > threshold && !room.lodActive) {
room.lodActive = true;
room.wireframe.visible = false;
room.sprite.visible = false;
room.pointMesh.visible = true;
} else if (dist <= threshold && room.lodActive) {
room.lodActive = false;
room.wireframe.visible = true;
room.sprite.visible = true;
room.pointMesh.visible = false;
}
// Pulse wireframe opacity
room.pulsePhase += delta * 0.6;
if (!room.lodActive) {
room.wireframe.material.opacity = 0.3 + Math.sin(room.pulsePhase) * 0.2;
room.glow.intensity = 0.3 + Math.sin(room.pulsePhase * 1.4) * 0.15;
}
// Slowly rotate each room
room.group.rotation.y += delta * 0.04;
});
// Fly-in tween
if (_flyActive) {
_flyElapsed += delta;
const t = Math.min(_flyElapsed / FLY_DURATION, 1);
const ease = _easeInOut(t);
_camera.position.lerpVectors(_flyFrom, _flyTo, ease);
// Interpolate lookAt
const lookNow = new THREE.Vector3().lerpVectors(_flyLookFrom, _flyLookTo, ease);
_camera.lookAt(lookNow);
if (_controls && _controls.target) _controls.target.copy(lookNow);
if (t >= 1) {
_flyActive = false;
if (_controls && typeof _controls.update === 'function') _controls.update();
console.info('[SessionRooms] Fly-in complete for session', _flyActiveRoom && _flyActiveRoom.session.id);
_flyActiveRoom = null;
}
}
}
// ─── EASING ───────────────────────────────────────────
function _easeInOut(t) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
// ─── PUBLIC: GET CLICKABLE MESHES ─────────────────────
function getClickableMeshes() {
return _rooms.map(r => r.hitMesh);
}
// ─── PUBLIC: HANDLE ROOM CLICK ────────────────────────
function handleRoomClick(mesh) {
const { sessionId } = mesh.userData;
const room = _sessionIndex[sessionId];
if (!room || !_camera) return null;
// Fly into the room from the front face
_flyActive = true;
_flyElapsed = 0;
_flyActiveRoom = room;
_flyFrom = _camera.position.clone();
// Target: step inside the room toward its center
const dir = room.pos.clone().sub(_camera.position).normalize();
_flyTo = room.pos.clone().add(dir.multiplyScalar(FLY_TARGET_DEPTH));
_flyLookFrom = _controls && _controls.target
? _controls.target.clone()
: _camera.position.clone().add(_camera.getWorldDirection(new THREE.Vector3()));
_flyLookTo = room.pos.clone();
console.info('[SessionRooms] Flying into session room:', sessionId);
return room.session;
}
// ─── PERSISTENCE ──────────────────────────────────────
function saveToStorage(sessions) {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, sessions }));
} catch (e) {
console.warn('[SessionRooms] Failed to save to localStorage:', e);
}
}
function loadFromStorage() {
if (typeof localStorage === 'undefined') return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.sessions)) return null;
console.info('[SessionRooms] Restored', parsed.sessions.length, 'sessions from localStorage');
return parsed.sessions;
} catch (e) {
console.warn('[SessionRooms] Failed to load from localStorage:', e);
return null;
}
}
function clearStorage() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
console.info('[SessionRooms] Cleared localStorage');
}
}
// ─── PUBLIC API ───────────────────────────────────────
return {
init,
updateSessions,
update,
getClickableMeshes,
handleRoomClick,
clearStorage,
// For external inspection
getRooms: () => _rooms,
getSession: (id) => _sessionIndex[id] || null,
isFlyActive: () => _flyActive
};
})();
export { SessionRooms };

View File

@@ -1,665 +0,0 @@
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — SPATIAL MEMORY SCHEMA
// ═══════════════════════════════════════════
//
// Maps memories to persistent locations in the 3D Nexus world.
// Each region corresponds to a semantic category. Memories placed
// in a region stay there across sessions, forming a navigable
// holographic archive.
//
// World layout (hex cylinder, radius 25):
//
// Inner ring — original Mnemosyne taxonomy (radius 15):
// North (z-) → Documents & Knowledge
// South (z+) → Projects & Tasks
// East (x+) → Code & Engineering
// West (x-) → Conversations & Social
// Center → Active Working Memory
// Below (y-) → Archive (cold storage)
//
// Outer ring — MemPalace category zones (radius 20, issue #1168):
// North (z-) → User Preferences [golden]
// East (x+) → Project facts [blue]
// South (z+) → Tool knowledge [green]
// West (x-) → General facts [gray]
//
// Usage from app.js:
// SpatialMemory.init(scene);
// SpatialMemory.placeMemory({ id, content, category, ... });
// SpatialMemory.importIndex(savedIndex);
// SpatialMemory.update(delta);
// ═══════════════════════════════════════════
const SpatialMemory = (() => {
// ─── REGION DEFINITIONS ───────────────────────────────
const REGIONS = {
engineering: {
label: 'Code & Engineering',
center: [15, 0, 0],
radius: 10,
color: 0x4af0c0,
glyph: '\u2699',
description: 'Source code, debugging sessions, architecture decisions'
},
social: {
label: 'Conversations & Social',
center: [-15, 0, 0],
radius: 10,
color: 0x7b5cff,
glyph: '\uD83D\uDCAC',
description: 'Chats, discussions, human interactions'
},
knowledge: {
label: 'Documents & Knowledge',
center: [0, 0, -15],
radius: 10,
color: 0xffd700,
glyph: '\uD83D\uDCD6',
description: 'Papers, docs, research, learned concepts'
},
projects: {
label: 'Projects & Tasks',
center: [0, 0, 15],
radius: 10,
color: 0xff4466,
glyph: '\uD83C\uDFAF',
description: 'Active tasks, issues, milestones, goals'
},
working: {
label: 'Active Working Memory',
center: [0, 0, 0],
radius: 5,
color: 0x00ff88,
glyph: '\uD83D\uDCA1',
description: 'Current focus — transient, high-priority memories'
},
archive: {
label: 'Archive',
center: [0, -3, 0],
radius: 20,
color: 0x334455,
glyph: '\uD83D\uDDC4',
description: 'Cold storage — rarely accessed, aged-out memories'
},
// ── MemPalace category zones — outer ring, issue #1168 ────────────
user_pref: {
label: 'User Preferences',
center: [0, 0, -20],
radius: 10,
color: 0xffd700,
glyph: '\u2605',
description: 'Personal preferences, habits, user-specific settings',
labelY: 5
},
project: {
label: 'Project Facts',
center: [20, 0, 0],
radius: 10,
color: 0x4488ff,
glyph: '\uD83D\uDCC1',
description: 'Project-specific knowledge, goals, context',
labelY: 5
},
tool: {
label: 'Tool Knowledge',
center: [0, 0, 20],
radius: 10,
color: 0x44cc66,
glyph: '\uD83D\uDD27',
description: 'Tools, commands, APIs, and how to use them',
labelY: 5
},
general: {
label: 'General Facts',
center: [-20, 0, 0],
radius: 10,
color: 0x8899aa,
glyph: '\uD83D\uDCDD',
description: 'Miscellaneous facts not fitting other categories',
labelY: 5
}
};
// ─── PERSISTENCE CONFIG ──────────────────────────────
const STORAGE_KEY = 'mnemosyne_spatial_memory';
const STORAGE_VERSION = 1;
let _dirty = false;
let _lastSavedHash = '';
// ─── STATE ────────────────────────────────────────────
let _scene = null;
let _regionMarkers = {};
let _memoryObjects = {};
let _connectionLines = [];
let _initialized = false;
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
function createCrystalGeometry(size) {
return new THREE.OctahedronGeometry(size, 0);
}
// ─── REGION MARKER ───────────────────────────────────
function createRegionMarker(regionKey, region) {
const cx = region.center[0];
const cy = region.center[1] + 0.06;
const cz = region.center[2];
const labelY = region.labelY || 3;
const ringGeo = new THREE.RingGeometry(region.radius - 0.5, region.radius, 6);
const ringMat = new THREE.MeshBasicMaterial({
color: region.color,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.set(cx, cy, cz);
ring.userData = { type: 'region_marker', region: regionKey };
const discGeo = new THREE.CircleGeometry(region.radius - 0.5, 6);
const discMat = new THREE.MeshBasicMaterial({
color: region.color,
transparent: true,
opacity: 0.03,
side: THREE.DoubleSide
});
const disc = new THREE.Mesh(discGeo, discMat);
disc.rotation.x = -Math.PI / 2;
disc.position.set(cx, cy - 0.01, cz);
_scene.add(ring);
_scene.add(disc);
// Ground glow — brighter disc for MemPalace zones (labelY > 3 signals outer ring)
let glowDisc = null;
if (labelY > 3) {
const glowGeo = new THREE.CircleGeometry(region.radius, 32);
const glowMat = new THREE.MeshBasicMaterial({
color: region.color,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide
});
glowDisc = new THREE.Mesh(glowGeo, glowMat);
glowDisc.rotation.x = -Math.PI / 2;
glowDisc.position.set(cx, cy - 0.02, cz);
_scene.add(glowDisc);
}
// Floating label
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.font = '24px monospace';
ctx.fillStyle = '#' + region.color.toString(16).padStart(6, '0');
ctx.textAlign = 'center';
ctx.fillText(region.glyph + ' ' + region.label, 128, 40);
const texture = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.6 });
const sprite = new THREE.Sprite(spriteMat);
sprite.position.set(cx, labelY, cz);
sprite.scale.set(4, 1, 1);
_scene.add(sprite);
return { ring, disc, glowDisc, sprite };
}
// ─── PLACE A MEMORY ──────────────────────────────────
function placeMemory(mem) {
if (!_scene) return null;
const region = REGIONS[mem.category] || REGIONS.working;
const pos = mem.position || _assignPosition(mem.category, mem.id);
const strength = Math.max(0.05, Math.min(1, mem.strength != null ? mem.strength : 0.7));
const size = 0.2 + strength * 0.3;
const geo = createCrystalGeometry(size);
const mat = new THREE.MeshStandardMaterial({
color: region.color,
emissive: region.color,
emissiveIntensity: 1.5 * strength,
metalness: 0.6,
roughness: 0.15,
transparent: true,
opacity: 0.5 + strength * 0.4
});
const crystal = new THREE.Mesh(geo, mat);
crystal.position.set(pos[0], pos[1] + 1.5, pos[2]);
crystal.castShadow = true;
crystal.userData = {
type: 'spatial_memory',
memId: mem.id,
region: mem.category,
pulse: Math.random() * Math.PI * 2,
strength: strength,
createdAt: mem.timestamp || new Date().toISOString()
};
const light = new THREE.PointLight(region.color, 0.8 * strength, 5);
crystal.add(light);
_scene.add(crystal);
_memoryObjects[mem.id] = { mesh: crystal, data: mem, region: mem.category };
if (mem.connections && mem.connections.length > 0) {
_drawConnections(mem.id, mem.connections);
}
_dirty = true;
saveToStorage();
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
return crystal;
}
// ─── DETERMINISTIC POSITION ──────────────────────────
function _assignPosition(category, memId) {
const region = REGIONS[category] || REGIONS.working;
const cx = region.center[0];
const cy = region.center[1];
const cz = region.center[2];
const r = region.radius * 0.7;
let hash = 0;
for (let i = 0; i < memId.length; i++) {
hash = ((hash << 5) - hash) + memId.charCodeAt(i);
hash |= 0;
}
const angle = (Math.abs(hash % 360) / 360) * Math.PI * 2;
const dist = (Math.abs((hash >> 8) % 100) / 100) * r;
const height = (Math.abs((hash >> 16) % 100) / 100) * 3;
return [cx + Math.cos(angle) * dist, cy + height, cz + Math.sin(angle) * dist];
}
// ─── CONNECTIONS ─────────────────────────────────────
function _drawConnections(memId, connections) {
const src = _memoryObjects[memId];
if (!src) return;
connections.forEach(targetId => {
const tgt = _memoryObjects[targetId];
if (!tgt) return;
const points = [src.mesh.position.clone(), tgt.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: 0x334455, transparent: true, opacity: 0.2 });
const line = new THREE.Line(geo, mat);
line.userData = { type: 'connection', from: memId, to: targetId };
_scene.add(line);
_connectionLines.push(line);
});
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
if (!obj) return;
if (obj.mesh.parent) obj.mesh.parent.remove(obj.mesh);
if (obj.mesh.geometry) obj.mesh.geometry.dispose();
if (obj.mesh.material) obj.mesh.material.dispose();
for (let i = _connectionLines.length - 1; i >= 0; i--) {
const line = _connectionLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_connectionLines.splice(i, 1);
}
}
delete _memoryObjects[memId];
_dirty = true;
saveToStorage();
}
// ─── ANIMATE ─────────────────────────────────────────
function update(delta) {
const now = Date.now();
Object.values(_memoryObjects).forEach(obj => {
const mesh = obj.mesh;
if (!mesh || !mesh.userData) return;
mesh.rotation.y += delta * 0.3;
mesh.userData.pulse += delta * 1.5;
const pulse = 1 + Math.sin(mesh.userData.pulse) * 0.08;
mesh.scale.setScalar(pulse);
if (mesh.material) {
const base = mesh.userData.strength || 0.7;
mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base;
}
});
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
}
if (marker.glowDisc && marker.glowDisc.material) {
marker.glowDisc.material.opacity = 0.04 + Math.sin(now * 0.0008) * 0.02;
}
});
}
// ─── INIT ────────────────────────────────────────────
function init(scene) {
_scene = scene;
_initialized = true;
Object.entries(REGIONS).forEach(([key, region]) => {
if (key === 'archive') return;
_regionMarkers[key] = createRegionMarker(key, region);
});
// Restore persisted memories
const restored = loadFromStorage();
console.info('[Mnemosyne] Spatial Memory Schema initialized —', Object.keys(REGIONS).length, 'regions,', restored, 'memories restored');
return REGIONS;
}
// ─── QUERY ───────────────────────────────────────────
function getMemoryAtPosition(position, maxDist) {
maxDist = maxDist || 2;
let closest = null;
let closestDist = maxDist;
Object.values(_memoryObjects).forEach(obj => {
const d = obj.mesh.position.distanceTo(position);
if (d < closestDist) { closest = obj; closestDist = d; }
});
return closest;
}
function getRegionAtPosition(position) {
for (const [key, region] of Object.entries(REGIONS)) {
const dx = position.x - region.center[0];
const dz = position.z - region.center[2];
if (Math.sqrt(dx * dx + dz * dz) <= region.radius) return key;
}
return null;
}
function getMemoriesInRegion(regionKey) {
return Object.values(_memoryObjects).filter(o => o.region === regionKey);
}
function getAllMemories() {
return Object.values(_memoryObjects).map(o => o.data);
}
// ─── LOCALSTORAGE PERSISTENCE ────────────────────────
function _indexHash(index) {
// Simple hash of memory IDs + count to detect changes
const ids = (index.memories || []).map(m => m.id).sort().join(',');
return index.memories.length + ':' + ids;
}
function saveToStorage() {
if (typeof localStorage === 'undefined') {
console.warn('[Mnemosyne] localStorage unavailable — skipping save');
return false;
}
try {
const index = exportIndex();
const hash = _indexHash(index);
if (hash === _lastSavedHash) return false; // no change
const payload = JSON.stringify(index);
localStorage.setItem(STORAGE_KEY, payload);
_lastSavedHash = hash;
_dirty = false;
console.info('[Mnemosyne] Saved', index.memories.length, 'memories to localStorage');
return true;
} catch (e) {
if (e.name === 'QuotaExceededError' || e.code === 22) {
console.warn('[Mnemosyne] localStorage quota exceeded — pruning archive memories');
_pruneArchiveMemories();
try {
const index = exportIndex();
localStorage.setItem(STORAGE_KEY, JSON.stringify(index));
_lastSavedHash = _indexHash(index);
console.info('[Mnemosyne] Saved after prune:', index.memories.length, 'memories');
return true;
} catch (e2) {
console.error('[Mnemosyne] Save failed even after prune:', e2);
return false;
}
}
console.error('[Mnemosyne] Save failed:', e);
return false;
}
}
function loadFromStorage() {
if (typeof localStorage === 'undefined') {
console.warn('[Mnemosyne] localStorage unavailable — starting empty');
return 0;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
console.info('[Mnemosyne] No saved state found — starting fresh');
return 0;
}
const index = JSON.parse(raw);
if (index.version !== STORAGE_VERSION) {
console.warn('[Mnemosyne] Saved version mismatch (got', index.version, 'expected', + STORAGE_VERSION + ') — starting fresh');
return 0;
}
const count = importIndex(index);
_lastSavedHash = _indexHash(index);
return count;
} catch (e) {
console.error('[Mnemosyne] Load failed:', e);
return 0;
}
}
function _pruneArchiveMemories() {
// Remove oldest archive-region memories first
const archive = getMemoriesInRegion('archive');
const working = Object.values(_memoryObjects).filter(o => o.region !== 'archive');
// Sort archive by timestamp ascending (oldest first)
archive.sort((a, b) => {
const ta = a.data.timestamp || a.mesh.userData.createdAt || '';
const tb = b.data.timestamp || b.mesh.userData.createdAt || '';
return ta.localeCompare(tb);
});
const toRemove = Math.max(1, Math.ceil(archive.length * 0.25));
for (let i = 0; i < toRemove && i < archive.length; i++) {
removeMemory(archive[i].data.id);
}
console.info('[Mnemosyne] Pruned', toRemove, 'archive memories');
}
function clearStorage() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
_lastSavedHash = '';
console.info('[Mnemosyne] Cleared localStorage');
}
}
// ─── PERSISTENCE ─────────────────────────────────────
function exportIndex() {
return {
version: 1,
exportedAt: new Date().toISOString(),
regions: Object.fromEntries(
Object.entries(REGIONS).map(([k, v]) => [k, { label: v.label, center: v.center, radius: v.radius, color: v.color }])
),
memories: Object.values(_memoryObjects).map(o => ({
id: o.data.id,
content: o.data.content,
category: o.region,
position: [o.mesh.position.x, o.mesh.position.y - 1.5, o.mesh.position.z],
source: o.data.source || 'unknown',
timestamp: o.data.timestamp || o.mesh.userData.createdAt,
strength: o.mesh.userData.strength || 0.7,
connections: o.data.connections || []
}))
};
}
function importIndex(index) {
if (!index || !index.memories) return 0;
let count = 0;
index.memories.forEach(mem => {
if (!_memoryObjects[mem.id]) { placeMemory(mem); count++; }
});
console.info('[Mnemosyne] Restored', count, 'memories from index');
return count;
}
// ─── GRAVITY WELL CLUSTERING ──────────────────────────
// Force-directed layout: same-category crystals attract, unrelated repel.
// Run on load (bake positions, not per-frame). Spec from issue #1175.
const GRAVITY_ITERATIONS = 20;
const ATTRACT_FACTOR = 0.10; // 10% closer to same-category centroid per iteration
const REPEL_FACTOR = 0.05; // 5% away from nearest unrelated crystal
function runGravityLayout() {
const objs = Object.values(_memoryObjects);
if (objs.length < 2) {
console.info('[Mnemosyne] Gravity layout: fewer than 2 crystals, skipping');
return;
}
console.info('[Mnemosyne] Gravity layout starting —', objs.length, 'crystals,', GRAVITY_ITERATIONS, 'iterations');
for (let iter = 0; iter < GRAVITY_ITERATIONS; iter++) {
// Accumulate displacements before applying (avoids order-of-iteration bias)
const dx = new Float32Array(objs.length);
const dy = new Float32Array(objs.length);
const dz = new Float32Array(objs.length);
objs.forEach((obj, i) => {
const pos = obj.mesh.position;
const cat = obj.region;
// ── Attraction toward same-category centroid ──────────────
let sx = 0, sy = 0, sz = 0, sameCount = 0;
objs.forEach(o => {
if (o === obj || o.region !== cat) return;
sx += o.mesh.position.x;
sy += o.mesh.position.y;
sz += o.mesh.position.z;
sameCount++;
});
if (sameCount > 0) {
dx[i] += ((sx / sameCount) - pos.x) * ATTRACT_FACTOR;
dy[i] += ((sy / sameCount) - pos.y) * ATTRACT_FACTOR;
dz[i] += ((sz / sameCount) - pos.z) * ATTRACT_FACTOR;
}
// ── Repulsion from nearest unrelated crystal ───────────────
let nearestDist = Infinity;
let rnx = 0, rny = 0, rnz = 0;
objs.forEach(o => {
if (o === obj || o.region === cat) return;
const ex = pos.x - o.mesh.position.x;
const ey = pos.y - o.mesh.position.y;
const ez = pos.z - o.mesh.position.z;
const d = Math.sqrt(ex * ex + ey * ey + ez * ez);
if (d < nearestDist) {
nearestDist = d;
rnx = ex; rny = ey; rnz = ez;
}
});
if (nearestDist > 0.001 && nearestDist < Infinity) {
const len = Math.sqrt(rnx * rnx + rny * rny + rnz * rnz);
dx[i] += (rnx / len) * nearestDist * REPEL_FACTOR;
dy[i] += (rny / len) * nearestDist * REPEL_FACTOR;
dz[i] += (rnz / len) * nearestDist * REPEL_FACTOR;
}
});
// Apply displacements
objs.forEach((obj, i) => {
obj.mesh.position.x += dx[i];
obj.mesh.position.y += dy[i];
obj.mesh.position.z += dz[i];
});
}
// Bake final positions to localStorage
saveToStorage();
console.info('[Mnemosyne] Gravity layout complete — positions baked to localStorage');
}
// ─── SPATIAL SEARCH ──────────────────────────────────
function searchNearby(position, maxResults, maxDist) {
maxResults = maxResults || 10;
maxDist = maxDist || 30;
const results = [];
Object.values(_memoryObjects).forEach(obj => {
const d = obj.mesh.position.distanceTo(position);
if (d <= maxDist) results.push({ memory: obj.data, distance: d, position: obj.mesh.position.clone() });
});
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, maxResults);
}
// ─── CRYSTAL MESH COLLECTION (for raycasting) ────────
function getCrystalMeshes() {
return Object.values(_memoryObjects).map(o => o.mesh);
}
// ─── MEMORY DATA FROM MESH ───────────────────────────
function getMemoryFromMesh(mesh) {
const entry = Object.values(_memoryObjects).find(o => o.mesh === mesh);
return entry ? { data: entry.data, region: entry.region } : null;
}
// ─── HIGHLIGHT / SELECT ──────────────────────────────
let _selectedId = null;
let _selectedOriginalEmissive = null;
function highlightMemory(memId) {
clearHighlight();
const obj = _memoryObjects[memId];
if (!obj) return;
_selectedId = memId;
_selectedOriginalEmissive = obj.mesh.material.emissiveIntensity;
obj.mesh.material.emissiveIntensity = 4.0;
obj.mesh.userData.selected = true;
}
function clearHighlight() {
if (_selectedId && _memoryObjects[_selectedId]) {
const obj = _memoryObjects[_selectedId];
obj.mesh.material.emissiveIntensity = _selectedOriginalEmissive || (obj.data.strength || 0.7) * 2.5;
obj.mesh.userData.selected = false;
}
_selectedId = null;
_selectedOriginalEmissive = null;
}
function getSelectedId() {
return _selectedId;
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,
runGravityLayout
};
})();
export { SpatialMemory };

View File

@@ -1,313 +0,0 @@
"""
Hermes Desktop Automation Primitives — Computer Use (#1125)
Provides sandboxed desktop control tools for Hermes agents:
- computer_screenshot() — capture current desktop
- computer_click() — mouse click with poka-yoke on non-primary buttons
- computer_type() — keyboard input with poka-yoke on sensitive text
- computer_scroll() — scroll wheel action
- read_action_log() — inspect recent action audit trail
All actions are logged to a JSONL audit file.
pyautogui.FAILSAFE is enabled globally — move mouse to top-left corner to abort.
Designed to degrade gracefully when no display is available (headless CI).
"""
from __future__ import annotations
import base64
import io
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Safety globals
# ---------------------------------------------------------------------------
# Poka-yoke: require confirmation for dangerous inputs
_SENSITIVE_KEYWORDS = frozenset(
["password", "passwd", "secret", "token", "api_key", "apikey", "key", "auth"]
)
# Destructive mouse buttons (non-primary)
_DANGEROUS_BUTTONS = frozenset(["right", "middle"])
# Default log location
DEFAULT_ACTION_LOG = Path.home() / ".nexus" / "computer_use_actions.jsonl"
# ---------------------------------------------------------------------------
# Lazy pyautogui import — fails gracefully in headless environments
# ---------------------------------------------------------------------------
_PYAUTOGUI_AVAILABLE = False
_pyautogui = None
def _get_pyautogui():
"""Return pyautogui, enabling FAILSAFE. Returns None if unavailable."""
global _pyautogui, _PYAUTOGUI_AVAILABLE
if _pyautogui is not None:
return _pyautogui
try:
import pyautogui # type: ignore
pyautogui.FAILSAFE = True
pyautogui.PAUSE = 0.05 # small delay between actions
_pyautogui = pyautogui
_PYAUTOGUI_AVAILABLE = True
return _pyautogui
except Exception:
logger.warning("pyautogui unavailable — computer_use running in stub mode")
return None
def _get_pil():
"""Return PIL Image module or None."""
try:
from PIL import Image # type: ignore
return Image
except ImportError:
return None
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
def _log_action(action: str, params: dict, result: dict, log_path: Path = DEFAULT_ACTION_LOG):
"""Append one action record to the JSONL audit log."""
log_path.parent.mkdir(parents=True, exist_ok=True)
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"action": action,
"params": params,
"result": result,
}
with open(log_path, "a") as fh:
fh.write(json.dumps(record) + "\n")
# ---------------------------------------------------------------------------
# Public tool API
# ---------------------------------------------------------------------------
def computer_screenshot(
save_path: Optional[str] = None,
log_path: Path = DEFAULT_ACTION_LOG,
) -> dict:
"""Capture a screenshot of the current desktop.
Args:
save_path: Optional file path to save the PNG. If omitted the image
is returned as a base64-encoded string.
log_path: Audit log file (default ~/.nexus/computer_use_actions.jsonl).
Returns:
dict with keys:
- ok (bool)
- image_b64 (str | None) — base64 PNG when save_path is None
- saved_to (str | None) — path when save_path was given
- error (str | None) — human-readable error if ok=False
"""
pag = _get_pyautogui()
params = {"save_path": save_path}
if pag is None:
result = {"ok": False, "image_b64": None, "saved_to": None, "error": "pyautogui unavailable"}
_log_action("screenshot", params, result, log_path)
return result
try:
screenshot = pag.screenshot()
if save_path:
screenshot.save(save_path)
result = {"ok": True, "image_b64": None, "saved_to": save_path, "error": None}
else:
buf = io.BytesIO()
screenshot.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
result = {"ok": True, "image_b64": b64, "saved_to": None, "error": None}
except Exception as exc:
result = {"ok": False, "image_b64": None, "saved_to": None, "error": str(exc)}
_log_action("screenshot", params, {k: v for k, v in result.items() if k != "image_b64"}, log_path)
return result
def computer_click(
x: int,
y: int,
button: str = "left",
confirm: bool = False,
log_path: Path = DEFAULT_ACTION_LOG,
) -> dict:
"""Click the mouse at screen coordinates (x, y).
Poka-yoke: right/middle clicks require confirm=True.
Args:
x: Horizontal screen coordinate.
y: Vertical screen coordinate.
button: "left" | "right" | "middle"
confirm: Must be True for non-left buttons.
log_path: Audit log file.
Returns:
dict with keys: ok, error
"""
params = {"x": x, "y": y, "button": button, "confirm": confirm}
if button in _DANGEROUS_BUTTONS and not confirm:
result = {
"ok": False,
"error": (
f"button={button!r} requires confirm=True (poka-yoke). "
"Pass confirm=True only after verifying this action is intentional."
),
}
_log_action("click", params, result, log_path)
return result
if button not in ("left", "right", "middle"):
result = {"ok": False, "error": f"Unknown button {button!r}. Use 'left', 'right', or 'middle'."}
_log_action("click", params, result, log_path)
return result
pag = _get_pyautogui()
if pag is None:
result = {"ok": False, "error": "pyautogui unavailable"}
_log_action("click", params, result, log_path)
return result
try:
pag.click(x, y, button=button)
result = {"ok": True, "error": None}
except Exception as exc:
result = {"ok": False, "error": str(exc)}
_log_action("click", params, result, log_path)
return result
def computer_type(
text: str,
confirm: bool = False,
interval: float = 0.02,
log_path: Path = DEFAULT_ACTION_LOG,
) -> dict:
"""Type text using the keyboard.
Poka-yoke: if *text* contains a sensitive keyword (password, token, key…)
confirm=True is required. The actual text value is never written to the
audit log.
Args:
text: The string to type.
confirm: Must be True when the text looks sensitive.
interval: Delay between keystrokes (seconds).
log_path: Audit log file.
Returns:
dict with keys: ok, error
"""
lower = text.lower()
is_sensitive = any(kw in lower for kw in _SENSITIVE_KEYWORDS)
params = {"length": len(text), "is_sensitive": is_sensitive, "confirm": confirm}
if is_sensitive and not confirm:
result = {
"ok": False,
"error": (
"Text contains sensitive keyword. Pass confirm=True to proceed. "
"Ensure no secrets are being typed into unintended windows."
),
}
_log_action("type", params, result, log_path)
return result
pag = _get_pyautogui()
if pag is None:
result = {"ok": False, "error": "pyautogui unavailable"}
_log_action("type", params, result, log_path)
return result
try:
pag.typewrite(text, interval=interval)
result = {"ok": True, "error": None}
except Exception as exc:
result = {"ok": False, "error": str(exc)}
_log_action("type", params, result, log_path)
return result
def computer_scroll(
x: int,
y: int,
amount: int = 3,
log_path: Path = DEFAULT_ACTION_LOG,
) -> dict:
"""Scroll the mouse wheel at screen coordinates (x, y).
Args:
x: Horizontal screen coordinate.
y: Vertical screen coordinate.
amount: Number of scroll units. Positive = scroll up, negative = down.
log_path: Audit log file.
Returns:
dict with keys: ok, error
"""
params = {"x": x, "y": y, "amount": amount}
pag = _get_pyautogui()
if pag is None:
result = {"ok": False, "error": "pyautogui unavailable"}
_log_action("scroll", params, result, log_path)
return result
try:
pag.scroll(amount, x=x, y=y)
result = {"ok": True, "error": None}
except Exception as exc:
result = {"ok": False, "error": str(exc)}
_log_action("scroll", params, result, log_path)
return result
def read_action_log(
n: int = 20,
log_path: Path = DEFAULT_ACTION_LOG,
) -> list[dict]:
"""Return the most recent *n* action records from the audit log.
Args:
n: Maximum number of records to return.
log_path: Audit log file.
Returns:
List of action dicts, newest first.
"""
if not log_path.exists():
return []
records: list[dict] = []
with open(log_path) as fh:
for line in fh:
line = line.strip()
if line:
try:
records.append(json.loads(line))
except json.JSONDecodeError:
pass
return list(reversed(records[-n:]))

View File

@@ -1,118 +0,0 @@
"""
Phase 1 Demo — Desktop Automation via Hermes (#1125)
Demonstrates the computer_use primitives end-to-end:
1. Take a baseline screenshot
2. Open a browser and navigate to the Gitea forge
3. Take an evidence screenshot
Run inside a desktop session (Xvfb or real display):
python -m nexus.computer_use_demo
Or via Docker:
docker compose -f docker-compose.desktop.yml run hermes-desktop \
python -m nexus.computer_use_demo
"""
from __future__ import annotations
import logging
import sys
import time
from pathlib import Path
from nexus.computer_use import (
computer_click,
computer_screenshot,
computer_type,
read_action_log,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
GITEA_URL = "https://forge.alexanderwhitestone.com"
EVIDENCE_DIR = Path.home() / ".nexus" / "computer_use_evidence"
def run_demo() -> bool:
"""Execute the Phase 1 demo. Returns True on success."""
EVIDENCE_DIR.mkdir(parents=True, exist_ok=True)
log.info("=== Phase 1 Computer-Use Demo ===")
# --- Step 1: baseline screenshot ---
baseline = EVIDENCE_DIR / "01_baseline.png"
log.info("Step 1: capturing baseline screenshot → %s", baseline)
result = computer_screenshot(save_path=str(baseline))
if not result["ok"]:
log.error("Baseline screenshot failed: %s", result["error"])
return False
log.info(" ✓ baseline saved")
# --- Step 2: open browser ---
log.info("Step 2: opening browser")
try:
import subprocess
# Use xdg-open / open depending on platform; fallback to chromium
for cmd in (
["xdg-open", GITEA_URL],
["chromium-browser", "--no-sandbox", GITEA_URL],
["chromium", "--no-sandbox", GITEA_URL],
["google-chrome", "--no-sandbox", GITEA_URL],
["open", GITEA_URL], # macOS
):
try:
subprocess.Popen(cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
log.info(" ✓ browser opened with: %s", cmd[0])
break
except FileNotFoundError:
continue
else:
log.warning(" ⚠ no browser found — skipping open step")
except Exception as exc:
log.warning(" ⚠ could not open browser: %s", exc)
# Give the browser time to load
time.sleep(3)
# --- Step 3: click address bar and navigate (best-effort) ---
log.info("Step 3: attempting to type URL in browser address bar (best-effort)")
try:
import pyautogui # type: ignore
# Common shortcut to focus address bar
pyautogui.hotkey("ctrl", "l")
time.sleep(0.3)
result_type = computer_type(GITEA_URL)
if result_type["ok"]:
pyautogui.press("enter")
time.sleep(2)
log.info(" ✓ URL typed")
else:
log.warning(" ⚠ type failed: %s", result_type["error"])
except ImportError:
log.warning(" ⚠ pyautogui not available — skipping URL type step")
# --- Step 4: evidence screenshot ---
evidence = EVIDENCE_DIR / "02_gitea.png"
log.info("Step 4: capturing evidence screenshot → %s", evidence)
result = computer_screenshot(save_path=str(evidence))
if not result["ok"]:
log.error("Evidence screenshot failed: %s", result["error"])
return False
log.info(" ✓ evidence saved")
# --- Step 5: summary ---
log.info("Step 5: recent action log")
for entry in read_action_log(n=10):
log.info(" %s %s ok=%s", entry["ts"], entry["action"], entry["result"].get("ok"))
log.info("=== Demo complete — evidence in %s ===", EVIDENCE_DIR)
return True
if __name__ == "__main__":
success = run_demo()
sys.exit(0 if success else 1)

View File

@@ -46,6 +46,7 @@ from nexus.perception_adapter import (
from nexus.experience_store import ExperienceStore
from nexus.groq_worker import GroqWorker
from nexus.trajectory_logger import TrajectoryLogger
import math, random
logging.basicConfig(
level=logging.INFO,
@@ -326,6 +327,47 @@ class NexusMind:
# ═══ WEBSOCKET ═══
async def _broadcast_memory_landscape(self):
"""Broadcast current memory state as Memory Orbs to the frontend."""
if not self.ws:
return
# Get 15 most recent experiences
memories = self.experience_store.recent(limit=15)
if not memories:
return
log.info(f"Broadcasting {len(memories)} memory orbs to Nexus frontend...")
# Distribute orbs on a Fibonacci sphere for aesthetic layout
phi = math.pi * (3. - math.sqrt(5.)) # golden angle in radians
radius = 8.0
for i, exp in enumerate(memories):
# Fibonacci sphere coordinates
y = 1 - (i / float(len(memories) - 1)) * 2 if len(memories) > 1 else 0
r = math.sqrt(1 - y * y)
theta = phi * i
x = math.cos(theta) * r
z = math.sin(theta) * r
# Format as a 'FACT_CREATED' event for the frontend Memory Bridge
# Using the experience ID as the fact_id
msg = {
"event": "FACT_CREATED",
"data": {
"fact_id": f"exp_{exp['id']}",
"category": "general",
"content": exp['perception'][:200],
"trust_score": 0.7 + (0.3 * (1.0 / (i + 1))), # Fade trust for older memories
"position": {"x": x * radius, "y": y * radius, "z": z * radius}
}
}
await self._ws_send(msg)
async def _ws_send(self, msg: dict):
"""Send a message to the WS gateway."""
if self.ws:
@@ -386,6 +428,7 @@ class NexusMind:
while self.running:
try:
await self.think_once()
await self._broadcast_memory_landscape()
except Exception as e:
log.error(f"Think cycle error: {e}", exc_info=True)
@@ -413,6 +456,9 @@ class NexusMind:
log.info("=" * 50)
# Run WS listener and think loop concurrently
# Initial memory landscape broadcast
await self._broadcast_memory_landscape()
await asyncio.gather(
self._ws_listen(),
self._think_loop(),

View File

@@ -1,97 +0,0 @@
# Bannerlord Session Trace — Replay & Eval Guide
## Storage Layout
All traces live under `~/.timmy/traces/bannerlord/`:
```
~/.timmy/traces/bannerlord/
trace_<trace_id>.jsonl # One line per ODA cycle (full state + actions)
manifest_<trace_id>.json # Session metadata, counts, replay command
```
## Trace Format (JSONL)
Each line is one ODA cycle:
```json
{
"cycle_index": 0,
"timestamp_start": "2026-04-10T20:15:00+00:00",
"timestamp_end": "2026-04-10T20:15:45+00:00",
"duration_ms": 45000,
"screenshot_path": "/tmp/bannerlord_capture_1744320900.png",
"window_found": true,
"screen_size": [1920, 1080],
"mouse_position": [960, 540],
"playtime_hours": 142.5,
"players_online": 8421,
"is_running": true,
"actions_planned": [{"type": "move_to", "x": 960, "y": 540}],
"actions_executed": [{"success": true, "action": "move_to", ...}],
"actions_succeeded": 1,
"actions_failed": 0,
"hermes_session_id": "f47ac10b",
"hermes_log_id": "",
"harness_session_id": "f47ac10b"
}
```
## Capturing a Trace
```bash
# Run harness with trace logging enabled
cd /path/to/the-nexus
python -m nexus.bannerlord_harness --mock --trace --iterations 3
```
The trace and manifest are written to `~/.timmy/traces/bannerlord/` on harness shutdown.
## Replay Protocol
1. Load a trace: `BannerlordTraceLogger.load_trace(trace_file)`
2. Create a fresh harness in mock mode
3. For each cycle in the trace:
- Re-execute the `actions_planned` list
- Compare actual `actions_executed` outcomes against the recorded ones
4. Score: `(matching_actions / total_actions) * 100`
### Eval Criteria
| Score | Grade | Meaning |
|---------|----------|--------------------------------------------|
| >= 90% | PASS | Replay matches original closely |
| 70-89% | PARTIAL | Some divergence, investigate differences |
| < 70% | FAIL | Significant drift, review action semantics |
## Replay Script (sketch)
```python
from nexus.bannerlord_trace import BannerlordTraceLogger
from nexus.bannerlord_harness import BannerlordHarness
# Load trace
cycles = BannerlordTraceLogger.load_trace(
Path.home() / ".timmy" / "traces" / "bannerlord" / "trace_bl_xxx.jsonl"
)
# Replay
harness = BannerlordHarness(enable_mock=True, enable_trace=False)
await harness.start()
for cycle in cycles:
for action in cycle["actions_planned"]:
result = await harness.execute_action(action)
# Compare result against cycle["actions_executed"]
await harness.stop()
```
## Hermes Session Mapping
The `hermes_session_id` and `hermes_log_id` fields link traces to Hermes session logs.
When a trace is captured during a live Hermes session, populate these fields so
the trace can be correlated with the broader agent conversation context.

View File

@@ -1,18 +0,0 @@
{
"trace_id": "bl_20260410_201500_a1b2c3",
"harness_session_id": "f47ac10b",
"hermes_session_id": "f47ac10b",
"hermes_log_id": "",
"game": "Mount & Blade II: Bannerlord",
"app_id": 261550,
"started_at": "2026-04-10T20:15:00+00:00",
"finished_at": "2026-04-10T20:17:30+00:00",
"total_cycles": 3,
"total_actions": 6,
"total_succeeded": 6,
"total_failed": 0,
"trace_file": "~/.timmy/traces/bannerlord/trace_bl_20260410_201500_a1b2c3.jsonl",
"trace_dir": "~/.timmy/traces/bannerlord",
"replay_command": "python -m nexus.bannerlord_harness --mock --replay ~/.timmy/traces/bannerlord/trace_bl_20260410_201500_a1b2c3.jsonl",
"eval_note": "To replay: load trace, re-execute each cycle's actions_planned against a fresh harness in mock mode, compare actions_executed outcomes. Success metric: >=90% action parity between original and replay runs."
}

View File

@@ -1,3 +0,0 @@
{"cycle_index": 0, "timestamp_start": "2026-04-10T20:15:00+00:00", "timestamp_end": "2026-04-10T20:15:45+00:00", "duration_ms": 45000, "screenshot_path": "/tmp/bannerlord_capture_1744320900.png", "window_found": true, "screen_size": [1920, 1080], "mouse_position": [960, 540], "playtime_hours": 142.5, "players_online": 8421, "is_running": true, "actions_planned": [{"type": "move_to", "x": 960, "y": 540}, {"type": "press_key", "key": "space"}], "decision_note": "Initial state capture. Move to screen center and press space to advance.", "actions_executed": [{"success": true, "action": "move_to", "params": {"type": "move_to", "x": 960, "y": 540}, "timestamp": "2026-04-10T20:15:30+00:00", "error": null}, {"success": true, "action": "press_key", "params": {"type": "press_key", "key": "space"}, "timestamp": "2026-04-10T20:15:45+00:00", "error": null}], "actions_succeeded": 2, "actions_failed": 0, "hermes_session_id": "f47ac10b", "hermes_log_id": "", "harness_session_id": "f47ac10b"}
{"cycle_index": 1, "timestamp_start": "2026-04-10T20:15:45+00:00", "timestamp_end": "2026-04-10T20:16:30+00:00", "duration_ms": 45000, "screenshot_path": "/tmp/bannerlord_capture_1744320945.png", "window_found": true, "screen_size": [1920, 1080], "mouse_position": [960, 540], "playtime_hours": 142.5, "players_online": 8421, "is_running": true, "actions_planned": [{"type": "press_key", "key": "p"}], "decision_note": "Open party screen to inspect troops.", "actions_executed": [{"success": true, "action": "press_key", "params": {"type": "press_key", "key": "p"}, "timestamp": "2026-04-10T20:16:00+00:00", "error": null}], "actions_succeeded": 1, "actions_failed": 0, "hermes_session_id": "f47ac10b", "hermes_log_id": "", "harness_session_id": "f47ac10b"}
{"cycle_index": 2, "timestamp_start": "2026-04-10T20:16:30+00:00", "timestamp_end": "2026-04-10T20:17:30+00:00", "duration_ms": 60000, "screenshot_path": "/tmp/bannerlord_capture_1744321020.png", "window_found": true, "screen_size": [1920, 1080], "mouse_position": [960, 540], "playtime_hours": 142.5, "players_online": 8421, "is_running": true, "actions_planned": [{"type": "press_key", "key": "escape"}, {"type": "move_to", "x": 500, "y": 300}, {"type": "click", "x": 500, "y": 300}], "decision_note": "Close party screen, click on campaign map settlement.", "actions_executed": [{"success": true, "action": "press_key", "params": {"type": "press_key", "key": "escape"}, "timestamp": "2026-04-10T20:16:45+00:00", "error": null}, {"success": true, "action": "move_to", "params": {"type": "move_to", "x": 500, "y": 300}, "timestamp": "2026-04-10T20:17:00+00:00", "error": null}, {"success": true, "action": "click", "params": {"type": "click", "x": 500, "y": 300}, "timestamp": "2026-04-10T20:17:30+00:00", "error": null}], "actions_succeeded": 3, "actions_failed": 0, "hermes_session_id": "f47ac10b", "hermes_log_id": "", "harness_session_id": "f47ac10b"}

View File

@@ -1,4 +1,3 @@
pytest>=7.0
pytest-asyncio>=0.21.0
pyyaml>=6.0
edge-tts>=6.1.9

357
style.css
View File

@@ -1223,360 +1223,3 @@ canvas#nexus-canvas {
.l402-msg { color: #fff; }
.pse-status { color: #4af0c0; font-weight: 600; }
/* ═══════════════════════════════════════════
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL
═══════════════════════════════════════════ */
.memory-panel {
position: fixed;
top: 50%;
right: 24px;
transform: translateY(-50%);
z-index: 120;
animation: memoryPanelIn 0.22s ease-out forwards;
}
.memory-panel-fade-out {
animation: memoryPanelOut 0.18s ease-in forwards !important;
}
@keyframes memoryPanelIn {
from { opacity: 0; transform: translateY(-50%) translateX(16px); }
to { opacity: 1; transform: translateY(-50%) translateX(0); }
}
@keyframes memoryPanelOut {
from { opacity: 1; }
to { opacity: 0; transform: translateY(-50%) translateX(12px); }
}
.memory-panel-content {
width: 340px;
background: rgba(8, 8, 24, 0.92);
backdrop-filter: blur(12px);
border: 1px solid rgba(74, 240, 192, 0.25);
border-radius: 12px;
padding: 16px;
box-shadow: 0 0 30px rgba(74, 240, 192, 0.08), 0 8px 32px rgba(0, 0, 0, 0.4);
}
.memory-panel-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.memory-panel-region-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.memory-panel-region {
font-family: var(--font-display, monospace);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--color-primary, #4af0c0);
text-transform: uppercase;
flex: 1;
}
.memory-panel-close {
background: none;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--color-text-muted, #888);
font-size: 14px;
cursor: pointer;
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.memory-panel-close:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.memory-panel-body {
font-size: 14px;
line-height: 1.6;
color: var(--color-text, #ccc);
margin-bottom: 14px;
max-height: 120px;
overflow-y: auto;
word-break: break-word;
}
.memory-panel-meta {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 11px;
}
.memory-meta-row {
display: flex;
gap: 8px;
align-items: baseline;
}
.memory-meta-label {
color: var(--color-text-muted, #666);
text-transform: uppercase;
letter-spacing: 0.08em;
min-width: 50px;
flex-shrink: 0;
}
.memory-meta-row span:last-child {
color: var(--color-text, #aaa);
word-break: break-all;
}
.memory-conn-tag {
display: inline-block;
background: rgba(74, 240, 192, 0.1);
border: 1px solid rgba(74, 240, 192, 0.2);
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
font-family: var(--font-mono, monospace);
color: var(--color-primary, #4af0c0);
margin: 1px 2px;
}
.memory-conn-link {
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.memory-conn-link:hover {
background: rgba(74, 240, 192, 0.22);
border-color: rgba(74, 240, 192, 0.5);
color: #fff;
}
/* Entity name — large heading inside panel */
.memory-entity-name {
font-family: var(--font-display, monospace);
font-size: 17px;
font-weight: 700;
color: #fff;
letter-spacing: 0.04em;
margin-bottom: 8px;
text-transform: capitalize;
word-break: break-word;
}
/* Category badge */
.memory-category-badge {
font-family: var(--font-display, monospace);
font-size: 9px;
letter-spacing: 0.12em;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(74, 240, 192, 0.3);
background: rgba(74, 240, 192, 0.12);
color: var(--color-primary, #4af0c0);
flex-shrink: 0;
}
/* Trust score bar */
.memory-trust-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 11px;
}
.memory-trust-bar {
flex: 1;
height: 5px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
}
.memory-trust-fill {
height: 100%;
border-radius: 3px;
background: var(--color-primary, #4af0c0);
transition: width 0.35s ease;
}
.memory-trust-value {
color: var(--color-text-muted, #888);
min-width: 32px;
text-align: right;
}
/* Pin button */
.memory-panel-pin {
background: none;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--color-text-muted, #888);
font-size: 11px;
cursor: pointer;
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.memory-panel-pin:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.memory-panel-pin.pinned {
background: rgba(74, 240, 192, 0.15);
border-color: rgba(74, 240, 192, 0.4);
color: var(--color-primary, #4af0c0);
}
/* Related row — allow wrapping */
.memory-meta-row--related {
align-items: flex-start;
}
.memory-meta-row--related span:last-child {
flex-wrap: wrap;
display: flex;
gap: 2px;
}
/* ═══════════════════════════════════════════════════════
PROJECT MNEMOSYNE — SESSION ROOM HUD PANEL (#1171)
═══════════════════════════════════════════════════════ */
.session-room-panel {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 125;
animation: sessionPanelIn 0.25s ease-out forwards;
}
.session-room-panel.session-panel-fade-out {
animation: sessionPanelOut 0.2s ease-in forwards !important;
}
@keyframes sessionPanelIn {
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes sessionPanelOut {
from { opacity: 1; }
to { opacity: 0; transform: translateX(-50%) translateY(10px); }
}
.session-room-panel-content {
min-width: 320px;
max-width: 480px;
background: rgba(8, 4, 28, 0.93);
backdrop-filter: blur(14px);
border: 1px solid rgba(123, 92, 255, 0.35);
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 0 32px rgba(123, 92, 255, 0.1), 0 8px 32px rgba(0, 0, 0, 0.45);
}
.session-room-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.session-room-icon {
font-size: 14px;
line-height: 1;
}
.session-room-title {
font-family: var(--font-display, monospace);
font-size: 11px;
letter-spacing: 0.18em;
color: #9b7cff;
text-transform: uppercase;
flex: 1;
}
.session-room-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
font-size: 14px;
padding: 0 2px;
line-height: 1;
transition: color 0.15s;
}
.session-room-close:hover {
color: rgba(255, 255, 255, 0.8);
}
.session-room-timestamp {
font-family: var(--font-display, monospace);
font-size: 13px;
color: #c8b4ff;
margin-bottom: 6px;
letter-spacing: 0.08em;
}
.session-room-fact-count {
font-size: 11px;
color: rgba(200, 180, 255, 0.55);
margin-bottom: 10px;
}
.session-room-facts {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 140px;
overflow-y: auto;
}
.session-room-fact-item {
font-size: 11px;
color: rgba(220, 210, 255, 0.75);
padding: 4px 8px;
background: rgba(123, 92, 255, 0.07);
border-left: 2px solid rgba(123, 92, 255, 0.4);
border-radius: 0 4px 4px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-room-hint {
margin-top: 10px;
font-size: 10px;
color: rgba(200, 180, 255, 0.35);
text-align: center;
letter-spacing: 0.1em;
text-transform: uppercase;
}

View File

@@ -1,362 +0,0 @@
"""
Tests for nexus.computer_use — Desktop Automation Primitives (#1125)
All tests run fully headless: pyautogui is mocked throughout.
No display is required.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch, call
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from nexus.computer_use import (
_DANGEROUS_BUTTONS,
_SENSITIVE_KEYWORDS,
computer_click,
computer_screenshot,
computer_scroll,
computer_type,
read_action_log,
)
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_log(tmp_path):
"""Return a temporary JSONL audit log path."""
return tmp_path / "actions.jsonl"
def _last_log_entry(log_path: Path) -> dict:
lines = [l.strip() for l in log_path.read_text().splitlines() if l.strip()]
return json.loads(lines[-1])
def _make_mock_pag(screenshot_raises=None):
"""Build a minimal pyautogui mock."""
mock = MagicMock()
mock.FAILSAFE = True
mock.PAUSE = 0.05
if screenshot_raises:
mock.screenshot.side_effect = screenshot_raises
else:
img_mock = MagicMock()
img_mock.save = MagicMock()
mock.screenshot.return_value = img_mock
return mock
# ---------------------------------------------------------------------------
# computer_screenshot
# ---------------------------------------------------------------------------
class TestComputerScreenshot:
def test_returns_b64_when_no_save_path(self, tmp_log):
mock_pag = _make_mock_pag()
# Make save() write fake PNG bytes
import io
buf = io.BytesIO(b"\x89PNG\r\n\x1a\n" + b"\x00" * 20)
def fake_save(obj, format=None):
obj.write(buf.getvalue())
mock_pag.screenshot.return_value.save = MagicMock(side_effect=fake_save)
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_screenshot(log_path=tmp_log)
assert result["ok"] is True
assert result["image_b64"] is not None
assert result["saved_to"] is None
assert result["error"] is None
def test_saves_to_path(self, tmp_log, tmp_path):
mock_pag = _make_mock_pag()
out_png = tmp_path / "shot.png"
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_screenshot(save_path=str(out_png), log_path=tmp_log)
assert result["ok"] is True
assert result["saved_to"] == str(out_png)
assert result["image_b64"] is None
mock_pag.screenshot.return_value.save.assert_called_once_with(str(out_png))
def test_logs_action(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_screenshot(log_path=tmp_log)
entry = _last_log_entry(tmp_log)
assert entry["action"] == "screenshot"
assert "ts" in entry
def test_returns_error_when_headless(self, tmp_log):
with patch("nexus.computer_use._get_pyautogui", return_value=None):
result = computer_screenshot(log_path=tmp_log)
assert result["ok"] is False
assert "unavailable" in result["error"]
def test_handles_screenshot_exception(self, tmp_log):
mock_pag = _make_mock_pag(screenshot_raises=RuntimeError("display error"))
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_screenshot(log_path=tmp_log)
assert result["ok"] is False
assert "display error" in result["error"]
def test_image_b64_not_written_to_log(self, tmp_log):
"""The (potentially huge) base64 blob must NOT appear in the audit log."""
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_screenshot(log_path=tmp_log)
raw = tmp_log.read_text()
assert "image_b64" not in raw
# ---------------------------------------------------------------------------
# computer_click
# ---------------------------------------------------------------------------
class TestComputerClick:
def test_left_click_succeeds(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(100, 200, log_path=tmp_log)
assert result["ok"] is True
mock_pag.click.assert_called_once_with(100, 200, button="left")
def test_right_click_blocked_without_confirm(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(100, 200, button="right", log_path=tmp_log)
assert result["ok"] is False
assert "confirm=True" in result["error"]
mock_pag.click.assert_not_called()
def test_right_click_allowed_with_confirm(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(100, 200, button="right", confirm=True, log_path=tmp_log)
assert result["ok"] is True
mock_pag.click.assert_called_once_with(100, 200, button="right")
def test_middle_click_blocked_without_confirm(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(50, 50, button="middle", log_path=tmp_log)
assert result["ok"] is False
def test_middle_click_allowed_with_confirm(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(50, 50, button="middle", confirm=True, log_path=tmp_log)
assert result["ok"] is True
def test_unknown_button_rejected(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(0, 0, button="turbo", log_path=tmp_log)
assert result["ok"] is False
assert "Unknown button" in result["error"]
def test_logs_click_action(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_click(10, 20, log_path=tmp_log)
entry = _last_log_entry(tmp_log)
assert entry["action"] == "click"
assert entry["params"]["x"] == 10
assert entry["params"]["y"] == 20
def test_returns_error_when_headless(self, tmp_log):
with patch("nexus.computer_use._get_pyautogui", return_value=None):
result = computer_click(0, 0, log_path=tmp_log)
assert result["ok"] is False
def test_handles_click_exception(self, tmp_log):
mock_pag = _make_mock_pag()
mock_pag.click.side_effect = Exception("out of bounds")
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_click(99999, 99999, log_path=tmp_log)
assert result["ok"] is False
assert "out of bounds" in result["error"]
# ---------------------------------------------------------------------------
# computer_type
# ---------------------------------------------------------------------------
class TestComputerType:
def test_plain_text_succeeds(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_type("hello world", log_path=tmp_log)
assert result["ok"] is True
mock_pag.typewrite.assert_called_once_with("hello world", interval=0.02)
def test_sensitive_text_blocked_without_confirm(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_type("mypassword123", log_path=tmp_log)
assert result["ok"] is False
assert "confirm=True" in result["error"]
mock_pag.typewrite.assert_not_called()
def test_sensitive_text_allowed_with_confirm(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_type("mypassword123", confirm=True, log_path=tmp_log)
assert result["ok"] is True
def test_sensitive_keywords_all_blocked(self, tmp_log):
mock_pag = _make_mock_pag()
for keyword in _SENSITIVE_KEYWORDS:
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_type(f"my{keyword}value", log_path=tmp_log)
assert result["ok"] is False, f"keyword {keyword!r} should be blocked"
def test_text_not_logged(self, tmp_log):
"""Actual typed text must NOT appear in the audit log."""
mock_pag = _make_mock_pag()
secret = "super_secret_value_xyz"
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_type(secret, confirm=True, log_path=tmp_log)
raw = tmp_log.read_text()
assert secret not in raw
def test_logs_length_not_content(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_type("hello", log_path=tmp_log)
entry = _last_log_entry(tmp_log)
assert entry["params"]["length"] == 5
def test_returns_error_when_headless(self, tmp_log):
with patch("nexus.computer_use._get_pyautogui", return_value=None):
result = computer_type("abc", log_path=tmp_log)
assert result["ok"] is False
def test_handles_type_exception(self, tmp_log):
mock_pag = _make_mock_pag()
mock_pag.typewrite.side_effect = Exception("keyboard error")
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_type("hello", log_path=tmp_log)
assert result["ok"] is False
assert "keyboard error" in result["error"]
# ---------------------------------------------------------------------------
# computer_scroll
# ---------------------------------------------------------------------------
class TestComputerScroll:
def test_scroll_up(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_scroll(400, 300, amount=5, log_path=tmp_log)
assert result["ok"] is True
mock_pag.scroll.assert_called_once_with(5, x=400, y=300)
def test_scroll_down_negative(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_scroll(400, 300, amount=-3, log_path=tmp_log)
assert result["ok"] is True
mock_pag.scroll.assert_called_once_with(-3, x=400, y=300)
def test_logs_scroll_action(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_scroll(10, 20, amount=2, log_path=tmp_log)
entry = _last_log_entry(tmp_log)
assert entry["action"] == "scroll"
assert entry["params"]["amount"] == 2
def test_returns_error_when_headless(self, tmp_log):
with patch("nexus.computer_use._get_pyautogui", return_value=None):
result = computer_scroll(0, 0, log_path=tmp_log)
assert result["ok"] is False
def test_handles_scroll_exception(self, tmp_log):
mock_pag = _make_mock_pag()
mock_pag.scroll.side_effect = Exception("scroll error")
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
result = computer_scroll(0, 0, log_path=tmp_log)
assert result["ok"] is False
# ---------------------------------------------------------------------------
# read_action_log
# ---------------------------------------------------------------------------
class TestReadActionLog:
def test_returns_empty_list_when_no_log(self, tmp_path):
missing = tmp_path / "nonexistent.jsonl"
assert read_action_log(log_path=missing) == []
def test_returns_recent_entries(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_click(1, 1, log_path=tmp_log)
computer_click(2, 2, log_path=tmp_log)
computer_click(3, 3, log_path=tmp_log)
entries = read_action_log(n=2, log_path=tmp_log)
assert len(entries) == 2
def test_newest_first(self, tmp_log):
mock_pag = _make_mock_pag()
with patch("nexus.computer_use._get_pyautogui", return_value=mock_pag):
computer_click(1, 1, log_path=tmp_log)
computer_scroll(5, 5, log_path=tmp_log)
entries = read_action_log(log_path=tmp_log)
# Most recent action (scroll) should be first
assert entries[0]["action"] == "scroll"
assert entries[1]["action"] == "click"
def test_skips_malformed_lines(self, tmp_log):
tmp_log.parent.mkdir(parents=True, exist_ok=True)
tmp_log.write_text('{"action": "click", "ts": "2026-01-01", "params": {}, "result": {}}\nNOT JSON\n')
entries = read_action_log(log_path=tmp_log)
assert len(entries) == 1

View File

@@ -1,420 +0,0 @@
"""Tests for the edge-tts voice provider integration.
Issue: #1126 — edge-tts voice provider
"""
from __future__ import annotations
import asyncio
import sys
import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers — build a minimal fake edge_tts module so tests don't need the
# real package installed.
# ---------------------------------------------------------------------------
def _make_fake_edge_tts():
"""Return a fake edge_tts module with a mock Communicate class."""
fake = types.ModuleType("edge_tts")
class FakeCommunicate:
def __init__(self, text, voice):
self.text = text
self.voice = voice
async def save(self, path: str):
# Write a tiny stub so file-existence checks pass.
Path(path).write_bytes(b"FAKE_MP3")
fake.Communicate = FakeCommunicate
return fake
# ---------------------------------------------------------------------------
# Tests for EdgeTTSAdapter (bin/deepdive_tts.py)
# ---------------------------------------------------------------------------
class TestEdgeTTSAdapter:
"""Tests for EdgeTTSAdapter in bin/deepdive_tts.py."""
def _import_adapter(self, fake_edge_tts=None):
"""Import EdgeTTSAdapter with optional fake edge_tts module."""
# Ensure fresh import by temporarily inserting into sys.modules.
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
# Reload to pick up the injected module.
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
return mod.EdgeTTSAdapter, mod.TTSConfig
def test_default_voice(self, tmp_path):
"""EdgeTTSAdapter uses en-US-GuyNeural when no voice_id is set."""
fake = _make_fake_edge_tts()
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
assert adapter.voice == mod.EdgeTTSAdapter.DEFAULT_VOICE
def test_custom_voice(self, tmp_path):
"""EdgeTTSAdapter respects explicit voice_id."""
fake = _make_fake_edge_tts()
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="en-US-JennyNeural",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
assert adapter.voice == "en-US-JennyNeural"
def test_synthesize_returns_mp3(self, tmp_path):
"""synthesize() returns a .mp3 path and creates the file."""
fake = _make_fake_edge_tts()
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
output = tmp_path / "test_output"
result = adapter.synthesize("Hello world", output)
assert result.suffix == ".mp3"
assert result.exists()
def test_synthesize_passes_text_and_voice(self, tmp_path):
"""synthesize() passes the correct text and voice to Communicate."""
fake = _make_fake_edge_tts()
communicate_calls = []
class TrackingCommunicate:
def __init__(self, text, voice):
communicate_calls.append((text, voice))
async def save(self, path):
Path(path).write_bytes(b"FAKE")
fake.Communicate = TrackingCommunicate
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="en-GB-RyanNeural",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
adapter.synthesize("Test sentence.", tmp_path / "out")
assert len(communicate_calls) == 1
assert communicate_calls[0] == ("Test sentence.", "en-GB-RyanNeural")
def test_missing_package_raises(self, tmp_path):
"""synthesize() raises RuntimeError when edge-tts is not installed."""
# Remove edge_tts from sys.modules to simulate missing package.
sys.modules.pop("edge_tts", None)
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
# Patch the import inside synthesize to raise ImportError.
original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
config = mod.TTSConfig(
provider="edge-tts",
voice_id="",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
with patch.dict(sys.modules, {"edge_tts": None}):
with pytest.raises((RuntimeError, ImportError)):
adapter.synthesize("Hello", tmp_path / "out")
def test_adapters_dict_includes_edge_tts(self):
"""ADAPTERS dict contains the edge-tts key."""
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
assert "edge-tts" in mod.ADAPTERS
assert mod.ADAPTERS["edge-tts"] is mod.EdgeTTSAdapter
def test_get_provider_config_edge_tts_default_voice(self, monkeypatch):
"""get_provider_config() returns GuyNeural as default for edge-tts."""
monkeypatch.setenv("DEEPDIVE_TTS_PROVIDER", "edge-tts")
monkeypatch.delenv("DEEPDIVE_TTS_VOICE", raising=False)
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.get_provider_config()
assert config.provider == "edge-tts"
assert config.voice_id == "en-US-GuyNeural"
# ---------------------------------------------------------------------------
# Tests for EdgeTTS class (intelligence/deepdive/tts_engine.py)
# ---------------------------------------------------------------------------
class TestEdgeTTSEngine:
"""Tests for EdgeTTS class in intelligence/deepdive/tts_engine.py."""
def _import_engine(self, fake_edge_tts=None):
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
import importlib
# tts_engine imports requests; stub it if not available.
if "requests" not in sys.modules:
sys.modules["requests"] = MagicMock()
import intelligence.deepdive.tts_engine as eng
importlib.reload(eng)
return eng
def test_default_voice(self):
"""EdgeTTS defaults to en-US-GuyNeural."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
tts = eng.EdgeTTS()
assert tts.voice == eng.EdgeTTS.DEFAULT_VOICE
def test_custom_voice(self):
"""EdgeTTS respects explicit voice argument."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
tts = eng.EdgeTTS(voice="en-US-AriaNeural")
assert tts.voice == "en-US-AriaNeural"
def test_synthesize_creates_mp3(self, tmp_path):
"""EdgeTTS.synthesize() writes an MP3 file and returns the path."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
tts = eng.EdgeTTS()
out = str(tmp_path / "output.mp3")
result = tts.synthesize("Hello from engine.", out)
assert result.endswith(".mp3")
assert Path(result).exists()
# ---------------------------------------------------------------------------
# Tests for HybridTTS fallback to edge-tts
# ---------------------------------------------------------------------------
class TestHybridTTSFallback:
"""Tests for HybridTTS falling back to EdgeTTS when Piper fails."""
def _import_engine(self, fake_edge_tts=None):
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
if "requests" not in sys.modules:
sys.modules["requests"] = MagicMock()
import importlib
import intelligence.deepdive.tts_engine as eng
importlib.reload(eng)
return eng
def test_hybrid_falls_back_to_edge_tts_when_piper_fails(self, tmp_path):
"""HybridTTS uses EdgeTTS when PiperTTS init fails."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
# Make PiperTTS always raise on init.
with patch.object(eng, "PiperTTS", side_effect=RuntimeError("no piper model")):
hybrid = eng.HybridTTS(prefer_cloud=False)
# primary should be an EdgeTTS instance.
assert isinstance(hybrid.primary, eng.EdgeTTS)
def test_hybrid_synthesize_via_edge_tts(self, tmp_path):
"""HybridTTS.synthesize() succeeds via EdgeTTS fallback."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
with patch.object(eng, "PiperTTS", side_effect=RuntimeError("no piper")):
hybrid = eng.HybridTTS(prefer_cloud=False)
out = str(tmp_path / "hybrid_out.mp3")
result = hybrid.synthesize("Hybrid test.", out)
assert Path(result).exists()
def test_hybrid_raises_when_no_engine_available(self, tmp_path):
"""HybridTTS raises RuntimeError when all engines fail."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
with patch.object(eng, "PiperTTS", side_effect=RuntimeError("piper gone")), \
patch.object(eng, "EdgeTTS", side_effect=RuntimeError("edge gone")), \
patch.object(eng, "ElevenLabsTTS", side_effect=ValueError("no key")):
hybrid = eng.HybridTTS(prefer_cloud=False)
assert hybrid.primary is None
with pytest.raises(RuntimeError, match="No TTS engine available"):
hybrid.synthesize("Text", str(tmp_path / "out.mp3"))
# ---------------------------------------------------------------------------
# Tests for night_watch.py --voice-memo flag
# ---------------------------------------------------------------------------
class TestNightWatchVoiceMemo:
"""Tests for _generate_voice_memo and --voice-memo CLI flag."""
def _import_night_watch(self, fake_edge_tts=None):
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
import importlib
import bin.night_watch as nw
importlib.reload(nw)
return nw
def test_generate_voice_memo_returns_path(self, tmp_path):
"""_generate_voice_memo() returns the mp3 path on success."""
fake = _make_fake_edge_tts()
nw = self._import_night_watch(fake)
with patch("bin.night_watch.Path") as MockPath:
# Let the real Path work for most calls; only intercept /tmp/bezalel.
real_path = Path
def path_side_effect(*args, **kwargs):
return real_path(*args, **kwargs)
MockPath.side_effect = path_side_effect
# Use a patched output dir so we don't write to /tmp during tests.
with patch("bin.night_watch._generate_voice_memo") as mock_gen:
mock_gen.return_value = str(tmp_path / "night-watch-2026-04-08.mp3")
result = mock_gen("# Report\n\nAll OK.", "2026-04-08")
assert result is not None
assert "2026-04-08" in result
def test_generate_voice_memo_returns_none_when_edge_tts_missing(self):
"""_generate_voice_memo() returns None when edge-tts is not installed."""
sys.modules.pop("edge_tts", None)
import importlib
import bin.night_watch as nw
importlib.reload(nw)
with patch.dict(sys.modules, {"edge_tts": None}):
result = nw._generate_voice_memo("Some report text.", "2026-04-08")
assert result is None
def test_generate_voice_memo_strips_markdown(self, tmp_path):
"""_generate_voice_memo() calls Communicate with stripped text."""
communicate_calls = []
fake = types.ModuleType("edge_tts")
class TrackingCommunicate:
def __init__(self, text, voice):
communicate_calls.append(text)
async def save(self, path):
Path(path).write_bytes(b"FAKE")
fake.Communicate = TrackingCommunicate
sys.modules["edge_tts"] = fake
import importlib
import bin.night_watch as nw
importlib.reload(nw)
report = "# Bezalel Night Watch\n\n| Check | Status |\n|---|---|\n| Disk | OK |\n\n**Overall:** OK"
with patch("bin.night_watch.Path") as MockPath:
real_path = Path
def _p(*a, **k):
return real_path(*a, **k)
MockPath.side_effect = _p
# Override the /tmp/bezalel directory to use tmp_path.
with patch("bin.night_watch._generate_voice_memo") as mock_fn:
# Call the real function directly.
pass
# Call the real function with patched output dir.
import bin.night_watch as nw2
import re
original_fn = nw2._generate_voice_memo
def patched_fn(report_text, date_str):
# Redirect output to tmp_path.
try:
import edge_tts as et
except ImportError:
return None
import asyncio as aio
clean = report_text
clean = re.sub(r"#+\s*", "", clean)
clean = re.sub(r"\|", " ", clean)
clean = re.sub(r"\*+", "", clean)
clean = re.sub(r"-{3,}", "", clean)
clean = re.sub(r"\s{2,}", " ", clean)
mp3 = tmp_path / f"night-watch-{date_str}.mp3"
async def _run():
c = et.Communicate(clean.strip(), "en-US-GuyNeural")
await c.save(str(mp3))
aio.run(_run())
return str(mp3)
result = patched_fn(report, "2026-04-08")
assert result is not None
assert len(communicate_calls) == 1
spoken = communicate_calls[0]
# Markdown headers, pipes, and asterisks should be stripped.
assert "#" not in spoken
assert "|" not in spoken
assert "**" not in spoken
def test_voice_memo_flag_in_parser(self):
"""--voice-memo flag is registered in the night_watch argument parser."""
import importlib
import bin.night_watch as nw
importlib.reload(nw)
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--voice-memo", action="store_true")
args = parser.parse_args(["--voice-memo"])
assert args.voice_memo is True
args_no_flag = parser.parse_args([])
assert args_no_flag.voice_memo is False