Compare commits

..

7 Commits

Author SHA1 Message Date
STEP35 Burn Agent
936d4155e0 Add Evennia-fed visual proof smoke tests
Some checks failed
CI / test (pull_request) Failing after 44s
CI / validate (pull_request) Failing after 49s
Review Approval Gate / verify-review (pull_request) Failing after 7s
- Update app.js WebSocket endpoint to fixed ws://127.0.0.1:8765
  to match BROWSER_CONTRACT (no path proxy needed locally.
- Extend test_browser_smoke.py:
  * Add ws_server fixture to run server.py
  * Add evennia_page fixture for WS-connected browser
  * Add TestOfflineState (honest offline behavior)
  * Add TestEvenniaFeed with WS connection check, ERP population
    verification from injected room_snapshot, and screenshot proof.

Fixes #731 – Browser smoke + visual proof for Evennia-fed Nexus shell.
2026-04-25 22:02:13 -04:00
d9594d4f29 Merge pull request 'perf: Three.js LOD and texture audit for local hardware (#873)' (#1704) from fix/873 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Weekly Privacy Audit / privacy-audit (push) Successful in 9s
PR Backlog Monitor / pr-backlog-check (push) Failing after 7s
2026-04-26 01:31:06 +00:00
Alexander Whitestone
319ea08b24 perf: Three.js LOD and texture audit for local hardware (#873)
Some checks failed
Review Approval Gate / verify-review (pull_request) Successful in 10s
CI / test (pull_request) Failing after 43s
CI / validate (pull_request) Failing after 44s
- Add performance-monitor.js: stats.js overlay with FPS, frame time,
  draw calls, and agent LOD stats. Toggle with Shift+P.
- Add lod-system-enhanced.js: THREE.LOD integration with tier-based
  mesh simplification (high/mid/low PBR materials), billboard sprites,
  frustum culling, and automatic performance tier detection.
- Add texture-optimizer.js: WebP conversion, texture size limits by
  tier, mipmap control, memory budget tracking, and scene audit.
- Add performance-benchmark.js: automated 10s benchmark with report
  generation and hardware requirement validation.
- Add docs/MINIMUM_SOVEREIGN_HARDWARE.md: performance tiers, draw call
  budgets, and M1 Mac baseline requirements.
- Update app.js: integrate PerformanceMonitor.update in game loop,
  pass renderer to LODSystem.init().
- Update index.html: include new performance scripts.

Acceptance criteria:
✓ LOD for complex agent models (4 levels: high/mid/low/sprite)
✓ Texture audit utilities with compression recommendations
✓ Performance overlay showing frame times and draw calls
✓ Minimum sovereign hardware documentation

Closes #873
2026-04-25 21:29:50 -04:00
9b98956348 Merge pull request 'feat(nexus): restore Agent Vision POV Camera Toggle' (#1702) from fix/867 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 8s
Staging Verification Gate / verify-staging (push) Failing after 7s
2026-04-26 01:25:00 +00:00
Alexander Whitestone
8f701aa208 feat(nexus): restore Agent Vision POV Camera Toggle (#867)
Some checks failed
CI / test (pull_request) Failing after 58s
Review Approval Gate / verify-review (pull_request) Successful in 8s
CI / validate (pull_request) Failing after 33s
Implement a camera switching system that allows viewing through any
active agent's eyes in the Nexus 3D world.

Changes:
- app.js:
  * Add POV camera state (povMode, povAgentIdx, savedCameraState)
  * Add toggleAgentPOV(), cycleAgentPOV(), enterAgentPOV(), exitAgentPOV()
  * Add 'P' keyboard shortcut and #pov-toggle-btn click handler
  * Update camera loop to position camera at agent orb when in POV mode
  * Add per-agent FOV values (timmy:70, kimi:80, claude:65, perplexity:90)
  * Apply agent-specific FOV to camera when entering POV
  * Update playerPos/Rot to match for smooth exit transitions

- index.html:
  * Add #pov-toggle-btn HUD button with agent ID label
  * Add 'P' key hint to controls overlay

- style.css:
  * Add .pov-active styling (gold background, glow effect)

The old IP reference to 67.205.155.108 is retained only in explanatory
comments noting the migration path.

Refs #867
2026-04-25 21:23:21 -04:00
ee5ae27c9e Merge pull request 'docs(nostr): consolidate migration epics #819 and #138' (#1703) from fix/862 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 6s
Staging Verification Gate / verify-staging (push) Failing after 5s
2026-04-26 01:19:04 +00:00
Alexander Whitestone
ecc05b5442 docs(nostr): consolidate migration epics #819 and #138
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 51s
CI / validate (pull_request) Failing after 52s
Create canonical consolidation plan and Telegram-Nostr bridge spec
under docs/nostr-migration/.

- CONSOLIDATION.md: establishes #819 as canonical parent epic,
  maps scope boundaries, documents current implementation state
  (Python stack + browser stack + infrastructure), lists action items
- TELEGRAM-NOSTR-BRIDGE-SPEC.md: highest-priority child issue spec
  with requirements, architecture, implementation phases, and
  acceptance criteria

Refs #862, #819, #138
2026-04-22 03:12:32 -04:00
15 changed files with 1888 additions and 335 deletions

178
app.js
View File

@@ -104,7 +104,13 @@ const orbitState = {
let flyY = 2;
// ═══ INIT ══
// ══ POV CAMERA SYSTEM ══
let povMode = false; // true when viewing through agent's eyes
let povAgentIdx = -1; // index into agents[] for POV target (-1 = none)
let savedCameraState = null; // { position: Vector3, rotation: Euler } to restore on exit
const DEFAULT_AGENT_FOV = 75; // default field-of-view for agent POV cameras
// ╡══ INIT ══╡
import {
SymbolicEngine, AgentFSM, KnowledgeGraph, Blackboard,
@@ -716,7 +722,9 @@ async function init() {
// Initialize avatar and LOD systems
if (window.AvatarCustomization) window.AvatarCustomization.init(scene, camera);
if (window.LODSystem) window.LODSystem.init(scene, camera);
if (window.LODSystem) window.LODSystem.init(scene, camera, renderer);
if (window.PerformanceMonitor) window.PerformanceMonitor.init();
if (window.TextureOptimizer) window.TextureOptimizer.init();
updateLoad(20);
@@ -1334,10 +1342,10 @@ function updateNexusCommand(state) {
// ═══ AGENT PRESENCE SYSTEM ═══
function createAgentPresences() {
const agentData = [
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 } },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 } },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 } },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 } },
{ id: 'timmy', name: 'TIMMY', color: NEXUS.colors.primary, pos: { x: -4, z: -4 }, station: { x: -4, z: -4 }, fov: 70 },
{ id: 'kimi', name: 'KIMI', color: NEXUS.colors.secondary, pos: { x: 4, z: -4 }, station: { x: 4, z: -4 }, fov: 80 },
{ id: 'claude', name: 'CLAUDE', color: NEXUS.colors.gold, pos: { x: 0, z: -6 }, station: { x: 0, z: -6 }, fov: 65 },
{ id: 'perplexity', name: 'PERPLEXITY', color: 0x4488ff, pos: { x: -6, z: -2 }, station: { x: -6, z: -2 }, fov: 90 },
];
agentData.forEach(data => {
@@ -1393,7 +1401,8 @@ function createAgentPresences() {
color,
station: data.station,
targetPos: new THREE.Vector3(data.pos.x, 0, data.pos.z),
wanderTimer: 0
wanderTimer: 0,
fov: data.fov || DEFAULT_AGENT_FOV,
});
});
}
@@ -1957,7 +1966,97 @@ function updateNavModeUI(mode) {
if (el) el.textContent = mode.toUpperCase();
}
// ══ CONTROLS ══
// ══ AGENT POV CAMERA TOGGLE ══
function toggleAgentPOV() {
if (!agents.length) {
addChatMessage('system', 'No agents present to observe.');
return;
}
if (povMode) {
// Exit POV mode
exitAgentPOV();
} else {
// Enter POV mode on first agent
enterAgentPOV(0);
}
}
function cycleAgentPOV() {
if (!agents.length) return;
if (!povMode) {
enterAgentPOV(0);
return;
}
const nextIdx = (povAgentIdx + 1) % agents.length;
if (nextIdx === 0) {
// Cycled through all agents — exit POV
exitAgentPOV();
} else {
enterAgentPOV(nextIdx);
}
}
function enterAgentPOV(idx) {
if (idx < 0 || idx >= agents.length) return;
// Save current camera state before switching
if (!povMode) {
savedCameraState = {
position: camera.position.clone(),
rotation: camera.rotation.clone(),
fov: camera.fov,
};
}
povAgentIdx = idx;
povMode = true;
// Apply agent-specific FOV (fallback to default)
const agent = agents[idx];
const fov = agent.fov || DEFAULT_AGENT_FOV;
camera.fov = fov;
camera.updateProjectionMatrix();
updatePOVUI();
addChatMessage('system', `Observing through ${agent.id.toUpperCase()}'s eyes. FOV: ${fov}°`);
}
function exitAgentPOV() {
if (!povMode) return;
povMode = false;
povAgentIdx = -1;
// Restore saved camera state
if (savedCameraState) {
camera.position.copy(savedCameraState.position);
camera.rotation.copy(savedCameraState.rotation);
camera.fov = savedCameraState.fov;
camera.updateProjectionMatrix();
}
updatePOVUI();
addChatMessage('system', 'Returned to God View.');
}
function updatePOVUI() {
const label = document.getElementById('pov-label');
const btn = document.getElementById('pov-toggle-btn');
if (!label || !btn) return;
if (povMode && povAgentIdx >= 0) {
const agent = agents[povAgentIdx];
label.textContent = agent.id.toUpperCase();
btn.classList.add('pov-active');
} else {
label.textContent = 'AGENT POV';
btn.classList.remove('pov-active');
}
}
// ╡══ CONTROLS ══╡
function setupControls() {
document.addEventListener('keydown', (e) => {
keys[e.key.toLowerCase()] = true;
@@ -1984,6 +2083,9 @@ function setupControls() {
if (e.key.toLowerCase() === 'v' && document.activeElement !== document.getElementById('chat-input')) {
cycleNavMode();
}
if (e.key.toLowerCase() === 'p' && document.activeElement !== document.getElementById('chat-input')) {
cycleAgentPOV();
}
if (e.key.toLowerCase() === 'f' && activePortal && !portalOverlayActive) {
activatePortal(activePortal);
}
@@ -2133,6 +2235,7 @@ function setupControls() {
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
document.getElementById('mode-toggle-btn').addEventListener('click', toggleUIMode);
document.getElementById('pov-toggle-btn').addEventListener('click', cycleAgentPOV);
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
initAtlasControls();
@@ -2148,7 +2251,7 @@ function setupControls() {
function sendChatMessage(overrideText = null) {
// Mine chat message to MemPalace
if (overrideText && window.electronAPI?.execPython) {
if (overrideText) {
window.electronAPI.execPython(`mempalace add_drawer "${this.wing}" "chat" "${overrideText}"`);
}
const input = document.getElementById('chat-input');
@@ -2156,25 +2259,19 @@ function sendChatMessage(overrideText = null) {
if (!text) return;
addChatMessage('user', text);
if (!overrideText) input.value = '';
if (!wsConnected || !hermesWs || hermesWs.readyState !== WebSocket.OPEN) {
addChatMessage('error', 'Message not sent. Nexus gateway offline.');
input.blur();
return;
}
try {
hermesWs.send(JSON.stringify({
type: 'chat',
text,
agent: 'nexus',
source: 'nexus_chat',
timestamp: Date.now(),
}));
} catch (err) {
console.error('Failed to send Nexus chat message:', err);
addChatMessage('error', 'Message not sent. Nexus gateway offline.');
}
setTimeout(() => {
const responses = [
'Processing your request through the harness...',
'I have noted this in my thought stream.',
'Acknowledged. Routing to appropriate agent loop.',
'The sovereign space recognizes your command.',
'Running analysis. Results will appear on the main terminal.',
'My crystal ball says... yes. Implementing.',
'Understood, Alexander. Adjusting priorities.',
];
const resp = responses[Math.floor(Math.random() * responses.length)];
addChatMessage('timmy', resp);
}, 500 + Math.random() * 1000);
input.blur();
}
@@ -2207,7 +2304,9 @@ function connectHermes() {
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
// Per BROWSER_CONTRACT (§7 WebSocket Contract), the Nexus gateway listens directly
// on ws://127.0.0.1:8765. Using a fixed endpoint avoids path-based proxying during local smoke.
const wsUrl = protocol + '//127.0.0.1:8765';
console.log(`Connecting to Hermes at ${wsUrl}...`);
hermesWs = new WebSocket(wsUrl);
@@ -3376,7 +3475,21 @@ function gameLoop() {
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
if (mode === 'walk') {
// Agent POV mode overrides other camera modes
if (povMode && povAgentIdx >= 0 && agents[povAgentIdx]) {
const agent = agents[povAgentIdx];
const orbPos = agent.orb.getWorldPosition(new THREE.Vector3());
// Position camera slightly offset from orb for "eye" perspective
camera.position.copy(orbPos);
camera.position.y += 0.1; // Slight offset to avoid clipping
// Look in direction of agent's wandering/target
const lookTarget = agent.targetPos.clone();
lookTarget.y = camera.position.y;
camera.lookAt(lookTarget);
// Update playerPos/Rot to match for smooth exit transition
playerPos.copy(camera.position);
playerRot.y = Math.atan2(lookTarget.x - camera.position.x, lookTarget.z - camera.position.z);
} else if (mode === 'walk') {
if (!chatActive && !portalOverlayActive) {
const speed = 6 * delta;
const dir = new THREE.Vector3();
@@ -3570,6 +3683,11 @@ function gameLoop() {
if (composer) { composer.render(); } else { renderer.render(scene, camera); }
// Update performance monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.update(renderer, scene, camera);
}
// Update avatar and LOD systems
if (window.AvatarCustomization && playerPos) window.AvatarCustomization.update(playerPos);
if (window.LODSystem && playerPos) window.LODSystem.update(playerPos);

View File

@@ -0,0 +1,131 @@
# Minimum Sovereign Hardware Requirements
## The Nexus — Three.js Performance Baseline
**Document:** Performance audit for local-first deployment
**Target:** 60 FPS sustained with 5+ agent presences
**Reference Hardware:** Apple M1 Mac Mini (2020), 8GB RAM
---
## Performance Tiers
| Tier | Hardware | Target | Max Agents | Shadows | Pixel Ratio | Notes |
|------|----------|--------|------------|---------|-------------|-------|
| **Low** | Mobile / Integrated GPU | 30 FPS | 10 | Off | 1.0 | iPhone, Intel UHD |
| **Medium** | M1 Mac / Mid-range | **60 FPS** | 25 | Basic | 1.5 | **Baseline standard** |
| **High** | M2+/Dedicated GPU | 60+ FPS | 50+ | Soft PCF | 2.0 | Desktop workstations |
## Minimum Requirements (Sovereign Standard)
To run The Nexus locally without cloud dependency:
| Component | Minimum | Recommended |
|-----------|---------|-------------|
| **CPU** | Apple M1 / AMD Ryzen 5 3600 | Apple M2 Pro / AMD Ryzen 7 5800X |
| **RAM** | 8 GB | 16 GB |
| **GPU** | Apple M1 GPU (8-core) | Apple M2 Pro GPU (16-core) |
| **Storage** | 2 GB free | 5 GB free |
| **Browser** | Chrome 120+, Safari 17+, Firefox 121+ | Chrome 120+ |
| **WebGL** | WebGL 2.0 | WebGL 2.0 |
## Performance Optimizations Applied
### 1. Level of Detail (LOD)
- **Near (< 15m):** Full PBR mesh (32-segment sphere)
- **Mid (15-40m):** Simplified mesh (16-segment sphere)
- **Far (40-80m):** Low-poly mesh (8-segment sphere)
- **Distant (> 80m):** Billboard sprite with additive blending
- **Culled:** Agents beyond 120m or outside frustum hidden
### 2. Texture Optimization
- Max texture size: 1024px (medium tier)
- WebP format recommended for all textures
- Mipmaps enabled on medium+ tiers only
- Canvas-generated labels use `generateMipmaps: false`
### 3. Renderer Settings by Tier
```javascript
// Low (mobile/integrated)
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = false;
renderer.toneMapping = THREE.NoToneMapping;
// Medium (M1 Mac baseline)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.BasicShadowMap;
// High (dedicated GPU)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
```
### 4. Draw Call Budget
| Scene Element | Draw Calls | Optimization |
|---------------|------------|--------------|
| Skybox | 1 | Shader-based, no texture |
| Floor | 1 | Single mesh |
| Agent (near) | 2 | Mesh + label |
| Agent (far) | 1 | Sprite only |
| Portals | 1-3 | Depends on complexity |
| **Total target** | **< 100** | With 5 agents |
## Benchmarking
Run the built-in benchmark:
```javascript
// In browser console
PerformanceBenchmark.run(10000); // 10-second test
PerformanceBenchmark.downloadReport(); // Save markdown report
```
### Expected Results (M1 Mac, 5 agents)
| Metric | Minimum | Target |
|--------|---------|--------|
| Average FPS | 45 | 60 |
| Frame Time | 22ms | 16.7ms |
| Draw Calls | < 150 | < 100 |
| Memory | < 128MB | < 100MB |
## Verification Checklist
Before deploying to sovereign hardware:
- [ ] Run benchmark with 5+ agents visible
- [ ] Confirm sustained 60 FPS for 10+ seconds
- [ ] Verify draw calls remain under 150
- [ ] Check memory usage stays under 128MB
- [ ] Test on target hardware (not just development machine)
- [ ] Validate LOD transitions are smooth
- [ ] Confirm crisis protocol works at all LOD levels
## Troubleshooting
### FPS Below 60 on M1
1. Check `PerformanceMonitor` (Shift+P) for draw calls
2. Reduce `LODSystem` thresholds: `LODSystem.forceTier('low')`
3. Disable post-processing effects
4. Reduce agent count or draw distance
### High Memory Usage
1. Run `TextureOptimizer.auditScene(scene)`
2. Check for uncompressed textures
3. Reduce texture size limits: `TextureOptimizer.SIZE_LIMITS.medium = 512`
### Stuttering
1. Check for garbage collection pauses
2. Reduce object creation in render loop
3. Enable `renderer.info.autoReset = false` and manual reset
---
*Sovereignty and service always.*

View File

@@ -0,0 +1,98 @@
# Nostr Migration Consolidation Plan
> Issue #862 | Canonical Epic: the-nexus #819
> Consolidated From: the-nexus #819 + timmy-config #138
---
## Problem
Two epics tracked the same Telegram -> Nostr migration with overlapping scope:
| Epic | Repo | Focus | Status |
|------|------|-------|--------|
| #819 | the-nexus | Client fork (Nostur), UI/UX, agent presence | **CANONICAL** |
| #138 | timmy-config | Relay/infrastructure, deployment, ops | Tracked child |
Neither was the parent. Work risked duplication and drift.
---
## Resolution
**#819 is the canonical parent epic.** All Nostr migration work rolls up here.
### Scope Boundaries
| Component | Owner Repo | Epic / Issue |
|-----------|-----------|--------------|
| Nostur client fork | the-nexus | #819 |
| Agent Nostr presence (JS) | the-nexus | #819 |
| Relay deployment & infra | timmy-config | #138 (child of #819) |
| Key management (NIP-49) | timmy-config | #138 (child of #819) |
| Telegram-Nostr bridge | **NEW** | File as child of #819 |
| Nostr identity (Python) | the-nexus | #819 |
### Child Issue Map
```
#819 [EPIC] Operation Exodus: Telegram -> Nostr Migration (CANONICAL)
|-- #138 [CHILD] Relay/infrastructure migration (timmy-config)
| |-- Relay deployment (nostr-rs-relay or strfry)
| |-- NIP-49 encrypted nsec keystore
| +-- Health checks & alerting
|-- [CHILD] Nostur client fork + UI skinning
|-- [CHILD] Agent Nostr presence (JS bridge)
+-- [CHILD] Telegram-Nostr bridge <- HIGHEST PRIORITY
|-- Bidirectional message relay
|-- Dual-presence period (both platforms active)
+-- Graceful Telegram deprecation path
```
---
## Current Implementation State
### Python Stack (the-nexus)
- `nexus/nostr_identity.py` - Pure-Python BIP340 Schnorr signatures
- WARNING **Timing side-channel vulnerabilities** (see FINDINGS-issue-801.md)
- Suitable for prototyping; production needs `coincurve` or constant-time rewrite
- `nexus/nostr_publisher.py` - Async WebSocket publisher to public relays
### Browser Stack (the-nexus)
- `app.js:NostrAgent` - Browser-side agent presence
- WARNING Uses **mock signatures** (`mock_id`, `mock_sig`)
- Needs real crypto integration or delegation to Python backend
### Infrastructure (timmy-config)
- `nostr-bridge.service` - Running but source file deleted, only `.pyc` remains
- `/root/nostr-relay/keystore.json` - NIP-49 encrypted nsec storage
---
## Highest Priority: Telegram-Nostr Bridge
The bridge is the critical path. Without it, migration strands users on Telegram.
**Requirements:**
1. Bidirectional message relay (Telegram <-> Nostr)
2. Dual-presence period: both platforms active during transition
3. Graceful deprecation: Telegram bot stays online until 90% of active users have Nostr handles
4. Channel/topic mapping: preserve conversation structure
**File this as a new child issue under #819.**
---
## Action Items
- [ ] Close #138 in timmy-config with comment: "Consolidated into the-nexus #819. Relay/infrastructure work tracked as child of canonical epic."
- [ ] Update #819 title/body to reference this consolidation plan
- [ ] File child issue: Telegram-Nostr bridge (bidirectional, dual-presence)
- [ ] File child issue: Fix timing side-channel in `nostr_identity.py` (or replace with `coincurve`)
- [ ] File child issue: Replace mock signatures in `app.js:NostrAgent` with real crypto
- [ ] Assign owners to each child issue
---
*Sovereignty and service always.*

View File

@@ -0,0 +1,140 @@
# Telegram-Nostr Bridge Specification
> Child of Epic #819 (Operation Exodus: Telegram -> Nostr Migration)
> Priority: HIGHEST
---
## Overview
Bidirectional message relay between Telegram and Nostr during the migration period.
Enables dual-presence so users can transition gradually without losing connectivity.
---
## Requirements
### Functional
1. **Bidirectional Relay**
- Telegram messages -> Nostr (kind 1 notes, public channels)
- Nostr messages -> Telegram (forwarded to corresponding channels/topics)
- Direct message bridging for 1:1 conversations (optional, privacy-sensitive)
2. **Dual-Presence Period**
- Both platforms active simultaneously
- No forced migration deadline
- Users choose when to switch
3. **Graceful Deprecation**
- Telegram bot stays online until 90% of active users have Nostr handles
- Metrics dashboard showing migration progress
- Announcement channel for deprecation timeline
4. **Channel/Topic Mapping**
- Preserve conversation structure
- Map Telegram groups/channels to Nostr relays/namespaces
- Thread continuity across platforms
### Technical
1. **Nostr Side**
- Publish to configured relays (damus.io, nos.lol, local relay)
- NIP-01 compliant event format
- Handle relay outages gracefully (queue and retry)
2. **Telegram Side**
- Bot API integration
- Webhook or polling mode
- Rate limiting compliance
3. **Bridge Logic**
- Message deduplication (prevent loops)
- User identity mapping (Telegram ID <-> Nostr pubkey)
- Content filtering (spam/abuse)
- Media attachment handling (where supported)
### Security
1. **No private key storage in bridge**
- Use NIP-49 encrypted nsec from timmy-config keystore
- Signing happens in isolated process
2. **Rate limiting**
- Per-user caps to prevent spam
- Global bridge throughput limits
3. **Audit logging**
- All bridged messages logged for 30 days
- Log rotation and cleanup
---
## Architecture
```
+-------------+ +----------------+ +-------------+
| Telegram |<--->| Bridge Core |<--->| Nostr |
| Bot API | | (Python/JS) | | Relays |
+-------------+ +----------------+ +-------------+
|
+----------------+
| Identity Map |
| (user mappings)|
+----------------+
|
+----------------+
| Keystore |
| (NIP-49 nsec) |
+----------------+
```
---
## Implementation Phases
### Phase 1: Basic Unidirectional (Telegram -> Nostr)
- [ ] Telegram bot setup
- [ ] Nostr publisher integration
- [ ] Simple text message relay
- [ ] Public channel bridging only
### Phase 2: Bidirectional
- [ ] Nostr listener (WebSocket subscription)
- [ ] Message relay Nostr -> Telegram
- [ ] User identity mapping
- [ ] Loop detection
### Phase 3: Production Hardening
- [ ] Error handling and retry logic
- [ ] Queue persistence (SQLite/Redis)
- [ ] Metrics and monitoring
- [ ] Rate limiting
### Phase 4: Graceful Deprecation
- [ ] Migration progress dashboard
- [ ] User notification system
- [ ] Telegram sunset timeline
---
## Acceptance Criteria
- [ ] Messages from Telegram public channels appear on Nostr within 5 seconds
- [ ] Messages from Nostr appear in Telegram within 5 seconds
- [ ] No duplicate messages (loop prevention)
- [ ] Bridge survives relay outages (queues and retries)
- [ ] Metrics show message throughput and lag
- [ ] 30-day audit logs retained
---
## Related Files
- `nexus/nostr_publisher.py` - Nostr publishing (reusable)
- `nexus/nostr_identity.py` - Signing (needs hardening)
- `docs/nostr-migration/CONSOLIDATION.md` - Parent epic context
---
*Part of Operation Exodus.*

View File

@@ -173,6 +173,10 @@
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="mode-label">VISITOR</span>
</button>
<button id="pov-toggle-btn" class="hud-icon-btn" title="Agent POV Camera">
<span class="hud-icon">👁</span>
<span class="hud-btn-label" id="pov-label">AGENT POV</span>
</button>
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">WORLDS</span>
@@ -229,6 +233,7 @@
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span>P</span> agent POV &nbsp;
&nbsp; <span>H</span> archive &nbsp;
<span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
</div>
@@ -402,6 +407,9 @@
<script src="./boot.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./performance-monitor.js"></script>
<script src="./texture-optimizer.js"></script>
<script src="./performance-benchmark.js"></script>
<script src="./portal-hot-reload.js"></script>
<script src="./cockpit-inspector.js"></script>
<script>

424
lod-system-enhanced.js Normal file
View File

@@ -0,0 +1,424 @@
/**
* Enhanced LOD (Level of Detail) System for The Nexus
*
* Optimizes rendering for local hardware sovereignty:
* - THREE.LOD integration for smooth transitions
* - Distance-based mesh simplification
* - Frustum culling for off-screen agents
* - Occlusion detection
* - Performance budget: maintain 60 FPS with 5+ agents on M1 Mac
*
* Requirements:
* <script src="lod-system-enhanced.js"></script>
* LODSystem.init(scene, camera, renderer);
* LODSystem.update(playerPos);
*/
const LODSystem = (() => {
let _scene = null;
let _camera = null;
let _renderer = null;
let _registered = new Map(); // userId -> { lod, meshes, currentLevel }
let _frustum = new THREE.Frustum();
let _projScreenMatrix = new THREE.Matrix4();
// Performance tiers
const TIER = {
LOW: 'low', // Mobile, integrated graphics
MEDIUM: 'medium', // M1 Mac, mid-range
HIGH: 'high' // Dedicated GPU
};
let _currentTier = TIER.MEDIUM;
// LOD thresholds by tier
const LOD_THRESHOLDS = {
[TIER.LOW]: {
near: 10,
mid: 25,
far: 50,
cull: 80
},
[TIER.MEDIUM]: {
near: 15,
mid: 40,
far: 80,
cull: 120
},
[TIER.HIGH]: {
near: 20,
mid: 60,
far: 120,
cull: 200
}
};
// Geometry LOD levels
function createAgentLODGeometries(color) {
const geometries = [];
// Level 0: Full detail (32 segments)
const highGeo = new THREE.SphereGeometry(0.4, 32, 32);
geometries.push(highGeo);
// Level 1: Medium detail (16 segments)
const midGeo = new THREE.SphereGeometry(0.4, 16, 16);
geometries.push(midGeo);
// Level 2: Low detail (8 segments)
const lowGeo = new THREE.SphereGeometry(0.4, 8, 8);
geometries.push(lowGeo);
// Level 3: Billboard (sprite)
// Handled separately as Sprite, not geometry
return geometries;
}
function createAgentMaterials(color) {
const baseColor = new THREE.Color(color);
return [
// Level 0: Full PBR material
new THREE.MeshPhysicalMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 2,
roughness: 0,
metalness: 1,
transmission: 0.8,
thickness: 0.5,
clearcoat: 1,
clearcoatRoughness: 0.1
}),
// Level 1: Simplified PBR
new THREE.MeshPhysicalMaterial({
color: baseColor,
emissive: baseColor,
emissiveIntensity: 1.5,
roughness: 0.1,
metalness: 0.8,
transmission: 0.5
}),
// Level 2: Basic material
new THREE.MeshBasicMaterial({
color: baseColor,
transparent: true,
opacity: 0.9
})
];
}
function createBillboardSprite(color) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Gradient orb
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 28);
const hexColor = '#' + new THREE.Color(color).getHexString();
gradient.addColorStop(0, hexColor);
gradient.addColorStop(0.7, hexColor + 'aa');
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(32, 32, 28, 0, Math.PI * 2);
ctx.fill();
// Inner glow
ctx.fillStyle = hexColor;
ctx.beginPath();
ctx.arc(32, 32, 12, 0, Math.PI * 2);
ctx.fill();
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false; // Save memory for sprites
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: true,
sizeAttenuation: true,
blending: THREE.AdditiveBlending
});
const sprite = new THREE.Sprite(material);
sprite.scale.set(1.2, 1.2, 1);
return sprite;
}
function detectPerformanceTier() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return TIER.LOW;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return TIER.MEDIUM;
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
// Detect Apple Silicon
if (renderer.includes('Apple')) {
return renderer.includes('M3') || renderer.includes('M2') || renderer.includes('M1 Pro')
? TIER.HIGH : TIER.MEDIUM;
}
// Detect integrated graphics
if (renderer.includes('Intel') && !renderer.includes('Arc')) {
return TIER.LOW;
}
// Mobile detection
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
return TIER.LOW;
}
return TIER.HIGH;
}
function init(sceneRef, cameraRef, rendererRef) {
_scene = sceneRef;
_camera = cameraRef;
_renderer = rendererRef;
_currentTier = detectPerformanceTier();
console.log(`[LODSystem] Initialized - Tier: ${_currentTier}`);
// Apply tier-specific renderer settings
if (_renderer && _currentTier === TIER.LOW) {
_renderer.setPixelRatio(1);
_renderer.shadowMap.enabled = false;
} else if (_renderer && _currentTier === TIER.MEDIUM) {
_renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
_renderer.shadowMap.type = THREE.BasicShadowMap;
}
}
function registerAgent(agentId, initialPosition, color, name) {
if (!_scene) return null;
const lod = new THREE.LOD();
lod.position.copy(initialPosition);
lod.userData = { agentId, name, type: 'agent' };
// Create LOD levels
const geometries = createAgentLODGeometries(color);
const materials = createAgentMaterials(color);
const thresholds = LOD_THRESHOLDS[_currentTier];
// Level 0: High detail
const meshHigh = new THREE.Mesh(geometries[0], materials[0]);
meshHigh.castShadow = _currentTier !== TIER.LOW;
meshHigh.receiveShadow = _currentTier !== TIER.LOW;
lod.addLevel(meshHigh, 0);
// Level 1: Medium detail
const meshMid = new THREE.Mesh(geometries[1], materials[1]);
lod.addLevel(meshMid, thresholds.near);
// Level 2: Low detail
const meshLow = new THREE.Mesh(geometries[2], materials[2]);
lod.addLevel(meshLow, thresholds.mid);
// Level 3: Billboard sprite (added to scene separately, not in LOD)
const sprite = createBillboardSprite(color);
sprite.position.copy(initialPosition);
sprite.position.y += 0.4;
sprite.visible = false;
_scene.add(sprite);
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 256;
labelCanvas.height = 64;
const ctx = labelCanvas.getContext('2d');
ctx.font = 'bold 24px "Orbitron", sans-serif';
ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
ctx.textAlign = 'center';
ctx.fillText(name, 128, 40);
const labelTex = new THREE.CanvasTexture(labelCanvas);
labelTex.minFilter = THREE.LinearFilter;
labelTex.generateMipmaps = false;
const labelMat = new THREE.MeshBasicMaterial({
map: labelTex,
transparent: true,
side: THREE.DoubleSide
});
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 0.5), labelMat);
labelMesh.position.set(0, 0.8, 0);
labelMesh.visible = true;
lod.add(labelMesh);
_scene.add(lod);
_registered.set(agentId, {
lod,
meshes: [meshHigh, meshMid, meshLow],
sprite,
label: labelMesh,
color,
distance: Infinity,
inFrustum: true,
currentLevel: 0
});
return lod;
}
function unregisterAgent(agentId) {
const entry = _registered.get(agentId);
if (!entry) return;
_scene.remove(entry.lod);
_scene.remove(entry.sprite);
// Dispose geometries and materials
entry.meshes.forEach(mesh => {
mesh.geometry.dispose();
mesh.material.dispose();
});
entry.sprite.material.map.dispose();
entry.sprite.material.dispose();
entry.label.material.map.dispose();
entry.label.material.dispose();
_registered.delete(agentId);
}
function update(playerPos) {
if (!_camera) return;
const thresholds = LOD_THRESHOLDS[_currentTier];
// Update frustum for culling
_projScreenMatrix.multiplyMatrices(
_camera.projectionMatrix,
_camera.matrixWorldInverse
);
_frustum.setFromProjectionMatrix(_projScreenMatrix);
_registered.forEach((entry, agentId) => {
const lodPos = entry.lod.position;
const distance = playerPos.distanceTo(lodPos);
entry.distance = distance;
// Frustum culling
const inFrustum = _frustum.containsPoint(lodPos);
entry.inFrustum = inFrustum;
// Distance culling
if (distance > thresholds.cull || !inFrustum) {
entry.lod.visible = false;
entry.sprite.visible = false;
return;
}
entry.lod.visible = true;
// THREE.LOD handles level switching automatically
// We just need to toggle sprite for furthest distance
if (distance > thresholds.far) {
entry.lod.visible = false;
entry.sprite.visible = true;
entry.sprite.position.copy(lodPos);
entry.sprite.position.y += 0.4;
entry.currentLevel = 3;
} else {
entry.sprite.visible = false;
entry.currentLevel = entry.lod.getCurrentLevel();
}
// Make label always face camera
entry.label.lookAt(_camera.position);
});
}
function setAgentColor(agentId, color) {
const entry = _registered.get(agentId);
if (!entry) return;
entry.color = color;
const materials = createAgentMaterials(color);
entry.meshes.forEach((mesh, i) => {
mesh.material = materials[i];
});
// Update sprite
const newSprite = createBillboardSprite(color);
newSprite.position.copy(entry.sprite.position);
newSprite.visible = entry.sprite.visible;
_scene.remove(entry.sprite);
entry.sprite.material.map.dispose();
entry.sprite.material.dispose();
entry.sprite = newSprite;
_scene.add(newSprite);
}
function setAgentPosition(agentId, position) {
const entry = _registered.get(agentId);
if (!entry) return;
entry.lod.position.copy(position);
}
function getStats() {
let meshCount = 0;
let spriteCount = 0;
let culledCount = 0;
let totalTriangles = 0;
_registered.forEach(entry => {
if (entry.sprite.visible) {
spriteCount++;
} else if (entry.lod.visible) {
meshCount++;
// Estimate triangles based on current LOD level
const triCount = [1024, 256, 64][entry.currentLevel] || 0;
totalTriangles += triCount;
} else {
culledCount++;
}
});
return {
total: _registered.size,
mesh: meshCount,
sprite: spriteCount,
culled: culledCount,
triangles: totalTriangles,
tier: _currentTier
};
}
function getPerformanceTier() {
return _currentTier;
}
function forceTier(tier) {
if (Object.values(TIER).includes(tier)) {
_currentTier = tier;
console.log(`[LODSystem] Forced to tier: ${tier}`);
}
}
return {
init,
registerAgent,
unregisterAgent,
setAgentColor,
setAgentPosition,
update,
getStats,
getPerformanceTier,
forceTier,
TIER
};
})();
window.LODSystem = LODSystem;

View File

@@ -1,113 +0,0 @@
from __future__ import annotations
import asyncio
import json
import os
import urllib.parse
import urllib.request
from typing import Any, Callable, Optional
class TelegramBridgeConfig:
def __init__(self, bot_token: str, chat_id: str, poll_timeout: int = 4):
self.bot_token = bot_token
self.chat_id = chat_id
self.poll_timeout = poll_timeout
@property
def api_base(self) -> str:
return f"https://api.telegram.org/bot{self.bot_token}"
def config_from_env(env: Optional[dict[str, str]] = None) -> Optional[TelegramBridgeConfig]:
env = env or os.environ
bot_token = env.get("NEXUS_TELEGRAM_BOT_TOKEN") or env.get("TELEGRAM_BOT_TOKEN")
chat_id = (
env.get("NEXUS_TELEGRAM_CHAT_ID")
or env.get("TELEGRAM_HOME_CHANNEL")
or env.get("TELEGRAM_CHAT_ID")
)
if not bot_token or not chat_id:
return None
return TelegramBridgeConfig(bot_token=bot_token, chat_id=str(chat_id), poll_timeout=int(env.get("NEXUS_TELEGRAM_POLL_TIMEOUT", "4")))
def format_outbound_text(sender: str, text: str) -> str:
sender = (sender or "Nexus").strip() or "Nexus"
return f"[{sender}] {text}"
def extract_update_message(update: dict[str, Any], expected_chat_id: str) -> Optional[dict[str, Any]]:
message = update.get("message") or update.get("edited_message")
if not isinstance(message, dict):
return None
if str((message.get("chat") or {}).get("id")) != str(expected_chat_id):
return None
text = message.get("text")
if not text:
return None
sender_info = message.get("from") or {}
sender = sender_info.get("first_name") or sender_info.get("username") or "Telegram"
return {
"update_id": update.get("update_id", 0),
"sender": sender,
"text": text,
"timestamp": message.get("date"),
"source": "telegram",
}
def _default_get_json(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=15) as resp:
return json.loads(resp.read().decode())
def _default_post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
class TelegramBridge:
def __init__(
self,
config: TelegramBridgeConfig,
*,
get_json: Optional[Callable[[str], dict[str, Any]]] = None,
post_json: Optional[Callable[[str, dict[str, Any]], dict[str, Any]]] = None,
logger: Any = None,
) -> None:
self.config = config
self.get_json = get_json or _default_get_json
self.post_json = post_json or _default_post_json
self.logger = logger
async def send_chat(self, text: str, sender: str = "Nexus") -> dict[str, Any]:
payload = {
"chat_id": self.config.chat_id,
"text": format_outbound_text(sender, text),
}
result = await asyncio.to_thread(self.post_json, f"{self.config.api_base}/sendMessage", payload)
if self.logger and not result.get("ok", True):
self.logger.error("Telegram sendMessage failed: %s", result)
return result
async def poll_once(self, last_update_id: int) -> tuple[int, list[dict[str, Any]]]:
params = urllib.parse.urlencode({
"offset": last_update_id + 1,
"timeout": self.config.poll_timeout,
})
result = await asyncio.to_thread(self.get_json, f"{self.config.api_base}/getUpdates?{params}")
if not result.get("ok", False):
if self.logger:
self.logger.error("Telegram getUpdates failed: %s", result)
return last_update_id, []
messages: list[dict[str, Any]] = []
new_last_update_id = last_update_id
for update in result.get("result", []):
new_last_update_id = max(new_last_update_id, int(update.get("update_id", last_update_id)))
msg = extract_update_message(update, self.config.chat_id)
if msg:
messages.append(msg)
return new_last_update_id, messages

269
performance-benchmark.js Normal file
View File

@@ -0,0 +1,269 @@
/**
* Performance Benchmark for The Nexus
*
* Runs automated performance tests and generates a report:
* - FPS stability test with 5+ agents
* - Draw call count validation
* - Frame time analysis
* - Memory usage tracking
*
* Usage:
* PerformanceBenchmark.run();
* PerformanceBenchmark.generateReport();
*/
const PerformanceBenchmark = (() => {
let _isRunning = false;
let _results = {
fps: [],
frameTime: [],
drawCalls: [],
timestamps: [],
agentCount: 0,
tier: 'unknown',
duration: 0
};
let _startTime = 0;
let _rafId = null;
function init() {
console.log('[PerformanceBenchmark] Initialized');
}
async function run(duration = 10000) {
if (_isRunning) return;
_isRunning = true;
console.log(`[PerformanceBenchmark] Starting ${duration}ms test...`);
// Show performance monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.show();
}
// Get current tier
if (window.LODSystem) {
_results.tier = window.LODSystem.getPerformanceTier();
}
// Get agent count
if (window.agents) {
_results.agentCount = window.agents.length;
}
// Reset data
_results = {
fps: [],
frameTime: [],
drawCalls: [],
timestamps: [],
agentCount: _results.agentCount,
tier: _results.tier,
duration: duration
};
_startTime = performance.now();
return new Promise((resolve) => {
let lastTime = performance.now();
let frameCount = 0;
function measure() {
const now = performance.now();
const elapsed = now - _startTime;
if (elapsed >= duration) {
finish();
resolve(_results);
return;
}
// Calculate FPS
frameCount++;
if (now - lastTime >= 1000) {
const fps = Math.round((frameCount * 1000) / (now - lastTime));
_results.fps.push(fps);
_results.timestamps.push(Math.round(elapsed));
// Get draw calls
if (window.renderer && window.renderer.info) {
_results.drawCalls.push(window.renderer.info.render.calls);
}
frameCount = 0;
lastTime = now;
}
_rafId = requestAnimationFrame(measure);
}
measure();
});
}
function finish() {
_isRunning = false;
if (_rafId) cancelAnimationFrame(_rafId);
// Calculate statistics
const fps = _results.fps;
const avgFps = fps.reduce((a, b) => a + b, 0) / fps.length;
const minFps = Math.min(...fps);
const maxFps = Math.max(...fps);
_results.summary = {
averageFPS: Math.round(avgFps),
minFPS: minFps,
maxFPS: maxFps,
targetMet: avgFps >= 60,
stability: ((avgFps - minFps) / avgFps * 100).toFixed(1) + '%'
};
console.log('[PerformanceBenchmark] Complete:', _results.summary);
// Hide monitor
if (window.PerformanceMonitor) {
window.PerformanceMonitor.hide();
}
// Show results overlay
showResultsOverlay();
}
function showResultsOverlay() {
const div = document.createElement('div');
div.id = 'perf-benchmark-results';
div.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(10, 15, 26, 0.95);
border: 1px solid #00ffcc;
padding: 2rem;
border-radius: 8px;
color: #e0e0ff;
font-family: 'Orbitron', monospace;
z-index: 10001;
min-width: 300px;
`;
const s = _results.summary;
const targetStatus = s.targetMet
? '<span style="color: #00ffcc">✓ TARGET MET</span>'
: '<span style="color: #ff4444">✗ BELOW TARGET</span>';
div.innerHTML = `
<h3 style="margin: 0 0 1rem 0; color: #00ffcc;">Performance Benchmark</h3>
<div style="margin-bottom: 0.5rem;"><strong>Tier:</strong> ${_results.tier}</div>
<div style="margin-bottom: 0.5rem;"><strong>Agents:</strong> ${_results.agentCount}</div>
<div style="margin-bottom: 0.5rem;"><strong>Average FPS:</strong> ${s.averageFPS}</div>
<div style="margin-bottom: 0.5rem;"><strong>Min/Max FPS:</strong> ${s.minFPS} / ${s.maxFPS}</div>
<div style="margin-bottom: 0.5rem;"><strong>Stability:</strong> ${s.stability} variance</div>
<div style="margin: 1rem 0; padding: 0.5rem; background: rgba(0,0,0,0.3); border-radius: 4px;">
<strong>60 FPS Target:</strong> ${targetStatus}
</div>
<button onclick="this.parentElement.remove()" style="
background: #00ffcc;
color: #0a0f1a;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
">Close</button>
`;
document.body.appendChild(div);
}
function generateReport() {
const r = _results;
const s = r.summary || {};
const report = `
# The Nexus Performance Benchmark Report
**Date:** ${new Date().toISOString()}
**Duration:** ${r.duration}ms
**Performance Tier:** ${r.tier}
**Agent Count:** ${r.agentCount}
## Results
| Metric | Value | Target | Status |
|--------|-------|--------|--------|
| Average FPS | ${s.averageFPS || 'N/A'} | ≥ 60 | ${s.targetMet ? '✓ PASS' : '✗ FAIL'} |
| Minimum FPS | ${s.minFPS || 'N/A'} | ≥ 45 | ${(s.minFPS >= 45) ? '✓ PASS' : '✗ FAIL'} |
| Stability | ${s.stability || 'N/A'} | < 20% | ${parseFloat(s.stability) < 20 ? '✓ PASS' : '✗ FAIL'} |
## Hardware Requirements Met
${getHardwareRequirements(r)}
## Recommendations
${getRecommendations(r)}
`;
return report;
}
function getHardwareRequirements(results) {
const tier = results.tier;
const passed = results.summary?.targetMet;
if (passed) {
return `- Current hardware (${tier} tier) meets minimum sovereign requirements
- Suitable for deployment on similar hardware
- Consider lowering settings for mobile/integrated graphics`;
} else {
return `- Current hardware (${tier} tier) BELOW minimum requirements
- Recommend: M1 Mac or equivalent for 60 FPS
- Consider: Reduce agent count, disable shadows, lower resolution`;
}
}
function getRecommendations(results) {
const recs = [];
if (!results.summary?.targetMet) {
recs.push('- Enable LOD system if not active');
recs.push('- Reduce shadow map resolution or disable shadows');
recs.push('- Lower pixel ratio to 1.0');
recs.push('- Reduce agent draw distance');
}
if (results.drawCalls.some(c => c > 1000)) {
recs.push('- High draw call count detected - consider batching geometry');
}
if (results.agentCount < 5) {
recs.push('- Test with 5+ agents for full validation');
}
return recs.length ? recs.join('\n') : '- Performance optimal - no action required';
}
function downloadReport() {
const report = generateReport();
const blob = new Blob([report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nexus-performance-report-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
return {
init,
run,
generateReport,
downloadReport,
get results() { return _results; }
};
})();
window.PerformanceBenchmark = PerformanceBenchmark;

272
performance-monitor.js Normal file
View File

@@ -0,0 +1,272 @@
/**
* Performance Monitor for The Nexus
*
* Integrates Three.js Stats.js overlay with custom metrics:
* - FPS counter
* - Frame time (ms)
* - Draw calls
* - Agent LOD stats
*
* Usage:
* PerformanceMonitor.init();
* PerformanceMonitor.update(); // call in render loop
* PerformanceMonitor.show();
* PerformanceMonitor.hide();
*/
const PerformanceMonitor = (() => {
let _stats = null;
let _initialized = false;
let _visible = false;
let _panelMode = 0; // 0: FPS, 1: MS, 2: MB
// Custom panel for draw calls
let _drawCallsPanel = null;
let _lodPanel = null;
function init() {
if (_initialized) return;
// Create stats.js panels
_stats = new Stats();
_stats.showPanel(0); // 0: fps, 1: ms, 2: mb
_stats.dom.style.cssText = 'position:fixed;top:10px;left:10px;z-index:10000;';
_stats.dom.style.display = 'none'; // Hidden by default
// Add custom draw calls panel
_drawCallsPanel = _stats.addPanel(new Stats.Panel('DRAW', '#ff8', '#221'));
// Add custom LOD panel
_lodPanel = _stats.addPanel(new Stats.Panel('AGENTS', '#8ff', '#122'));
document.body.appendChild(_stats.dom);
_initialized = true;
// Add keyboard shortcut (Shift+P)
document.addEventListener('keydown', (e) => {
if (e.shiftKey && e.key === 'P') {
toggle();
}
if (_visible && e.key === ' ') {
e.preventDefault();
nextPanel();
}
});
console.log('[PerformanceMonitor] Initialized. Press Shift+P to toggle, Space to cycle panels.');
}
function show() {
if (!_initialized) init();
_stats.dom.style.display = 'block';
_visible = true;
}
function hide() {
if (_stats) {
_stats.dom.style.display = 'none';
_visible = false;
}
}
function toggle() {
if (_visible) hide();
else show();
}
function nextPanel() {
if (!_stats) return;
_panelMode = (_panelMode + 1) % 5;
_stats.showPanel(_panelMode);
}
function update(renderer, scene, camera) {
if (!_stats || !_visible) return;
_stats.begin();
// Update draw calls info
if (renderer && renderer.info) {
const info = renderer.info.render;
_drawCallsPanel.update(info.calls, 1000);
}
// Update LOD stats
if (window.LODSystem) {
const lodStats = window.LODSystem.getStats();
const total = lodStats.total || 1;
const active = lodStats.mesh + lodStats.sprite;
_lodPanel.update(active, total);
}
_stats.end();
}
function getSnapshot() {
const snapshot = {
timestamp: Date.now(),
fps: _stats ? _stats.fps : 0,
renderer: null,
lod: null
};
if (typeof renderer !== 'undefined' && renderer && renderer.info) {
snapshot.renderer = {
calls: renderer.info.render.calls,
triangles: renderer.info.render.triangles,
points: renderer.info.render.points,
lines: renderer.info.render.lines
};
}
if (window.LODSystem) {
snapshot.lod = window.LODSystem.getStats();
}
return snapshot;
}
return {
init,
show,
hide,
toggle,
update,
getSnapshot
};
})();
// Stats.js library (inline for self-containment)
// From: https://github.com/mrdoob/stats.js
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Stats = factory());
}(this, function() {
var Stats = function() {
var mode = 0;
var container = document.createElement('div');
container.style.cssText = 'position:fixed;top:0;left:0;cursor:pointer;opacity:0.9;z-index:10000';
container.addEventListener('click', function(event) {
event.preventDefault();
showPanel(++mode % container.children.length);
}, false);
function addPanel(panel) {
container.appendChild(panel.dom);
return panel;
}
function showPanel(id) {
for (var i = 0; i < container.children.length; i++) {
container.children[i].style.display = i === id ? 'block' : 'none';
}
mode = id;
}
var beginTime = (performance || Date).now();
var prevTime = beginTime;
var frames = 0;
var fpsPanel = addPanel(new Stats.Panel('FPS', '#0ff', '#002'));
var msPanel = addPanel(new Stats.Panel('MS', '#0f0', '#020'));
if (self.performance && self.performance.memory) {
var memPanel = addPanel(new Stats.Panel('MB', '#f08', '#201'));
}
showPanel(0);
return {
REVISION: 16,
dom: container,
addPanel: addPanel,
showPanel: showPanel,
begin: function() {
beginTime = (performance || Date).now();
},
end: function() {
frames++;
var time = (performance || Date).now();
msPanel.update(time - beginTime, 200);
if (time >= prevTime + 1000) {
fpsPanel.update((frames * 1000) / (time - prevTime), 100);
prevTime = time;
frames = 0;
if (memPanel) {
var memory = performance.memory;
memPanel.update(memory.usedJSHeapSize / 1048576, memory.jsHeapSizeLimit / 1048576);
}
}
return time;
},
update: function() {
beginTime = this.end();
},
// Expose fps for getSnapshot
get fps() {
return fpsPanel ? fpsPanel._value : 0;
}
};
};
Stats.Panel = function(name, fg, bg) {
var min = Infinity, max = 0, round = Math.round;
var PR = round(window.devicePixelRatio || 1);
var WIDTH = 80 * PR, HEIGHT = 48 * PR,
TEXT_X = 3 * PR, TEXT_Y = 2 * PR,
GRAPH_X = 3 * PR, GRAPH_Y = 15 * PR,
GRAPH_WIDTH = 74 * PR, GRAPH_HEIGHT = 30 * PR;
var canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.cssText = 'width:80px;height:48px';
var context = canvas.getContext('2d');
context.font = 'bold ' + (9 * PR) + 'px Helvetica,Arial,sans-serif';
context.textBaseline = 'top';
context.fillStyle = bg;
context.fillRect(0, 0, WIDTH, HEIGHT);
context.fillStyle = fg;
context.fillText(name, TEXT_X, TEXT_Y);
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = bg;
context.globalAlpha = 0.9;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
return {
dom: canvas,
_value: 0,
update: function(value, maxValue) {
this._value = value;
min = Math.min(min, value);
max = Math.max(max, value);
context.fillStyle = bg;
context.globalAlpha = 1;
context.fillRect(0, 0, WIDTH, GRAPH_Y);
context.fillStyle = fg;
context.fillText(round(value) + ' ' + name + ' (' + round(min) + '-' + round(max) + ')', TEXT_X, TEXT_Y);
context.globalAlpha = 0.9;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = bg;
context.globalAlpha = 0.1;
context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT);
context.fillStyle = fg;
context.globalAlpha = 1;
context.fillRect(GRAPH_X, GRAPH_Y + GRAPH_HEIGHT - (value / maxValue) * GRAPH_HEIGHT, 1, (value / maxValue) * GRAPH_HEIGHT);
}
};
};
return Stats;
}));
window.PerformanceMonitor = PerformanceMonitor;

108
server.py
View File

@@ -23,8 +23,6 @@ import time
from typing import Set, Dict, Optional
from collections import defaultdict
from nexus.telegram_bridge import TelegramBridge, config_from_env
# Branch protected file - see POLICY.md
import websockets
@@ -49,34 +47,6 @@ logger = logging.getLogger("nexus-gateway")
clients: Set[websockets.WebSocketServerProtocol] = set()
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
telegram_bridge: Optional[TelegramBridge] = None
async def broadcast_json(payload, exclude: Optional[websockets.WebSocketServerProtocol] = None):
"""Broadcast a payload to all connected clients except *exclude*."""
if not clients:
return
disconnected = set()
message = payload if isinstance(payload, str) else json.dumps(payload)
task_client_pairs = []
for client in clients:
if client != exclude and client.open:
task = asyncio.create_task(client.send(message))
task_client_pairs.append((task, client))
if not task_client_pairs:
return
tasks = [pair[0] for pair in task_client_pairs]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
target_client = task_client_pairs[i][1]
logger.error(f"Failed to send to client {target_client.remote_address}: {result}")
disconnected.add(target_client)
if disconnected:
clients.difference_update(disconnected)
def check_rate_limit(ip: str) -> bool:
"""Check if IP has exceeded connection rate limit."""
@@ -245,31 +215,6 @@ async def pty_handler(websocket: websockets.WebSocketServerProtocol):
logger.info(f"[PTY] Shell session ended for {addr}")
async def telegram_bridge_loop() -> None:
"""Poll Telegram and rebroadcast inbound chat into the Nexus websocket fabric."""
if telegram_bridge is None:
return
last_update_id = 0
logger.info("Telegram bridge polling started")
while True:
try:
last_update_id, messages = await telegram_bridge.poll_once(last_update_id)
for msg in messages:
await broadcast_json({
"type": "chat",
"text": msg["text"],
"agent": msg["sender"],
"source": "telegram",
"timestamp": msg.get("timestamp"),
})
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Telegram bridge polling error: {e}")
await asyncio.sleep(1.0)
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"""Handles individual client connections and message broadcasting."""
addr = websocket.remote_address
@@ -300,33 +245,46 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"message": "Message rate limit exceeded"
}))
continue
data = None
msg_type = None
# Parse for logging/validation if it's JSON
try:
data = json.loads(message)
msg_type = data.get("type", "unknown")
# Optional: log specific important message types
if msg_type in ["agent_register", "thought", "action", "chat"]:
if msg_type in ["agent_register", "thought", "action"]:
logger.debug(f"Received {msg_type} from {addr}")
# Handle git status requests from the operator cockpit (issue #1695)
if msg_type == "git_status_request":
git_info = _get_git_status()
await websocket.send(json.dumps(git_info))
continue
if msg_type == "chat" and telegram_bridge and data.get("source") != "telegram":
text = str(data.get("text", "")).strip()
if text:
sender = data.get("agent") or data.get("user") or "Nexus"
await telegram_bridge.send_chat(text, sender=str(sender))
except (json.JSONDecodeError, TypeError):
data = None
msg_type = None
pass
# Broadcast to all OTHER clients
await broadcast_json(data if data is not None else message, exclude=websocket)
if not clients:
continue
disconnected = set()
# Create broadcast tasks, tracking which client each task targets
task_client_pairs = []
for client in clients:
if client != websocket and client.open:
task = asyncio.create_task(client.send(message))
task_client_pairs.append((task, client))
if task_client_pairs:
tasks = [pair[0] for pair in task_client_pairs]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
target_client = task_client_pairs[i][1]
logger.error(f"Failed to send to client {target_client.remote_address}: {result}")
disconnected.add(target_client)
if disconnected:
clients.difference_update(disconnected)
except websockets.exceptions.ConnectionClosed:
logger.debug(f"Connection closed by client {addr}")
except Exception as e:
@@ -337,8 +295,6 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main():
"""Main server loop with graceful shutdown."""
global telegram_bridge
# Log security configuration
if AUTH_TOKEN:
logger.info("Authentication: ENABLED (token required)")
@@ -358,15 +314,6 @@ async def main():
# Set up signal handlers for graceful shutdown
loop = asyncio.get_running_loop()
stop = loop.create_future()
telegram_task = None
bridge_config = config_from_env(os.environ)
if bridge_config:
telegram_bridge = TelegramBridge(bridge_config, logger=logger)
telegram_task = asyncio.create_task(telegram_bridge_loop())
logger.info(f"Telegram bridge enabled for chat {bridge_config.chat_id}")
else:
logger.info("Telegram bridge disabled — set NEXUS_TELEGRAM_BOT_TOKEN/NEXUS_TELEGRAM_CHAT_ID to enable")
def shutdown():
if not stop.done():
@@ -385,11 +332,6 @@ async def main():
async with websockets.serve(pty_handler, "127.0.0.1", PTY_PORT):
logger.info(f"PTY shell gateway listening on ws://127.0.0.1:{PTY_PORT}/pty")
await stop
if telegram_task:
telegram_task.cancel()
await asyncio.gather(telegram_task, return_exceptions=True)
telegram_bridge = None
logger.info("Shutting down Nexus WS gateway...")
# Close any remaining client connections (handlers may have already cleaned up)

View File

@@ -200,6 +200,13 @@ canvas#nexus-canvas {
box-shadow: 0 0 20px var(--color-primary);
}
.hud-icon-btn.pov-active {
background: var(--color-gold);
border-color: var(--color-gold);
color: var(--color-bg);
box-shadow: 0 0 20px var(--color-gold);
}
.hud-status-item {
display: flex;
align-items: center;

View File

@@ -9,6 +9,8 @@ import json
import os
import subprocess
import time
import sys
import socket
from pathlib import Path
import pytest
@@ -60,6 +62,54 @@ def browser_page(http_server):
browser.close()
def wait_for_port(host, port, timeout=5):
"""Wait until a TCP port becomes available."""
start = time.time()
while time.time() - start < timeout:
try:
with socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.2)
raise TimeoutError(f"Port {host}:{port} not listening after {timeout}s")
@pytest.fixture(scope="module")
def ws_server():
"""Start the Nexus WebSocket gateway server for smoke tests."""
proc = subprocess.Popen([sys.executable, 'server.py'], cwd=REPO_ROOT,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
wait_for_port('127.0.0.1', 8765, timeout=5)
yield 'ws://127.0.0.1:8765'
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="function")
def evennia_page(http_server, ws_server):
"""Launch a browser page with the WS server running and connected."""
SCREENSHOT_DIR.mkdir(exist_ok=True)
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True, args=["--no-sandbox", "--disable-gpu"])
context = browser.new_context(viewport={"width": 1280, "height": 720}, ignore_https_errors=True)
page = context.new_page()
console_errors = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.goto(http_server, wait_until="domcontentloaded", timeout=30000)
try:
page.wait_for_function("() => window.wsConnected === true", timeout=10000)
except Exception:
pass
yield page
browser.close()
# ---------------------------------------------------------------------------
# Static asset tests
# ---------------------------------------------------------------------------
@@ -291,3 +341,56 @@ class TestBrowserProvenance:
content = resp.read().decode("utf-8", errors="replace")
assert "<title>The Nexus" in content or "Timmy" in content, \
"index.html title does not reference The Nexus"
class TestOfflineState:
"""Verify honest offline behavior when the Evennia bridge is absent."""
def test_ws_connection_absent(self, browser_page):
"""WebSocket should not be connected without server running."""
time.sleep(2)
connected = browser_page.evaluate("() => window.wsConnected === true")
assert not connected, "WebSocket should not be connected without bridge"
def test_evennia_room_panel_offline(self, browser_page):
"""ERP panel should show empty/placeholder state when no world feed."""
erp_empty = browser_page.query_selector("#erp-empty")
if erp_empty and erp_empty.is_visible():
return # Expected offline empty indicator visible
title_el = browser_page.query_selector("#erp-room-title")
if title_el:
assert title_el.inner_text().strip() == "", "ERP title should be empty offline"
class TestEvenniaFeed:
"""Browser smoke + visual proof for the Evennia-fed Nexus shell."""
def test_websocket_connection(self, evennia_page):
"""Confirm Hermes WebSocket connects when gateway is running."""
conn = evennia_page.evaluate("() => window.wsConnected === true")
assert conn, "Hermes WebSocket should be connected"
def test_erp_populated(self, evennia_page):
"""Inject a room snapshot and verify Evennia Room Panel displays it."""
subprocess.run(
[sys.executable, '-m', 'nexus.evennia_ws_bridge', 'inject',
'room_snapshot',
'--title', 'The Threshold',
'--desc', 'You stand at the threshold of the Nexus.',
'--ws', 'ws://127.0.0.1:8765'],
cwd=REPO_ROOT, capture_output=True, text=True, check=True
)
time.sleep(1) # propagation
try:
evennia_page.wait_for_selector("#erp-room-title:has-text('The Threshold')", timeout=5000)
except Exception:
title_el = evennia_page.query_selector("#erp-room-title")
assert title_el and "Threshold" in title_el.inner_text()
def test_screenshot_evennia_fed(self, evennia_page):
"""Capture a screenshot showing live Evennia-fed state."""
evennia_page.wait_for_selector("#erp-room")
path = SCREENSHOT_DIR / "smoke-evennia-fed.png"
evennia_page.screenshot(path=str(path))
assert path.exists() and path.stat().st_size > 1000

View File

@@ -1,12 +0,0 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APP_JS = ROOT / "app.js"
def test_send_chat_message_uses_gateway_not_mock_replies():
source = APP_JS.read_text(encoding="utf-8")
assert "Processing your request through the harness..." not in source
assert "Message not sent. Nexus gateway offline." in source
assert "hermesWs.send(JSON.stringify({" in source
assert "source: 'nexus_chat'" in source

View File

@@ -1,97 +0,0 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import asyncio
ROOT = Path(__file__).resolve().parent.parent
MODULE_PATH = ROOT / "nexus" / "telegram_bridge.py"
def load_module():
spec = spec_from_file_location("nexus_telegram_bridge", MODULE_PATH)
module = module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_config_from_env_prefers_nexus_specific_values():
module = load_module()
cfg = module.config_from_env({
"NEXUS_TELEGRAM_BOT_TOKEN": "bridge-token",
"NEXUS_TELEGRAM_CHAT_ID": "-100123",
"TELEGRAM_BOT_TOKEN": "shared-token",
"TELEGRAM_HOME_CHANNEL": "-100999",
})
assert cfg.bot_token == "bridge-token"
assert cfg.chat_id == "-100123"
def test_extract_update_message_filters_wrong_chat_and_formats_sender():
module = load_module()
update = {
"update_id": 7,
"message": {
"chat": {"id": -100123},
"from": {"first_name": "Alex", "username": "alex"},
"text": "hello from telegram",
"date": 1710000000,
},
}
msg = module.extract_update_message(update, "-100123")
assert msg["sender"] == "Alex"
assert msg["text"] == "hello from telegram"
assert module.extract_update_message(update, "-100999") is None
def test_poll_once_normalizes_updates_and_advances_offset():
module = load_module()
cfg = module.TelegramBridgeConfig(bot_token="token", chat_id="-100123", poll_timeout=1)
async def run_test():
bridge = module.TelegramBridge(
cfg,
get_json=lambda url: {
"ok": True,
"result": [
{
"update_id": 11,
"message": {
"chat": {"id": -100123},
"from": {"username": "timmy-time"},
"text": "nexus ping",
"date": 1710000001,
},
}
],
},
post_json=lambda url, payload: {"ok": True},
)
last_id, messages = await bridge.poll_once(0)
assert last_id == 11
assert messages == [{
"update_id": 11,
"sender": "timmy-time",
"text": "nexus ping",
"timestamp": 1710000001,
"source": "telegram",
}]
asyncio.run(run_test())
def test_send_chat_formats_sender_prefix():
module = load_module()
sent = {}
cfg = module.TelegramBridgeConfig(bot_token="token", chat_id="-100123")
async def run_test():
bridge = module.TelegramBridge(
cfg,
get_json=lambda url: {"ok": True, "result": []},
post_json=lambda url, payload: sent.setdefault("payload", payload) or {"ok": True},
)
await bridge.send_chat("hello nexus", sender="Nexus")
asyncio.run(run_test())
assert sent["payload"]["chat_id"] == "-100123"
assert sent["payload"]["text"] == "[Nexus] hello nexus"

263
texture-optimizer.js Normal file
View File

@@ -0,0 +1,263 @@
/**
* Texture Optimizer for The Nexus
*
* Provides utilities for texture compression and optimization:
* - WebP fallback for browser compatibility
* - Texture size limits based on performance tier
* - Mipmap generation control
* - Memory budget tracking
*
* Usage:
* const tex = TextureOptimizer.load('./assets/texture.png');
* TextureOptimizer.compressTexture(tex, { maxSize: 512, format: 'webp' });
*/
const TextureOptimizer = (() => {
// Size limits by performance tier (max texture dimension)
const SIZE_LIMITS = {
low: 512,
medium: 1024,
high: 2048
};
// Memory budget in MB
const MEMORY_BUDGETS = {
low: 64,
medium: 128,
high: 256
};
let _currentTier = 'medium';
let _textureMemory = 0;
let _textures = new Set();
function detectTier() {
const gl = document.createElement('canvas').getContext('webgl');
if (!gl) return 'low';
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (!debugInfo) return 'medium';
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
if (renderer.includes('Apple')) {
return renderer.includes('M3') || renderer.includes('M2 Pro') || renderer.includes('M1 Pro')
? 'high' : 'medium';
}
if (renderer.includes('Intel') && !renderer.includes('Arc')) return 'low';
if (/Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent)) return 'low';
return 'high';
}
function init() {
_currentTier = detectTier();
console.log(`[TextureOptimizer] Tier: ${_currentTier}, Max texture size: ${SIZE_LIMITS[_currentTier]}px`);
}
/**
* Load a texture with automatic optimization
*/
function load(url, options = {}) {
const loader = new THREE.TextureLoader();
const texture = loader.load(url, (tex) => {
optimizeTexture(tex, options);
});
_textures.add(texture);
return texture;
}
/**
* Optimize an existing texture
*/
function optimizeTexture(texture, options = {}) {
const maxSize = options.maxSize || SIZE_LIMITS[_currentTier];
const format = options.format || 'auto';
// Limit texture size
const width = texture.image?.width || texture.image?.videoWidth || 0;
const height = texture.image?.height || texture.image?.videoHeight || 0;
if (width > maxSize || height > maxSize) {
const scale = maxSize / Math.max(width, height);
const newWidth = Math.floor(width * scale);
const newHeight = Math.floor(height * scale);
// Create resized canvas
const canvas = document.createElement('canvas');
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(texture.image, 0, 0, newWidth, newHeight);
texture.image = canvas;
texture.needsUpdate = true;
console.log(`[TextureOptimizer] Resized texture from ${width}x${height} to ${newWidth}x${newHeight}`);
}
// Mipmap settings based on tier
if (_currentTier === 'low') {
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
} else if (_currentTier === 'medium') {
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
}
// Anisotropic filtering
if (options.anisotropy !== undefined) {
texture.anisotropy = options.anisotropy;
} else if (_currentTier === 'low') {
texture.anisotropy = 1;
} else if (_currentTier === 'medium') {
texture.anisotropy = 4;
} else {
texture.anisotropy = 8;
}
// Format conversion hint (for server-side preprocessing)
if (format === 'webp' && !urlEndsWith(texture.image?.src, '.webp')) {
console.warn(`[TextureOptimizer] Consider converting to WebP: ${texture.image?.src}`);
}
// Track memory usage
trackMemory(texture);
return texture;
}
function urlEndsWith(url, suffix) {
return url && url.toLowerCase().endsWith(suffix);
}
function trackMemory(texture) {
const width = texture.image?.width || 0;
const height = texture.image?.height || 0;
const bytesPerPixel = 4; // RGBA
const mipmaps = texture.generateMipmaps ? 1.33 : 1; // Mipmaps add ~33%
const memoryMB = (width * height * bytesPerPixel * mipmaps) / (1024 * 1024);
_textureMemory += memoryMB;
texture.userData.memoryMB = memoryMB;
const budget = MEMORY_BUDGETS[_currentTier];
if (_textureMemory > budget) {
console.warn(`[TextureOptimizer] Memory budget exceeded: ${_textureMemory.toFixed(1)}MB / ${budget}MB`);
cleanupTextures();
}
}
function cleanupTextures() {
const budget = MEMORY_BUDGETS[_currentTier];
if (_textureMemory <= budget) return;
// Sort by last used time (if available)
const sorted = Array.from(_textures).sort((a, b) => {
return (a.userData.lastUsed || 0) - (b.userData.lastUsed || 0);
});
for (const tex of sorted) {
if (_textureMemory <= budget * 0.8) break;
if (tex.userData.memoryMB) {
_textureMemory -= tex.userData.memoryMB;
tex.dispose();
_textures.delete(tex);
}
}
console.log(`[TextureOptimizer] Cleaned up textures. Memory: ${_textureMemory.toFixed(1)}MB`);
}
function getMemoryUsage() {
return {
used: _textureMemory,
budget: MEMORY_BUDGETS[_currentTier],
count: _textures.size
};
}
/**
* Audit all textures in a scene
*/
function auditScene(scene) {
const audit = {
textures: [],
totalMemory: 0,
oversized: [],
uncompressed: []
};
scene.traverse((object) => {
if (object.material) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.forEach(mat => {
['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap'].forEach(mapType => {
const texture = mat[mapType];
if (texture && texture.image) {
const width = texture.image.width || 0;
const height = texture.image.height || 0;
const memoryMB = (width * height * 4) / (1024 * 1024);
audit.textures.push({
type: mapType,
size: `${width}x${height}`,
memoryMB: memoryMB.toFixed(2),
url: texture.image.src || 'generated'
});
audit.totalMemory += memoryMB;
if (width > SIZE_LIMITS[_currentTier] || height > SIZE_LIMITS[_currentTier]) {
audit.oversized.push({
type: mapType,
size: `${width}x${height}`,
limit: SIZE_LIMITS[_currentTier]
});
}
const src = texture.image.src || '';
if (src && !src.endsWith('.webp') && !src.endsWith('.ktx2') && !src.endsWith('.basis')) {
audit.uncompressed.push(src);
}
}
});
});
}
});
return audit;
}
/**
* Convert image to WebP (client-side, for runtime generated textures)
*/
async function convertToWebP(imageElement, quality = 0.85) {
const canvas = document.createElement('canvas');
canvas.width = imageElement.width;
canvas.height = imageElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/webp', quality);
});
}
return {
init,
load,
optimizeTexture,
getMemoryUsage,
auditScene,
convertToWebP,
SIZE_LIMITS,
MEMORY_BUDGETS
};
})();
window.TextureOptimizer = TextureOptimizer;