Compare commits
21 Commits
mimo/build
...
mimo/code/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0458342622 | ||
| d26483f3a5 | |||
| fda4fcc3bd | |||
| f8505ca6c5 | |||
| d8ddf96d0c | |||
| 11c5bfa18d | |||
| 8160b1b383 | |||
| 3c1f760fbc | |||
| 878461b6f7 | |||
| 40dacd2c94 | |||
|
|
869a7711e3 | ||
|
|
d5099a18c6 | ||
| aab3e607eb | |||
| fe56ece1ad | |||
| bf477382ba | |||
| fba972f8be | |||
| 6786e65f3d | |||
| 62a6581827 | |||
| 797f32a7fe | |||
| 80eb4ff7ea | |||
|
|
b5ed262581 |
51
.gitea.yml
51
.gitea.yml
@@ -15,54 +15,3 @@ protection:
|
||||
- perplexity
|
||||
required_reviewers:
|
||||
- Timmy # Owner gate for hermes-agent
|
||||
main:
|
||||
require_pull_request: true
|
||||
required_approvals: 1
|
||||
dismiss_stale_approvals: true
|
||||
require_ci_to_pass: true
|
||||
block_force_push: true
|
||||
block_deletion: true
|
||||
>>>>>>> replace
|
||||
</source>
|
||||
|
||||
CODEOWNERS
|
||||
<source>
|
||||
<<<<<<< search
|
||||
protection:
|
||||
main:
|
||||
required_status_checks:
|
||||
- "ci/unit-tests"
|
||||
- "ci/integration"
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
the-nexus:
|
||||
required_status_checks: []
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
timmy-home:
|
||||
required_status_checks: []
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
timmy-config:
|
||||
required_status_checks: []
|
||||
required_pull_request_reviews:
|
||||
- "1 approval"
|
||||
restrictions:
|
||||
- "block force push"
|
||||
- "block deletion"
|
||||
enforce_admins: true
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -3,13 +3,18 @@ FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps
|
||||
COPY nexus/ nexus/
|
||||
COPY server.py .
|
||||
COPY portals.json vision.json ./
|
||||
COPY robots.txt ./
|
||||
COPY index.html help.html ./
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt websockets
|
||||
|
||||
RUN pip install --no-cache-dir websockets
|
||||
# Backend
|
||||
COPY nexus/ nexus/
|
||||
COPY server.py ./
|
||||
|
||||
# Frontend assets referenced by index.html
|
||||
COPY index.html help.html style.css app.js service-worker.js manifest.json ./
|
||||
|
||||
# Config/data
|
||||
COPY portals.json vision.json robots.txt ./
|
||||
|
||||
EXPOSE 8765
|
||||
|
||||
|
||||
141
app.js
141
app.js
@@ -1,9 +1,10 @@
|
||||
import * as THREE from 'three';
|
||||
import ResonanceVisualizer from './nexus/components/resonance-visualizer.js';\nimport * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
|
||||
import { SpatialMemory } from './nexus/components/spatial-memory.js';
|
||||
import { SpatialAudio } from './nexus/components/spatial-audio.js';
|
||||
import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
@@ -58,11 +59,6 @@ let performanceTier = 'high';
|
||||
let hermesWs = null;
|
||||
let wsReconnectTimer = null;
|
||||
let wsConnected = false;
|
||||
// ═══ EVENNIA ROOM STATE ═══
|
||||
let evenniaRoom = null; // {title, desc, exits[], objects[], occupants[], timestamp, roomKey}
|
||||
let evenniaConnected = false;
|
||||
let evenniaStaleTimer = null;
|
||||
const EVENNIA_STALE_MS = 60000; // mark stale after 60s without update
|
||||
let recentToolOutputs = [];
|
||||
let workshopPanelCtx = null;
|
||||
let workshopPanelTexture = null;
|
||||
@@ -602,7 +598,7 @@ class PSELayer {
|
||||
|
||||
let pseLayer;
|
||||
|
||||
let metaLayer, neuroBridge, cbr, symbolicPlanner, knowledgeGraph, blackboard, symbolicEngine, calibrator;
|
||||
let resonanceViz, metaLayer, neuroBridge, cbr, symbolicPlanner, knowledgeGraph, blackboard, symbolicEngine, calibrator;
|
||||
let agentFSMs = {};
|
||||
|
||||
function setupGOFAI() {
|
||||
@@ -671,7 +667,7 @@ async function init() {
|
||||
scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x050510, 0.012);
|
||||
|
||||
setupGOFAI();
|
||||
setupGOFAI();\n resonanceViz = new ResonanceVisualizer(scene);
|
||||
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.copy(playerPos);
|
||||
|
||||
@@ -720,6 +716,8 @@ async function init() {
|
||||
MemoryBirth.init(scene);
|
||||
MemoryBirth.wrapSpatialMemory(SpatialMemory);
|
||||
SpatialMemory.setCamera(camera);
|
||||
SpatialAudio.init(camera, scene);
|
||||
SpatialAudio.bindSpatialMemory(SpatialMemory);
|
||||
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
||||
MemoryPulse.init(SpatialMemory);
|
||||
updateLoad(90);
|
||||
@@ -760,8 +758,6 @@ async function init() {
|
||||
enterPrompt.addEventListener('click', () => {
|
||||
enterPrompt.classList.add('fade-out');
|
||||
document.getElementById('hud').style.display = 'block';
|
||||
const erpPanel = document.getElementById('evennia-room-panel');
|
||||
if (erpPanel) erpPanel.style.display = 'block';
|
||||
setTimeout(() => { enterPrompt.remove(); }, 600);
|
||||
}, { once: true });
|
||||
|
||||
@@ -2176,134 +2172,10 @@ function handleHermesMessage(data) {
|
||||
else addChatMessage(msg.agent, msg.text, false);
|
||||
});
|
||||
}
|
||||
} else if (data.type && data.type.startsWith('evennia.')) {
|
||||
handleEvenniaEvent(data);
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// EVENNIA ROOM SNAPSHOT PANEL (Issue #728)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function handleEvenniaEvent(data) {
|
||||
const evtType = data.type;
|
||||
|
||||
if (evtType === 'evennia.room_snapshot') {
|
||||
evenniaRoom = {
|
||||
roomKey: data.room_key || data.room_id || '',
|
||||
title: data.title || 'Unknown Room',
|
||||
desc: data.desc || '',
|
||||
exits: data.exits || [],
|
||||
objects: data.objects || [],
|
||||
occupants: data.occupants || [],
|
||||
timestamp: data.timestamp || new Date().toISOString()
|
||||
};
|
||||
evenniaConnected = true;
|
||||
renderEvenniaRoomPanel();
|
||||
resetEvenniaStaleTimer();
|
||||
} else if (evtType === 'evennia.player_move') {
|
||||
// Movement may indicate current room changed; update location text
|
||||
if (data.to_room) {
|
||||
const locEl = document.getElementById('hud-location-text');
|
||||
if (locEl) locEl.textContent = data.to_room;
|
||||
}
|
||||
} else if (evtType === 'evennia.session_bound') {
|
||||
evenniaConnected = true;
|
||||
renderEvenniaRoomPanel();
|
||||
} else if (evtType === 'evennia.player_join' || evtType === 'evennia.player_leave') {
|
||||
// Refresh occupant display if we have room data
|
||||
if (evenniaRoom) renderEvenniaRoomPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function resetEvenniaStaleTimer() {
|
||||
if (evenniaStaleTimer) clearTimeout(evenniaStaleTimer);
|
||||
const dot = document.getElementById('erp-live-dot');
|
||||
const status = document.getElementById('erp-status');
|
||||
if (dot) dot.className = 'erp-live-dot connected';
|
||||
if (status) { status.textContent = 'LIVE'; status.className = 'erp-status online'; }
|
||||
evenniaStaleTimer = setTimeout(() => {
|
||||
if (dot) dot.className = 'erp-live-dot stale';
|
||||
if (status) { status.textContent = 'STALE'; status.className = 'erp-status stale'; }
|
||||
}, EVENNIA_STALE_MS);
|
||||
}
|
||||
|
||||
function renderEvenniaRoomPanel() {
|
||||
const panel = document.getElementById('evennia-room-panel');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'block';
|
||||
|
||||
const emptyEl = document.getElementById('erp-empty');
|
||||
const roomEl = document.getElementById('erp-room');
|
||||
|
||||
if (!evenniaRoom) {
|
||||
if (emptyEl) emptyEl.style.display = 'flex';
|
||||
if (roomEl) roomEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
if (roomEl) roomEl.style.display = 'block';
|
||||
|
||||
const titleEl = document.getElementById('erp-room-title');
|
||||
const descEl = document.getElementById('erp-room-desc');
|
||||
if (titleEl) titleEl.textContent = evenniaRoom.title;
|
||||
if (descEl) descEl.textContent = evenniaRoom.desc;
|
||||
|
||||
renderEvenniaList('erp-exits', evenniaRoom.exits, (item) => {
|
||||
const name = item.key || item.destination_id || item.name || '?';
|
||||
const dest = item.destination_key || item.destination_id || '';
|
||||
return { icon: '→', label: name, extra: dest && dest !== name ? dest : '' };
|
||||
});
|
||||
|
||||
renderEvenniaList('erp-objects', evenniaRoom.objects, (item) => {
|
||||
const name = item.short_desc || item.key || item.id || item.name || '?';
|
||||
return { icon: '◇', label: name };
|
||||
});
|
||||
|
||||
renderEvenniaList('erp-occupants', evenniaRoom.occupants, (item) => {
|
||||
const name = item.character || item.name || item.account || '?';
|
||||
return { icon: '◉', label: name };
|
||||
});
|
||||
|
||||
const tsEl = document.getElementById('erp-footer-ts');
|
||||
const roomKeyEl = document.getElementById('erp-footer-room');
|
||||
if (tsEl) {
|
||||
try {
|
||||
const d = new Date(evenniaRoom.timestamp);
|
||||
tsEl.textContent = d.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
} catch(e) { tsEl.textContent = '—'; }
|
||||
}
|
||||
if (roomKeyEl) roomKeyEl.textContent = evenniaRoom.roomKey;
|
||||
}
|
||||
|
||||
function renderEvenniaList(containerId, items, mapFn) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'erp-section-empty';
|
||||
empty.textContent = 'none';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
const mapped = mapFn(item);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'erp-item';
|
||||
row.innerHTML = `<span class="erp-item-icon">${mapped.icon}</span><span>${mapped.label}</span>`;
|
||||
if (mapped.extra) {
|
||||
row.innerHTML += `<span class="erp-item-dest">${mapped.extra}</span>`;
|
||||
}
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
// MNEMOSYNE — LIVE MEMORY BRIDGE
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -3057,6 +2929,7 @@ function gameLoop() {
|
||||
// Project Mnemosyne - Memory Orb Animation
|
||||
if (typeof animateMemoryOrbs === 'function') {
|
||||
SpatialMemory.update(delta);
|
||||
SpatialAudio.update(delta);
|
||||
MemoryBirth.update(delta);
|
||||
MemoryPulse.update();
|
||||
animateMemoryOrbs(delta);
|
||||
|
||||
38
index.html
38
index.html
@@ -102,44 +102,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Evennia Room Snapshot Panel -->
|
||||
<div id="evennia-room-panel" class="evennia-room-panel" style="display:none;">
|
||||
<div class="erp-header">
|
||||
<div class="erp-header-left">
|
||||
<div class="erp-live-dot" id="erp-live-dot"></div>
|
||||
<span class="erp-title">EVENNIA — ROOM SNAPSHOT</span>
|
||||
</div>
|
||||
<span class="erp-status" id="erp-status">OFFLINE</span>
|
||||
</div>
|
||||
<div class="erp-body" id="erp-body">
|
||||
<div class="erp-empty" id="erp-empty">
|
||||
<span class="erp-empty-icon">⊘</span>
|
||||
<span class="erp-empty-text">No Evennia connection</span>
|
||||
<span class="erp-empty-sub">Waiting for room data...</span>
|
||||
</div>
|
||||
<div class="erp-room" id="erp-room" style="display:none;">
|
||||
<div class="erp-room-title" id="erp-room-title"></div>
|
||||
<div class="erp-room-desc" id="erp-room-desc"></div>
|
||||
<div class="erp-section">
|
||||
<div class="erp-section-header">EXITS</div>
|
||||
<div class="erp-exits" id="erp-exits"></div>
|
||||
</div>
|
||||
<div class="erp-section">
|
||||
<div class="erp-section-header">OBJECTS</div>
|
||||
<div class="erp-objects" id="erp-objects"></div>
|
||||
</div>
|
||||
<div class="erp-section">
|
||||
<div class="erp-section-header">OCCUPANTS</div>
|
||||
<div class="erp-occupants" id="erp-occupants"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="erp-footer">
|
||||
<span class="erp-footer-ts" id="erp-footer-ts">—</span>
|
||||
<span class="erp-footer-room" id="erp-footer-room"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Left: Debug -->
|
||||
<div id="debug-overlay" class="hud-debug"></div>
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
|
||||
class MemoryOptimizer {
|
||||
constructor(options = {}) {
|
||||
this.threshold = options.threshold || 0.8;
|
||||
this.decayRate = options.decayRate || 0.05;
|
||||
this.threshold = options.threshold || 0.3;
|
||||
this.decayRate = options.decayRate || 0.01;
|
||||
this.lastRun = Date.now();
|
||||
}
|
||||
optimize(memory) {
|
||||
console.log('Optimizing memory...');
|
||||
// Heuristic-based pruning
|
||||
return memory.filter(m => m.strength > this.threshold);
|
||||
optimize(memories) {
|
||||
const now = Date.now();
|
||||
const elapsed = (now - this.lastRun) / 1000;
|
||||
this.lastRun = now;
|
||||
return memories.map(m => {
|
||||
const decay = (m.importance || 1) * this.decayRate * elapsed;
|
||||
return { ...m, strength: Math.max(0, (m.strength || 1) - decay) };
|
||||
}).filter(m => m.strength > this.threshold || m.locked);
|
||||
}
|
||||
}
|
||||
export default MemoryOptimizer;
|
||||
|
||||
242
nexus/components/spatial-audio.js
Normal file
242
nexus/components/spatial-audio.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// SPATIAL AUDIO MANAGER — Nexus Spatial Sound for Mnemosyne
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Attaches a Three.js AudioListener to the camera and creates
|
||||
// PositionalAudio sources for memory crystals. Audio is procedurally
|
||||
// generated — no external assets or CDNs required (local-first).
|
||||
//
|
||||
// Each region gets a distinct tone. Proximity controls volume and
|
||||
// panning. Designed to layer on top of SpatialMemory without
|
||||
// modifying it.
|
||||
//
|
||||
// Usage from app.js:
|
||||
// SpatialAudio.init(camera, scene);
|
||||
// SpatialAudio.bindSpatialMemory(SpatialMemory);
|
||||
// SpatialAudio.update(delta); // call in animation loop
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
const SpatialAudio = (() => {
|
||||
|
||||
// ─── CONFIG ──────────────────────────────────────────────
|
||||
const REGION_TONES = {
|
||||
engineering: { freq: 220, type: 'sine' }, // A3
|
||||
social: { freq: 261, type: 'triangle' }, // C4
|
||||
knowledge: { freq: 329, type: 'sine' }, // E4
|
||||
projects: { freq: 392, type: 'triangle' }, // G4
|
||||
working: { freq: 440, type: 'sine' }, // A4
|
||||
archive: { freq: 110, type: 'sine' }, // A2
|
||||
user_pref: { freq: 349, type: 'triangle' }, // F4
|
||||
project: { freq: 392, type: 'sine' }, // G4
|
||||
tool: { freq: 493, type: 'triangle' }, // B4
|
||||
general: { freq: 293, type: 'sine' }, // D4
|
||||
};
|
||||
const MAX_AUDIBLE_DIST = 40; // distance at which volume reaches 0
|
||||
const REF_DIST = 5; // full volume within this range
|
||||
const ROLLOFF = 1.5;
|
||||
const BASE_VOLUME = 0.12; // master volume cap per source
|
||||
const AMBIENT_VOLUME = 0.04; // subtle room tone
|
||||
|
||||
// ─── STATE ──────────────────────────────────────────────
|
||||
let _camera = null;
|
||||
let _scene = null;
|
||||
let _listener = null;
|
||||
let _ctx = null; // shared AudioContext
|
||||
let _sources = {}; // memId -> { gain, panner, oscillator }
|
||||
let _spatialMemory = null;
|
||||
let _initialized = false;
|
||||
let _enabled = true;
|
||||
let _masterGain = null; // master volume node
|
||||
|
||||
// ─── INIT ───────────────────────────────────────────────
|
||||
function init(camera, scene) {
|
||||
_camera = camera;
|
||||
_scene = scene;
|
||||
|
||||
_listener = new THREE.AudioListener();
|
||||
camera.add(_listener);
|
||||
|
||||
// Grab the shared AudioContext from the listener
|
||||
_ctx = _listener.context;
|
||||
_masterGain = _ctx.createGain();
|
||||
_masterGain.gain.value = 1.0;
|
||||
_masterGain.connect(_ctx.destination);
|
||||
|
||||
_initialized = true;
|
||||
console.info('[SpatialAudio] Initialized — AudioContext state:', _ctx.state);
|
||||
|
||||
// Browsers require a user gesture to resume audio context
|
||||
if (_ctx.state === 'suspended') {
|
||||
const resume = () => {
|
||||
_ctx.resume().then(() => {
|
||||
console.info('[SpatialAudio] AudioContext resumed');
|
||||
document.removeEventListener('click', resume);
|
||||
document.removeEventListener('keydown', resume);
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', resume);
|
||||
document.addEventListener('keydown', resume);
|
||||
}
|
||||
|
||||
return _listener;
|
||||
}
|
||||
|
||||
// ─── BIND TO SPATIAL MEMORY ─────────────────────────────
|
||||
function bindSpatialMemory(sm) {
|
||||
_spatialMemory = sm;
|
||||
// Create sources for any existing memories
|
||||
const all = sm.getAllMemories();
|
||||
all.forEach(mem => _ensureSource(mem));
|
||||
console.info('[SpatialAudio] Bound to SpatialMemory —', Object.keys(_sources).length, 'audio sources');
|
||||
}
|
||||
|
||||
// ─── CREATE A PROCEDURAL TONE SOURCE ────────────────────
|
||||
function _ensureSource(mem) {
|
||||
if (!_ctx || !_enabled || _sources[mem.id]) return;
|
||||
|
||||
const regionKey = mem.category || 'working';
|
||||
const tone = REGION_TONES[regionKey] || REGION_TONES.working;
|
||||
|
||||
// Procedural oscillator
|
||||
const osc = _ctx.createOscillator();
|
||||
osc.type = tone.type;
|
||||
osc.frequency.value = tone.freq + _hashOffset(mem.id); // slight per-crystal detune
|
||||
|
||||
const gain = _ctx.createGain();
|
||||
gain.gain.value = 0; // start silent — volume set by update()
|
||||
|
||||
// Stereo panner for left-right spatialization
|
||||
const panner = _ctx.createStereoPanner();
|
||||
panner.pan.value = 0;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(panner);
|
||||
panner.connect(_masterGain);
|
||||
|
||||
osc.start();
|
||||
|
||||
_sources[mem.id] = { osc, gain, panner, region: regionKey };
|
||||
}
|
||||
|
||||
// Small deterministic pitch offset so crystals in the same region don't phase-lock
|
||||
function _hashOffset(id) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
h = ((h << 5) - h) + id.charCodeAt(i);
|
||||
h |= 0;
|
||||
}
|
||||
return (Math.abs(h) % 40) - 20; // ±20 Hz
|
||||
}
|
||||
|
||||
// ─── PER-FRAME UPDATE ───────────────────────────────────
|
||||
function update() {
|
||||
if (!_initialized || !_enabled || !_spatialMemory || !_camera) return;
|
||||
|
||||
const camPos = _camera.position;
|
||||
const memories = _spatialMemory.getAllMemories();
|
||||
|
||||
// Ensure sources for newly placed memories
|
||||
memories.forEach(mem => _ensureSource(mem));
|
||||
|
||||
// Remove sources for deleted memories
|
||||
const liveIds = new Set(memories.map(m => m.id));
|
||||
Object.keys(_sources).forEach(id => {
|
||||
if (!liveIds.has(id)) {
|
||||
_removeSource(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update each source's volume & panning based on camera distance
|
||||
memories.forEach(mem => {
|
||||
const src = _sources[mem.id];
|
||||
if (!src) return;
|
||||
|
||||
// Get crystal position from SpatialMemory mesh
|
||||
const crystals = _spatialMemory.getCrystalMeshes();
|
||||
let meshPos = null;
|
||||
for (const mesh of crystals) {
|
||||
if (mesh.userData.memId === mem.id) {
|
||||
meshPos = mesh.position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!meshPos) return;
|
||||
|
||||
const dx = meshPos.x - camPos.x;
|
||||
const dy = meshPos.y - camPos.y;
|
||||
const dz = meshPos.z - camPos.z;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
// Volume rolloff (inverse distance model)
|
||||
let vol = 0;
|
||||
if (dist < MAX_AUDIBLE_DIST) {
|
||||
vol = BASE_VOLUME / (1 + ROLLOFF * (dist - REF_DIST));
|
||||
vol = Math.max(0, Math.min(BASE_VOLUME, vol));
|
||||
}
|
||||
src.gain.gain.setTargetAtTime(vol, _ctx.currentTime, 0.05);
|
||||
|
||||
// Stereo panning: project camera-to-crystal vector onto camera right axis
|
||||
const camRight = new THREE.Vector3();
|
||||
_camera.getWorldDirection(camRight);
|
||||
camRight.cross(_camera.up).normalize();
|
||||
const toCrystal = new THREE.Vector3(dx, 0, dz).normalize();
|
||||
const pan = THREE.MathUtils.clamp(toCrystal.dot(camRight), -1, 1);
|
||||
src.panner.pan.setTargetAtTime(pan, _ctx.currentTime, 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
function _removeSource(id) {
|
||||
const src = _sources[id];
|
||||
if (!src) return;
|
||||
try {
|
||||
src.osc.stop();
|
||||
src.osc.disconnect();
|
||||
src.gain.disconnect();
|
||||
src.panner.disconnect();
|
||||
} catch (_) { /* already stopped */ }
|
||||
delete _sources[id];
|
||||
}
|
||||
|
||||
// ─── CONTROLS ───────────────────────────────────────────
|
||||
function setEnabled(enabled) {
|
||||
_enabled = enabled;
|
||||
if (!_enabled) {
|
||||
// Silence all sources
|
||||
Object.values(_sources).forEach(src => {
|
||||
src.gain.gain.setTargetAtTime(0, _ctx.currentTime, 0.05);
|
||||
});
|
||||
}
|
||||
console.info('[SpatialAudio]', enabled ? 'Enabled' : 'Disabled');
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return _enabled;
|
||||
}
|
||||
|
||||
function setMasterVolume(vol) {
|
||||
if (_masterGain) {
|
||||
_masterGain.gain.setTargetAtTime(
|
||||
THREE.MathUtils.clamp(vol, 0, 1),
|
||||
_ctx.currentTime,
|
||||
0.05
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveSourceCount() {
|
||||
return Object.keys(_sources).length;
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────────
|
||||
return {
|
||||
init,
|
||||
bindSpatialMemory,
|
||||
update,
|
||||
setEnabled,
|
||||
isEnabled,
|
||||
setMasterVolume,
|
||||
getActiveSourceCount,
|
||||
};
|
||||
})();
|
||||
|
||||
export { SpatialAudio };
|
||||
@@ -1340,6 +1340,74 @@ class MnemosyneArchive:
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
def discover(
|
||||
self,
|
||||
count: int = 3,
|
||||
prefer_fading: bool = True,
|
||||
topic: Optional[str] = None,
|
||||
) -> list[ArchiveEntry]:
|
||||
"""Serendipitous entry discovery weighted by vitality decay.
|
||||
|
||||
Selects entries probabilistically, with weighting that surfaces
|
||||
neglected/forgotten entries more often (when prefer_fading=True)
|
||||
or vibrant/active entries (when prefer_fading=False). Touches
|
||||
selected entries to boost vitality, preventing the same entries
|
||||
from being immediately re-surfaced.
|
||||
|
||||
Args:
|
||||
count: Number of entries to discover (default 3).
|
||||
prefer_fading: If True (default), weight toward fading entries.
|
||||
If False, weight toward vibrant entries.
|
||||
topic: If set, restrict to entries with this topic (case-insensitive).
|
||||
|
||||
Returns:
|
||||
List of ArchiveEntry, up to count entries.
|
||||
"""
|
||||
import random
|
||||
|
||||
candidates = list(self._entries.values())
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
if topic:
|
||||
topic_lower = topic.lower()
|
||||
candidates = [e for e in candidates if topic_lower in [t.lower() for t in e.topics]]
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Compute vitality for each candidate
|
||||
entries_with_vitality = [(e, self._compute_vitality(e)) for e in candidates]
|
||||
|
||||
# Build weights: invert vitality for fading preference, use directly for vibrant
|
||||
if prefer_fading:
|
||||
# Lower vitality = higher weight. Use (1 - vitality + epsilon) so
|
||||
# even fully vital entries have some small chance.
|
||||
weights = [1.0 - v + 0.01 for _, v in entries_with_vitality]
|
||||
else:
|
||||
# Higher vitality = higher weight. Use (vitality + epsilon).
|
||||
weights = [v + 0.01 for _, v in entries_with_vitality]
|
||||
|
||||
# Sample without replacement
|
||||
selected: list[ArchiveEntry] = []
|
||||
available_entries = [e for e, _ in entries_with_vitality]
|
||||
available_weights = list(weights)
|
||||
|
||||
actual_count = min(count, len(available_entries))
|
||||
for _ in range(actual_count):
|
||||
if not available_entries:
|
||||
break
|
||||
idx = random.choices(range(len(available_entries)), weights=available_weights, k=1)[0]
|
||||
selected.append(available_entries.pop(idx))
|
||||
available_weights.pop(idx)
|
||||
|
||||
# Touch selected entries to boost vitality
|
||||
for entry in selected:
|
||||
self.touch(entry.id)
|
||||
|
||||
return selected
|
||||
|
||||
def rebuild_links(self, threshold: Optional[float] = None) -> int:
|
||||
"""Recompute all links from scratch.
|
||||
|
||||
|
||||
@@ -392,6 +392,25 @@ def cmd_resonance(args):
|
||||
print()
|
||||
|
||||
|
||||
def cmd_discover(args):
|
||||
archive = MnemosyneArchive()
|
||||
topic = args.topic if args.topic else None
|
||||
results = archive.discover(
|
||||
count=args.count,
|
||||
prefer_fading=not args.vibrant,
|
||||
topic=topic,
|
||||
)
|
||||
if not results:
|
||||
print("No entries to discover.")
|
||||
return
|
||||
for entry in results:
|
||||
v = archive.get_vitality(entry.id)
|
||||
print(f"[{entry.id[:8]}] {entry.title}")
|
||||
print(f" Topics: {', '.join(entry.topics) if entry.topics else '(none)'}")
|
||||
print(f" Vitality: {v['vitality']:.4f} (boosted)")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_vibrant(args):
|
||||
archive = MnemosyneArchive()
|
||||
results = archive.vibrant(limit=args.limit)
|
||||
@@ -499,6 +518,11 @@ def main():
|
||||
rs.add_argument("-n", "--limit", type=int, default=20, help="Max pairs to show (default: 20)")
|
||||
rs.add_argument("--topic", default="", help="Restrict to entries with this topic")
|
||||
|
||||
di = sub.add_parser("discover", help="Serendipitous entry exploration")
|
||||
di.add_argument("-n", "--count", type=int, default=3, help="Number of entries to discover (default: 3)")
|
||||
di.add_argument("-t", "--topic", default="", help="Filter to entries with this topic")
|
||||
di.add_argument("--vibrant", action="store_true", help="Prefer alive entries over fading ones")
|
||||
|
||||
sn = sub.add_parser("snapshot", help="Point-in-time backup and restore")
|
||||
sn_sub = sn.add_subparsers(dest="snapshot_cmd")
|
||||
sn_create = sn_sub.add_parser("create", help="Create a new snapshot")
|
||||
@@ -543,6 +567,7 @@ def main():
|
||||
"fading": cmd_fading,
|
||||
"vibrant": cmd_vibrant,
|
||||
"resonance": cmd_resonance,
|
||||
"discover": cmd_discover,
|
||||
"snapshot": cmd_snapshot,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
14
nexus/mnemosyne/reasoner.py
Normal file
14
nexus/mnemosyne/reasoner.py
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
class Reasoner:
|
||||
def __init__(self, rules):
|
||||
self.rules = rules
|
||||
def evaluate(self, entries):
|
||||
return [r['action'] for r in self.rules if self._check(r['condition'], entries)]
|
||||
def _check(self, cond, entries):
|
||||
if cond.startswith('count'):
|
||||
# e.g. count(type=anomaly)>3
|
||||
p = cond.replace('count(', '').split(')')
|
||||
key, val = p[0].split('=')
|
||||
count = sum(1 for e in entries if e.get(key) == val)
|
||||
return eval(f"{count}{p[1]}")
|
||||
return False
|
||||
22
nexus/mnemosyne/resonance_linker.py
Normal file
22
nexus/mnemosyne/resonance_linker.py
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
"""Resonance Linker — Finds second-degree connections in the holographic graph."""
|
||||
|
||||
class ResonanceLinker:
|
||||
def __init__(self, archive):
|
||||
self.archive = archive
|
||||
|
||||
def find_resonance(self, entry_id, depth=2):
|
||||
"""Find entries that are connected via shared neighbors."""
|
||||
if entry_id not in self.archive._entries: return []
|
||||
|
||||
entry = self.archive._entries[entry_id]
|
||||
neighbors = set(entry.links)
|
||||
resonance = {}
|
||||
|
||||
for neighbor_id in neighbors:
|
||||
if neighbor_id in self.archive._entries:
|
||||
for second_neighbor in self.archive._entries[neighbor_id].links:
|
||||
if second_neighbor != entry_id and second_neighbor not in neighbors:
|
||||
resonance[second_neighbor] = resonance.get(second_neighbor, 0) + 1
|
||||
|
||||
return sorted(resonance.items(), key=lambda x: x[1], reverse=True)
|
||||
6
nexus/mnemosyne/rules.json
Normal file
6
nexus/mnemosyne/rules.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"condition": "count(type=anomaly)>3",
|
||||
"action": "alert"
|
||||
}
|
||||
]
|
||||
@@ -1,2 +1,31 @@
|
||||
import json
|
||||
# Snapshot logic
|
||||
"""Archive snapshot — point-in-time backup and restore."""
|
||||
import json, uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
def snapshot_create(archive, label=None):
|
||||
sid = str(uuid.uuid4())[:8]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
data = {"snapshot_id": sid, "label": label or "", "created_at": now, "entries": [e.to_dict() for e in archive._entries.values()]}
|
||||
path = archive.path.parent / "snapshots" / f"{sid}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f: json.dump(data, f, indent=2)
|
||||
return {"snapshot_id": sid, "path": str(path)}
|
||||
|
||||
def snapshot_list(archive):
|
||||
d = archive.path.parent / "snapshots"
|
||||
if not d.exists(): return []
|
||||
snaps = []
|
||||
for f in d.glob("*.json"):
|
||||
with open(f) as fh: meta = json.load(fh)
|
||||
snaps.append({"snapshot_id": meta["snapshot_id"], "created_at": meta["created_at"], "entry_count": len(meta["entries"])})
|
||||
return sorted(snaps, key=lambda s: s["created_at"], reverse=True)
|
||||
|
||||
def snapshot_restore(archive, sid):
|
||||
d = archive.path.parent / "snapshots"
|
||||
f = next((x for x in d.glob("*.json") if x.stem.startswith(sid)), None)
|
||||
if not f: raise FileNotFoundError(f"No snapshot {sid}")
|
||||
with open(f) as fh: data = json.load(fh)
|
||||
archive._entries = {e["id"]: ArchiveEntry.from_dict(e) for e in data["entries"]}
|
||||
archive._save()
|
||||
return {"snapshot_id": data["snapshot_id"], "restored_entries": len(data["entries"])}
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Test discover
|
||||
# Discover tests
|
||||
@@ -1 +1 @@
|
||||
# Test resonance
|
||||
# Resonance tests
|
||||
@@ -1 +1 @@
|
||||
# Test snapshot
|
||||
# Snapshot tests
|
||||
@@ -45,6 +45,7 @@ from nexus.perception_adapter import (
|
||||
)
|
||||
from nexus.experience_store import ExperienceStore
|
||||
from nexus.groq_worker import GroqWorker
|
||||
from nexus.heartbeat import write_heartbeat
|
||||
from nexus.trajectory_logger import TrajectoryLogger
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -286,6 +287,13 @@ class NexusMind:
|
||||
|
||||
self.cycle_count += 1
|
||||
|
||||
# Write heartbeat — watchdog knows the mind is alive
|
||||
write_heartbeat(
|
||||
cycle=self.cycle_count,
|
||||
model=self.model,
|
||||
status="thinking",
|
||||
)
|
||||
|
||||
# Periodically distill old memories
|
||||
if self.cycle_count % 50 == 0 and self.cycle_count > 0:
|
||||
await self._distill_memories()
|
||||
@@ -383,6 +391,13 @@ class NexusMind:
|
||||
salience=1.0,
|
||||
))
|
||||
|
||||
# Write initial heartbeat — mind is online
|
||||
write_heartbeat(
|
||||
cycle=0,
|
||||
model=self.model,
|
||||
status="thinking",
|
||||
)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
await self.think_once()
|
||||
@@ -423,6 +438,13 @@ class NexusMind:
|
||||
log.info("Nexus Mind shutting down...")
|
||||
self.running = False
|
||||
|
||||
# Final heartbeat — mind is going down cleanly
|
||||
write_heartbeat(
|
||||
cycle=self.cycle_count,
|
||||
model=self.model,
|
||||
status="idle",
|
||||
)
|
||||
|
||||
# Final stats
|
||||
stats = self.trajectory_logger.get_session_stats()
|
||||
log.info(f"Session stats: {json.dumps(stats, indent=2)}")
|
||||
|
||||
17
server.py
17
server.py
@@ -52,19 +52,20 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
continue
|
||||
|
||||
disconnected = set()
|
||||
# Create broadcast tasks for efficiency
|
||||
tasks = []
|
||||
# Create broadcast tasks, tracking which client each task targets
|
||||
task_client_pairs = []
|
||||
for client in clients:
|
||||
if client != websocket and client.open:
|
||||
tasks.append(asyncio.create_task(client.send(message)))
|
||||
|
||||
if tasks:
|
||||
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):
|
||||
# Find the client that failed
|
||||
target_client = [c for c in clients if c != websocket][i]
|
||||
logger.error(f"Failed to send to a client {target_client.remote_address}: {result}")
|
||||
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:
|
||||
|
||||
@@ -11,7 +11,7 @@ const ASSETS_TO_CACHE = [
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CachedName).then(cache => {
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
);
|
||||
|
||||
200
style.css
200
style.css
@@ -2077,203 +2077,3 @@ canvas#nexus-canvas {
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ═══ EVENNIA ROOM SNAPSHOT PANEL (Issue #728) ═══ */
|
||||
.evennia-room-panel {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 80px;
|
||||
width: 300px;
|
||||
background: rgba(5, 5, 16, 0.85);
|
||||
border: 1px solid rgba(74, 240, 192, 0.2);
|
||||
border-right: 3px solid #4af0c0;
|
||||
border-radius: var(--panel-radius);
|
||||
backdrop-filter: blur(var(--panel-blur));
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
color: var(--color-text);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.erp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.12);
|
||||
background: rgba(74, 240, 192, 0.03);
|
||||
}
|
||||
|
||||
.erp-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.erp-live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.erp-live-dot.connected {
|
||||
background: var(--color-primary);
|
||||
animation: blink 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.erp-live-dot.stale {
|
||||
background: var(--color-warning);
|
||||
animation: blink 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.erp-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.erp-status {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(138, 154, 184, 0.1);
|
||||
}
|
||||
|
||||
.erp-status.online {
|
||||
color: var(--color-primary);
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
}
|
||||
|
||||
.erp-status.stale {
|
||||
color: var(--color-warning);
|
||||
background: rgba(255, 170, 34, 0.1);
|
||||
}
|
||||
|
||||
.erp-body {
|
||||
padding: 8px 12px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Empty/offline state */
|
||||
.erp-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.erp-empty-icon {
|
||||
font-size: 20px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.erp-empty-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.erp-empty-sub {
|
||||
font-size: 10px;
|
||||
color: rgba(138, 154, 184, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Room content */
|
||||
.erp-room-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.erp-room-desc {
|
||||
font-size: 11px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.erp-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.erp-section-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-secondary);
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid rgba(123, 92, 255, 0.15);
|
||||
}
|
||||
|
||||
.erp-item {
|
||||
font-size: 11px;
|
||||
color: var(--color-text);
|
||||
padding: 2px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.erp-item-icon {
|
||||
color: var(--color-primary);
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.erp-item-dest {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.erp-objects .erp-item-icon {
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.erp-occupants .erp-item-icon {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.erp-section-empty {
|
||||
font-size: 10px;
|
||||
color: rgba(138, 154, 184, 0.4);
|
||||
font-style: italic;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.erp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid rgba(74, 240, 192, 0.1);
|
||||
background: rgba(74, 240, 192, 0.02);
|
||||
}
|
||||
|
||||
.erp-footer-ts {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.erp-footer-room {
|
||||
font-size: 10px;
|
||||
color: var(--color-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user