Compare commits

...

8 Commits

Author SHA1 Message Date
STEP35
012a1fe83e fix(harness): replace dead /api/world/ws with configurable WS bridge
Some checks failed
CI / test (pull_request) Failing after 1m15s
Review Approval Gate / verify-review (pull_request) Failing after 14s
CI / validate (pull_request) Failing after 1m20s
- Replace dead HTTP path with direct WebSocket connection to server.py
- Add ws_host and ws_port query-param config (defaults: localhost:8765)
- Remove duplicate initializeMemPalace() call in connectHermes()
- Follows L402_PORT pattern for environment configuration

Closes #694
2026-04-30 20:22:42 -04:00
60e8b62a79 feat(portals): wire portal status wall to live portals.json (#1720)
Some checks failed
Deploy Nexus / deploy (push) Failing after 8s
Staging Verification Gate / verify-staging (push) Failing after 20s
2026-04-29 12:09:42 +00: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
11 changed files with 1898 additions and 64 deletions

152
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();
@@ -2174,9 +2277,6 @@ function sendChatMessage(overrideText = null) {
// ═══ HERMES WEBSOCKET ═══
function connectHermes() {
// Initialize MemPalace before Hermes connection
initializeMemPalace();
// Existing Hermes connection code...
// Initialize MemPalace before Hermes connection
initializeMemPalace();
if (hermesWs) return;
@@ -2184,8 +2284,6 @@ function connectHermes() {
// Initialize MemPalace storage
try {
console.log('Initializing MemPalace memory system...');
// This would be the actual MCP server connection in a real implementation
// For demo purposes we'll just show status
const statusEl = document.getElementById('mem-palace-status');
if (statusEl) {
statusEl.textContent = 'MEMPALACE INITIALIZING';
@@ -2200,8 +2298,11 @@ function connectHermes() {
}
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
// WebSocket gateway configuration — connects to server.py (Nexus WS bridge)
const WS_HOST = new URLSearchParams(window.location.search).get('ws_host') || 'localhost';
const WS_PORT = parseInt(new URLSearchParams(window.location.search).get('ws_port') || '8765');
const isSecure = window.location.protocol === 'https:';
const wsUrl = `${isSecure ? 'wss:' : 'ws:'}//${WS_HOST}:${WS_PORT}`;
console.log(`Connecting to Hermes at ${wsUrl}...`);
hermesWs = new WebSocket(wsUrl);
@@ -3370,7 +3471,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();
@@ -3564,6 +3679,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

@@ -238,6 +238,21 @@
letter-spacing: 0.08em;
text-transform: uppercase;
}
.env-badge {
font-size: 9px;
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
display: inline-block;
}
.env-badge.env_production { background: rgba(255, 68, 102, 0.15); color: var(--color-danger); }
.env-badge.env_staging { background: rgba(255, 170, 34, 0.15); color: var(--color-warning); }
.env-badge.env_local { background: rgba(74, 240, 192, 0.15); color: var(--color-primary); }
.env-badge.env_unknown { background: rgba(138, 154, 184, 0.15); color: var(--color-text-muted); }
</style>
</head>
<body>
@@ -251,16 +266,11 @@
<span style="font-size:11px;color:var(--color-text-muted)">LIVE</span>
</div>
<div class="portal-grid">
<div class="portal-grid" id="portal-grid">
<!-- Populated dynamically from portals.json -->
<!-- Portal: Hermes -->
<div class="portal-card status-online">
<div class="portal-header">
<div>
<div class="portal-name">Hermes</div>
<div class="portal-id">portal://hermes.nexus</div>
</div>
<span class="status-badge online">online</span>
<span class="status-badge online">online</span>
</div>
<div class="portal-meta">
<div class="meta-row">
@@ -285,13 +295,7 @@
</div>
<!-- Portal: Archive -->
<div class="portal-card status-online">
<div class="portal-header">
<div>
<div class="portal-name">Archive</div>
<div class="portal-id">portal://archive.nexus</div>
</div>
<span class="status-badge online">online</span>
<span class="status-badge online">online</span>
</div>
<div class="portal-meta">
<div class="meta-row">
@@ -316,13 +320,7 @@
</div>
<!-- Portal: Satflow -->
<div class="portal-card status-warning">
<div class="portal-header">
<div>
<div class="portal-name">Satflow</div>
<div class="portal-id">portal://satflow.nexus</div>
</div>
<span class="status-badge warning">degraded</span>
<span class="status-badge warning">degraded</span>
</div>
<div class="portal-meta">
<div class="meta-row">
@@ -347,13 +345,7 @@
</div>
<!-- Portal: Evennia -->
<div class="portal-card status-online">
<div class="portal-header">
<div>
<div class="portal-name">Evennia</div>
<div class="portal-id">portal://evennia.nexus</div>
</div>
<span class="status-badge online">online</span>
<span class="status-badge online">online</span>
</div>
<div class="portal-meta">
<div class="meta-row">
@@ -378,13 +370,7 @@
</div>
<!-- Portal: Bannerlord -->
<div class="portal-card status-offline">
<div class="portal-header">
<div>
<div class="portal-name">Bannerlord</div>
<div class="portal-id">portal://bannerlord.nexus</div>
</div>
<span class="status-badge offline">offline</span>
<span class="status-badge offline">offline</span>
</div>
<div class="portal-meta">
<div class="meta-row">
@@ -409,13 +395,7 @@
</div>
<!-- Portal: OpenClaw -->
<div class="portal-card status-locked">
<div class="portal-header">
<div>
<div class="portal-name">OpenClaw</div>
<div class="portal-id">portal://openclaw.nexus</div>
</div>
<span class="status-badge locked">locked</span>
<span class="status-badge locked">locked</span>
</div>
<div class="portal-meta">
<div class="meta-row">
@@ -445,34 +425,156 @@
<div class="summary-bar">
<div class="summary-item">
<div>
<div class="summary-count" style="color:var(--color-primary)">4</div>
<div class="summary-count online" data-default="0">0</div>
<div class="summary-label">Online</div>
</div>
</div>
<div class="summary-item">
<div>
<div class="summary-count" style="color:var(--color-warning)">1</div>
<div class="summary-count degraded" data-default="0">0</div>
<div class="summary-label">Degraded</div>
</div>
</div>
<div class="summary-item">
<div>
<div class="summary-count" style="color:var(--color-danger)">1</div>
<div class="summary-count offline" data-default="0">0</div>
<div class="summary-label">Offline</div>
</div>
</div>
<div class="summary-item">
<div>
<div class="summary-count" style="color:var(--color-secondary)">1</div>
<div class="summary-count locked" data-default="0">0</div>
<div class="summary-label">Locked</div>
</div>
</div>
<div style="margin-left:auto;align-self:center;font-size:10px;color:var(--color-text-muted)">
LAST SYNC: <span style="color:var(--color-text)">04:20:07 UTC</span>
LAST SYNC: <span class="last-sync-time" style="color:var(--color-text)">--:--:-- UTC</span>
</div>
</div>
</div>
<script>
// Portal Status Wall — dynamic renderer backed by portals.json
// Fetches real portal data and renders environment-aware status cards.
// Ref: #714
(async function() {
const grid = document.querySelector('.portal-grid');
if (!grid) {
console.error('Portal grid container not found');
return;
}
try {
const resp = await fetch('../portals.json');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const portals = await resp.json();
// Clear any placeholder cards
grid.innerHTML = '';
// Environment label mapping
const envLabels = {production: 'PROD', staging: 'STAGING', local: 'LOCAL'};
// Status to CSS class and display label
const statusConfig = {
online: {cls: 'online', label: 'ONLINE'},
offline: {cls: 'offline', label: 'OFFLINE'},
warning: {cls: 'warning', label: 'DEGRADED'},
locked: {cls: 'locked', label: 'LOCKED'},
standby: {cls: 'warning', label: 'STANDBY'}
};
// Render portal cards from portals.json
for (const p of portals) {
const env = (p.environment || 'unknown').toLowerCase();
const statusKey = (p.status || 'offline').toLowerCase();
const cfg = statusConfig[statusKey] || {cls: 'offline', label: statusKey.toUpperCase()};
const card = document.createElement('div');
card.className = `portal-card status-${cfg.cls}`;
card.innerHTML = `
<div class="portal-header">
<div>
<div class="portal-name">${escHtml(p.name || p.id)}</div>
<div class="portal-id">portal://${escHtml(p.id)}</div>
</div>
<span class="status-badge ${cfg.cls}">${cfg.label}</span>
</div>
<div class="portal-meta">
<div class="meta-row">
<span class="meta-label">Type</span>
<span class="meta-value">${escHtml(p.portal_type || 'world')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Category</span>
<span class="meta-value">${escHtml(p.world_category || '-')}</span>
</div>
<div class="meta-row">
<span class="meta-label">Env</span>
<span class="meta-value env-badge env_${env}">${envLabels[env] || env.toUpperCase()}</span>
</div>
<div class="meta-row">
<span class="meta-label">Readiness</span>
<span class="meta-value">${escHtml(p.readiness_state || 'unknown')}</span>
</div>
${p.blocked_reason ? `
<div class="meta-row" style="color:var(--color-warning)">
<span class="meta-label">Blocked</span>
<span class="meta-value">${escHtml(p.blocked_reason)}</span>
</div>` : ''}
</div>
`;
grid.appendChild(card);
}
// Update summary bar counts
updateSummary(portals);
// Update last sync time
const timeEl = document.querySelector('.last-sync-time');
if (timeEl) {
const now = new Date();
timeEl.textContent = now.toISOString().slice(11,19) + ' UTC';
}
} catch (e) {
console.error('Failed to load portal status:', e);
grid.innerHTML = '<div style="color:var(--color-danger);padding:12px;">Portal registry offline — unable to load portals.json</div>';
}
// Helper: escape HTML
function escHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;'
})[c]);
}
// Helper: update summary counts
function updateSummary(portals) {
const counts = {online:0, degraded:0, offline:0, locked:0};
for (const p of portals) {
const st = (p.status || '').toLowerCase();
if (st === 'online') counts.online++;
else if (st === 'offline') counts.offline++;
else if (st === 'warning' || st === 'degraded') counts.degraded++;
else if (st === 'locked') counts.locked++;
else counts.offline++;
}
const map = {
'online': '.summary-count.online',
'degraded': '.summary-count.degraded',
'offline': '.summary-count.offline',
'locked': '.summary-count.locked'
};
for (const [key, selector] of Object.entries(map)) {
const el = document.querySelector(selector);
if (el) el.textContent = counts[key];
}
}
})();
</script>
</body>
</html>

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;

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;

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;