Compare commits
26 Commits
feat/mnemo
...
mimo/code/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2ac5cd1f | ||
| cc4af009c7 | |||
| 089b06b6f8 | |||
| 8beae5ecc1 | |||
| e2edfd3318 | |||
| 8e18fa5311 | |||
| 1bf2af15a0 | |||
| 4095946749 | |||
|
|
845e2f2ced | ||
|
|
60af11ec2f | ||
| c387708892 | |||
| 8694c0f5ad | |||
| c3547196d8 | |||
| 87bfe9b332 | |||
| a0964a2fbf | |||
| 1e7bb2a453 | |||
| 847c4d50d4 | |||
|
|
220f20c794 | ||
| e85cefd9c0 | |||
| beec49a92d | |||
| ef25c073ce | |||
| 5ce928a00d | |||
| 61871cf6ed | |||
| 6f949698fe | |||
| 6cf1f4d078 | |||
|
|
ef74536e33 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ nexus/__pycache__/
|
||||
tests/__pycache__/
|
||||
mempalace/__pycache__/
|
||||
.aider*
|
||||
|
||||
# Prevent agents from writing to wrong path (see issue #1145)
|
||||
public/nexus/
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -42,6 +42,17 @@ 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`
|
||||
@@ -50,6 +61,7 @@ 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
|
||||
|
||||
|
||||
72
INVESTIGATION_ISSUE_1145.md
Normal file
72
INVESTIGATION_ISSUE_1145.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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)
|
||||
497
app.js
497
app.js
@@ -3,6 +3,8 @@ 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
|
||||
@@ -703,6 +705,8 @@ async function init() {
|
||||
createSessionPowerMeter();
|
||||
createWorkshopTerminal();
|
||||
createAshStorm();
|
||||
SpatialMemory.init(scene);
|
||||
SessionRooms.init(scene, camera, null);
|
||||
updateLoad(90);
|
||||
|
||||
loadSession();
|
||||
@@ -1881,7 +1885,7 @@ function setupControls() {
|
||||
orbitState.lastX = e.clientX;
|
||||
orbitState.lastY = e.clientY;
|
||||
|
||||
// Raycasting for portals
|
||||
// Raycasting for portals and memory crystals
|
||||
if (!portalOverlayActive) {
|
||||
const mouse = new THREE.Vector2(
|
||||
(e.clientX / window.innerWidth) * 2 - 1,
|
||||
@@ -1889,12 +1893,43 @@ function setupControls() {
|
||||
);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(portals.map(p => p.ring));
|
||||
if (intersects.length > 0) {
|
||||
const clickedRing = intersects[0].object;
|
||||
|
||||
// Priority 1: Portals
|
||||
const portalHits = raycaster.intersectObjects(portals.map(p => p.ring));
|
||||
if (portalHits.length > 0) {
|
||||
const clickedRing = portalHits[0].object;
|
||||
const portal = portals.find(p => p.ring === clickedRing);
|
||||
if (portal) activatePortal(portal);
|
||||
if (portal) { activatePortal(portal); return; }
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2549,6 +2584,226 @@ 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);
|
||||
@@ -2573,6 +2828,16 @@ 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');
|
||||
|
||||
@@ -2771,6 +3036,12 @@ 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();
|
||||
@@ -2933,9 +3204,208 @@ 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();
|
||||
@@ -3129,20 +3599,3 @@ init().then(() => {
|
||||
connectMemPalace();
|
||||
mineMemPalaceContent();
|
||||
});
|
||||
|
||||
// Project Mnemosyne — Holographic Memory
|
||||
function spawnMemoryOrb(position, color = 0x4af0c0, size = 0.5) {
|
||||
const geometry = new THREE.SphereGeometry(size, 32, 32);
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 2,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
shininess: 100
|
||||
});
|
||||
const orb = new THREE.Mesh(geometry, material);
|
||||
orb.position.copy(position);
|
||||
orb.userData.pulse = 0;
|
||||
return orb;
|
||||
}
|
||||
|
||||
@@ -152,17 +152,55 @@ 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")
|
||||
voice = os.environ.get("DEEPDIVE_TTS_VOICE", "alloy" if provider == "openai" else "matthew")
|
||||
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)
|
||||
|
||||
return TTSConfig(
|
||||
provider=provider,
|
||||
|
||||
@@ -32,12 +32,14 @@ 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,
|
||||
@@ -212,6 +214,46 @@ 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:
|
||||
@@ -226,6 +268,10 @@ 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")
|
||||
@@ -242,6 +288,14 @@ 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()
|
||||
|
||||
46
docker-compose.desktop.yml
Normal file
46
docker-compose.desktop.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
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
|
||||
174
docs/computer-use.md
Normal file
174
docs/computer-use.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 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) |
|
||||
135
docs/voice-output.md
Normal file
135
docs/voice-output.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 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.
|
||||
72
fleet/hermes-trismegistus/README.md
Normal file
72
fleet/hermes-trismegistus/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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 |
|
||||
43
fleet/hermes-trismegistus/lane.md
Normal file
43
fleet/hermes-trismegistus/lane.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
@@ -1,509 +0,0 @@
|
||||
<!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">×</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>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</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">></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>
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
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.0–1.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.0–1.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();
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 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…</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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
195
frontend/js/effects.js
vendored
195
frontend/js/effects.js
vendored
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* 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, 0–60 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;
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* 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">×</span>
|
||||
</div>
|
||||
<div class="agent-popup-role">${_esc(agent.role)}</div>
|
||||
<div class="agent-popup-state" style="color:${stateColor}">● ${stateLabel}</div>
|
||||
<button class="agent-popup-talk" id="agent-popup-talk" style="border-color:${color};color:${color}">
|
||||
TALK →
|
||||
</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">×</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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(() => {});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* 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.5–2.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();
|
||||
}
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Remove existing portal if it already exists to prevent duplication
|
||||
if (registry.has(def.id)) {
|
||||
removePortal(def.id);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
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">█</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value for use inside an HTML attribute (style="...", id="...").
|
||||
*/
|
||||
function escapeAttr(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
/**
|
||||
* 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: {
|
||||
type: 'physical',
|
||||
color: categoryColors[data.category] || 0xcccccc,
|
||||
emissive: categoryColors[data.category] || 0xcccccc,
|
||||
emissiveIntensity: 3,
|
||||
transmission: 0.6,
|
||||
roughness: 0.1,
|
||||
metalness: 0.1,
|
||||
opacity: 0.8,
|
||||
},
|
||||
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,
|
||||
material: {
|
||||
emissiveIntensity: 2,
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
/* ===== 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;
|
||||
}
|
||||
44
index.html
44
index.html
@@ -207,6 +207,50 @@
|
||||
</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">📌</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">□</span>
|
||||
<div class="session-room-title">SESSION CHAMBER</div>
|
||||
<button class="session-room-close" id="session-room-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="session-room-timestamp" id="session-room-timestamp">—</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…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portal Atlas Overlay -->
|
||||
<div id="atlas-overlay" class="atlas-overlay" style="display:none;">
|
||||
<div class="atlas-content">
|
||||
|
||||
@@ -157,14 +157,45 @@ 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()
|
||||
@@ -172,21 +203,29 @@ 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:
|
||||
@@ -194,7 +233,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")
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ from typing import Any, Callable, Optional
|
||||
|
||||
import websockets
|
||||
|
||||
from bannerlord_trace import BannerlordTraceLogger
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -265,11 +267,13 @@ 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
|
||||
@@ -284,6 +288,9 @@ class BannerlordHarness:
|
||||
self.cycle_count = 0
|
||||
self.running = False
|
||||
|
||||
# Session trace logger
|
||||
self.trace_logger: Optional[BannerlordTraceLogger] = None
|
||||
|
||||
# ═══ LIFECYCLE ═══
|
||||
|
||||
async def start(self) -> bool:
|
||||
@@ -314,6 +321,15 @@ 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
|
||||
|
||||
@@ -322,6 +338,12 @@ 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:
|
||||
@@ -707,6 +729,11 @@ 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()
|
||||
@@ -715,11 +742,24 @@ 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 = []
|
||||
@@ -731,6 +771,13 @@ 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",
|
||||
@@ -836,12 +883,18 @@ 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:
|
||||
|
||||
234
nexus/bannerlord_trace.py
Normal file
234
nexus/bannerlord_trace.py
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/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
|
||||
413
nexus/components/session-rooms.js
Normal file
413
nexus/components/session-rooms.js
Normal file
@@ -0,0 +1,413 @@
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 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 };
|
||||
665
nexus/components/spatial-memory.js
Normal file
665
nexus/components/spatial-memory.js
Normal file
@@ -0,0 +1,665 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// 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 };
|
||||
313
nexus/computer_use.py
Normal file
313
nexus/computer_use.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
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:]))
|
||||
118
nexus/computer_use_demo.py
Normal file
118
nexus/computer_use_demo.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
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)
|
||||
@@ -46,7 +46,6 @@ 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,
|
||||
@@ -327,47 +326,6 @@ 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:
|
||||
@@ -428,7 +386,6 @@ 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)
|
||||
|
||||
@@ -456,9 +413,6 @@ 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(),
|
||||
|
||||
97
nexus/traces/bannerlord/REPLAY.md
Normal file
97
nexus/traces/bannerlord/REPLAY.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 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.
|
||||
18
nexus/traces/bannerlord/sample_manifest.json
Normal file
18
nexus/traces/bannerlord/sample_manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
3
nexus/traces/bannerlord/sample_trace.jsonl
Normal file
3
nexus/traces/bannerlord/sample_trace.jsonl
Normal file
@@ -0,0 +1,3 @@
|
||||
{"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"}
|
||||
@@ -1,3 +1,4 @@
|
||||
pytest>=7.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pyyaml>=6.0
|
||||
edge-tts>=6.1.9
|
||||
|
||||
357
style.css
357
style.css
@@ -1223,3 +1223,360 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
362
tests/test_computer_use.py
Normal file
362
tests/test_computer_use.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
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
|
||||
420
tests/test_edge_tts.py
Normal file
420
tests/test_edge_tts.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user