Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
6ebf54d457 fix: closes #731
Some checks failed
CI / test (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 3s
2026-04-12 12:39:23 -04:00
37 changed files with 207 additions and 3779 deletions

View File

@@ -15,3 +15,54 @@ 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

30
CONTRIBUTORING.md Normal file
View File

@@ -0,0 +1,30 @@
# Contribution & Review Policy
## Branch Protection Rules
All repositories must enforce these rules on the `main` branch:
- ✅ Pull Request Required for Merge
- ✅ Minimum 1 Approved Review
- ✅ CI/CD Must Pass
- ✅ Dismiss Stale Approvals
- ✅ Block Force Pushes
- ✅ Block Deletion
## Review Requirements
All pull requests must:
1. Be reviewed by @perplexity (QA gate)
2. Be reviewed by @Timmy for hermes-agent
3. Get at least one additional reviewer based on code area
## CI Requirements
- hermes-agent: Must pass all CI checks
- the-nexus: CI required once runner is restored
- timmy-home & timmy-config: No CI enforcement
## Enforcement
These rules are enforced via Gitea branch protection settings. See your repo settings > Branches for details.
For code-specific ownership, see .gitea/Codowners

View File

@@ -3,18 +3,13 @@ FROM python:3.11-slim
WORKDIR /app
# Install Python deps
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt websockets
# Backend
COPY nexus/ nexus/
COPY server.py ./
COPY server.py .
COPY portals.json vision.json ./
COPY robots.txt ./
COPY index.html help.html ./
# 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 ./
RUN pip install --no-cache-dir websockets
EXPOSE 8765

309
app.js
View File

@@ -4,7 +4,6 @@ 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';
@@ -59,11 +58,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;
@@ -91,11 +85,6 @@ let flyY = 2;
// ═══ INIT ═══
import {
SymbolicEngine, AgentFSM, KnowledgeGraph, Blackboard,
SymbolicPlanner, HTNPlanner, CaseBasedReasoner,
NeuroSymbolicBridge, MetaReasoningLayer
} from './nexus/symbolic-engine.js';
// ═══ SOVEREIGN SYMBOLIC ENGINE (GOFAI) ═══
class SymbolicEngine {
constructor() {
@@ -119,8 +108,8 @@ class SymbolicEngine {
}
}
addRule(condition, action, description, triggerFacts = []) {
this.rules.push({ condition, action, description, triggerFacts });
addRule(condition, action, description) {
this.rules.push({ condition, action, description });
}
reason() {
@@ -415,7 +404,6 @@ class NeuroSymbolicBridge {
}
perceive(rawState) {
Object.entries(rawState).forEach(([key, value]) => this.engine.addFact(key, value));
const concepts = [];
if (rawState.stability < 0.4 && rawState.energy > 60) concepts.push('UNSTABLE_OSCILLATION');
if (rawState.energy < 30 && rawState.activePortals > 2) concepts.push('CRITICAL_DRAIN_PATTERN');
@@ -586,6 +574,7 @@ class PSELayer {
constructor() {
this.worker = new Worker('gofai_worker.js');
this.worker.onmessage = (e) => this.handleWorkerMessage(e);
this.pendingRequests = new Map();
}
handleWorkerMessage(e) {
@@ -623,7 +612,7 @@ function setupGOFAI() {
l402Client = new L402Client();
nostrAgent.announce({ name: "Timmy Nexus Agent", capabilities: ["GOFAI", "L402"] });
pseLayer = new PSELayer();
calibrator = new AdaptiveCalibrator('nexus-v1', { base_rate: 0.05 });\n MemoryOptimizer.blackboard = blackboard;
calibrator = new AdaptiveCalibrator('nexus-v1', { base_rate: 0.05 });
// Setup initial facts
symbolicEngine.addFact('energy', 100);
@@ -632,9 +621,6 @@ function setupGOFAI() {
// Setup FSM
agentFSMs['timmy'] = new AgentFSM('timmy', 'IDLE');
agentFSMs['timmy'].addTransition('IDLE', 'ANALYZING', (facts) => facts.get('activePortals') > 0);
symbolicEngine.addRule((facts) => facts.get('UNSTABLE_OSCILLATION'), () => 'STABILIZE MATRIX', 'Unstable oscillation demands stabilization', ['UNSTABLE_OSCILLATION']);
symbolicEngine.addRule((facts) => facts.get('CRITICAL_DRAIN_PATTERN'), () => 'SHED PORTAL LOAD', 'Critical drain demands portal shedding', ['CRITICAL_DRAIN_PATTERN']);
// Setup Planner
symbolicPlanner.addAction('Stabilize Matrix', { energy: 50 }, { stability: 1.0 });
@@ -645,13 +631,11 @@ function updateGOFAI(delta, elapsed) {
// Simulate perception
neuroBridge.perceive({ stability: 0.3, energy: 80, activePortals: 1 });
agentFSMs['timmy']?.update(symbolicEngine.facts);
// Run reasoning
if (Math.floor(elapsed * 2) > Math.floor((elapsed - delta) * 2)) {
symbolicEngine.reason();
pseLayer.offloadReasoning(Array.from(symbolicEngine.facts.entries()), symbolicEngine.rules.map((r) => ({ description: r.description, triggerFacts: r.triggerFacts })));
pseLayer.offloadPlanning(Object.fromEntries(symbolicEngine.facts), { stability: 1.0 }, symbolicPlanner.actions);
pseLayer.offloadReasoning(Array.from(symbolicEngine.facts.entries()), symbolicEngine.rules.map(r => ({ description: r.description })));
document.getElementById("pse-task-count").innerText = parseInt(document.getElementById("pse-task-count").innerText) + 1;
metaLayer.reflect();
@@ -720,19 +704,17 @@ async function init() {
createParticles();
createDustParticles();
updateLoad(85);
if (performanceTier !== "low") createAmbientStructures();
createAmbientStructures();
createAgentPresences();
if (performanceTier !== "low") createThoughtStream();
createThoughtStream();
createHarnessPulse();
createSessionPowerMeter();
createWorkshopTerminal();
if (performanceTier !== "low") createAshStorm();
createAshStorm();
SpatialMemory.init(scene);
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);
@@ -748,20 +730,14 @@ async function init() {
fetchGiteaData();
setInterval(fetchGiteaData, 30000); // Refresh every 30s
// Quality-tier feature gating: only enable heavy post-processing on medium/high
if (performanceTier !== 'low') {
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomStrength = performanceTier === 'high' ? 0.6 : 0.35;
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
bloomStrength, 0.4, 0.85
);
composer.addPass(bloom);
composer.addPass(new SMAAPass(window.innerWidth, window.innerHeight));
} else {
composer = null;
}
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.6, 0.4, 0.85
);
composer.addPass(bloom);
composer.addPass(new SMAAPass(window.innerWidth, window.innerHeight));
updateLoad(95);
@@ -779,8 +755,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 });
@@ -1579,22 +1553,15 @@ function createPortal(config) {
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 512;
labelCanvas.height = 96;
labelCanvas.height = 64;
const lctx = labelCanvas.getContext('2d');
lctx.font = 'bold 32px "Orbitron", sans-serif';
lctx.fillStyle = '#' + portalColor.getHexString();
lctx.textAlign = 'center';
lctx.fillText(`${config.name.toUpperCase()}`, 256, 36);
// Role tag (timmy/reflex/pilot) — defines portal ownership boundary
if (config.role) {
const roleColors = { timmy: '#4af0c0', reflex: '#ff4466', pilot: '#ffd700' };
lctx.font = 'bold 18px "Orbitron", sans-serif';
lctx.fillStyle = roleColors[config.role] || '#888888';
lctx.fillText(config.role.toUpperCase(), 256, 68);
}
lctx.fillText(`${config.name.toUpperCase()}`, 256, 42);
const labelTex = new THREE.CanvasTexture(labelCanvas);
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide });
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.75), labelMat);
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.5), labelMat);
labelMesh.position.y = 7.5;
group.add(labelMesh);
@@ -2065,7 +2032,6 @@ function setupControls() {
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
initAtlasControls();
}
function sendChatMessage(overrideText = null) {
@@ -2203,134 +2169,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
// ═══════════════════════════════════════════
@@ -2973,144 +2815,58 @@ function closeVisionOverlay() {
document.getElementById('vision-overlay').style.display = 'none';
}
// ═══ PORTAL ATLAS / WORLD DIRECTORY ═══
let atlasActiveFilter = 'all';
let atlasSearchQuery = '';
// ═══ PORTAL ATLAS ═══
function openPortalAtlas() {
atlasOverlayActive = true;
document.getElementById('atlas-overlay').style.display = 'flex';
populateAtlas();
// Focus search input
setTimeout(() => document.getElementById('atlas-search')?.focus(), 100);
}
function closePortalAtlas() {
atlasOverlayActive = false;
document.getElementById('atlas-overlay').style.display = 'none';
atlasSearchQuery = '';
atlasActiveFilter = 'all';
}
function initAtlasControls() {
const searchInput = document.getElementById('atlas-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
atlasSearchQuery = e.target.value.toLowerCase().trim();
populateAtlas();
});
}
const filterBtns = document.querySelectorAll('.atlas-filter-btn');
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
atlasActiveFilter = btn.dataset.filter;
populateAtlas();
});
});
}
function matchesAtlasFilter(config) {
if (atlasActiveFilter === 'all') return true;
if (atlasActiveFilter === 'harness') return (config.portal_type || 'harness') === 'harness' || !config.portal_type;
if (atlasActiveFilter === 'game-world') return config.portal_type === 'game-world';
return config.status === atlasActiveFilter;
}
function matchesAtlasSearch(config) {
if (!atlasSearchQuery) return true;
const haystack = [config.name, config.description, config.id,
config.world_category, config.portal_type, config.destination?.type]
.filter(Boolean).join(' ').toLowerCase();
return haystack.includes(atlasSearchQuery);
}
function populateAtlas() {
const grid = document.getElementById('atlas-grid');
grid.innerHTML = '';
let onlineCount = 0;
let standbyCount = 0;
let downloadedCount = 0;
let visibleCount = 0;
portals.forEach(portal => {
const config = portal.config;
if (config.status === 'online') onlineCount++;
if (config.status === 'standby') standbyCount++;
if (config.status === 'downloaded') downloadedCount++;
if (!matchesAtlasFilter(config) || !matchesAtlasSearch(config)) return;
visibleCount++;
const card = document.createElement('div');
card.className = 'atlas-card';
card.style.setProperty('--portal-color', config.color);
const statusClass = `status-${config.status || 'online'}`;
const statusLabel = (config.status || 'ONLINE').toUpperCase();
const portalType = config.portal_type || 'harness';
const categoryLabel = config.world_category
? config.world_category.replace(/-/g, ' ').toUpperCase()
: portalType.replace(/-/g, ' ').toUpperCase();
// Readiness bar for game-worlds
let readinessHTML = '';
if (config.readiness_steps) {
const steps = Object.values(config.readiness_steps);
readinessHTML = `<div class="atlas-card-readiness" title="Readiness: ${steps.filter(s=>s.done).length}/${steps.length}">`;
steps.forEach(step => {
readinessHTML += `<div class="readiness-step ${step.done ? 'done' : ''}" title="${step.label}${step.done ? ' ✓' : ''}"></div>`;
});
readinessHTML += '</div>';
}
// Action label
const actionLabel = config.destination?.action_label
|| (config.status === 'online' ? 'ENTER' : config.status === 'downloaded' ? 'LAUNCH' : 'VIEW');
card.innerHTML = `
<div class="atlas-card-header">
<div>
<span class="atlas-card-name">${config.name}</span>
<span class="atlas-card-category">${categoryLabel}</span>
</div>
<div class="atlas-card-status ${statusClass}">${statusLabel}</div>
<div class="atlas-card-name">${config.name}</div>
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
</div>
<div class="atlas-card-desc">${config.description}</div>
${readinessHTML}
<div class="atlas-card-footer">
<div class="atlas-card-coord">X:${config.position.x} Z:${config.position.z}</div>
<div class="atlas-card-action">${actionLabel} →</div>
${config.role ? `<div class="atlas-card-role role-${config.role}">${config.role.toUpperCase()}</div>` : ''}
<div class="atlas-card-type">${config.destination?.type?.toUpperCase() || 'UNKNOWN'}</div>
</div>
`;
card.addEventListener('click', () => {
focusPortal(portal);
closePortalAtlas();
});
grid.appendChild(card);
});
// Show empty state
if (visibleCount === 0) {
const empty = document.createElement('div');
empty.className = 'atlas-empty';
empty.textContent = atlasSearchQuery
? `No worlds match "${atlasSearchQuery}"`
: 'No worlds in this category';
grid.appendChild(empty);
}
document.getElementById('atlas-online-count').textContent = onlineCount;
document.getElementById('atlas-standby-count').textContent = standbyCount;
document.getElementById('atlas-downloaded-count').textContent = downloadedCount;
document.getElementById('atlas-total-count').textContent = portals.length;
// Update Bannerlord HUD status
const bannerlord = portals.find(p => p.config.id === 'bannerlord');
@@ -3170,7 +2926,6 @@ function gameLoop() {
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
SpatialAudio.update(delta);
MemoryBirth.update(delta);
MemoryPulse.update();
animateMemoryOrbs(delta);
@@ -3372,7 +3127,7 @@ function gameLoop() {
core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
}
if (composer) { composer.render(); } else { renderer.render(scene, camera); }
composer.render();
updateAshStorm(delta, elapsed);
@@ -3411,7 +3166,7 @@ function onResize() {
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (composer) composer.setSize(w, h);
composer.setSize(w, h);
}
// ═══ AGENT SIMULATION ═══

View File

@@ -586,8 +586,8 @@ def alert_on_failure(report: HealthReport, dry_run: bool = False) -> None:
logger.info("Created alert issue #%d", result["number"])
def run_once(args: argparse.Namespace) -> tuple:
"""Run one health check cycle. Returns (healthy, report)."""
def run_once(args: argparse.Namespace) -> bool:
"""Run one health check cycle. Returns True if healthy."""
report = run_health_checks(
ws_host=args.ws_host,
ws_port=args.ws_port,
@@ -615,7 +615,7 @@ def run_once(args: argparse.Namespace) -> tuple:
except Exception:
pass # never crash the watchdog over its own heartbeat
return report.overall_healthy, report
return report.overall_healthy
def main():
@@ -678,15 +678,21 @@ def main():
signal.signal(signal.SIGINT, _handle_sigterm)
while _running:
run_once(args) # (healthy, report) — not needed in watch mode
run_once(args)
for _ in range(args.interval):
if not _running:
break
time.sleep(1)
else:
healthy, report = run_once(args)
healthy = run_once(args)
if args.output_json:
report = run_health_checks(
ws_host=args.ws_host,
ws_port=args.ws_port,
heartbeat_path=Path(args.heartbeat_path),
stale_threshold=args.stale_threshold,
)
print(json.dumps({
"healthy": report.overall_healthy,
"timestamp": report.timestamp,

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env python3
"""
Swarm Governor — prevents PR pileup by enforcing merge discipline.
Runs as a pre-flight check before any swarm dispatch cycle.
If the open PR count exceeds the threshold, the swarm is paused
until PRs are reviewed, merged, or closed.
Usage:
python3 swarm_governor.py --check # Exit 0 if clear, 1 if blocked
python3 swarm_governor.py --report # Print status report
python3 swarm_governor.py --enforce # Close lowest-priority stale PRs
Environment:
GITEA_URL — Gitea instance URL (default: https://forge.alexanderwhitestone.com)
GITEA_TOKEN — API token
SWARM_MAX_OPEN — Max open PRs before blocking (default: 15)
SWARM_STALE_DAYS — Days before a PR is considered stale (default: 3)
"""
import os
import sys
import json
import urllib.request
import urllib.error
from datetime import datetime, timezone, timedelta
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
MAX_OPEN = int(os.environ.get("SWARM_MAX_OPEN", "15"))
STALE_DAYS = int(os.environ.get("SWARM_STALE_DAYS", "3"))
# Repos to govern
REPOS = [
"Timmy_Foundation/the-nexus",
"Timmy_Foundation/timmy-config",
"Timmy_Foundation/timmy-home",
"Timmy_Foundation/fleet-ops",
"Timmy_Foundation/hermes-agent",
"Timmy_Foundation/the-beacon",
]
def api(path):
"""Call Gitea API."""
url = f"{GITEA_URL}/api/v1{path}"
req = urllib.request.Request(url)
if GITEA_TOKEN:
req.add_header("Authorization", f"token {GITEA_TOKEN}")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
return []
def get_open_prs():
"""Get all open PRs across governed repos."""
all_prs = []
for repo in REPOS:
prs = api(f"/repos/{repo}/pulls?state=open&limit=50")
for pr in prs:
pr["_repo"] = repo
age = (datetime.now(timezone.utc) -
datetime.fromisoformat(pr["created_at"].replace("Z", "+00:00")))
pr["_age_days"] = age.days
pr["_stale"] = age.days >= STALE_DAYS
all_prs.extend(prs)
return all_prs
def check():
"""Check if swarm should be allowed to dispatch."""
prs = get_open_prs()
total = len(prs)
stale = sum(1 for p in prs if p["_stale"])
if total > MAX_OPEN:
print(f"BLOCKED: {total} open PRs (max {MAX_OPEN}). {stale} stale.")
print(f"Review and merge before dispatching new work.")
return 1
else:
print(f"CLEAR: {total}/{MAX_OPEN} open PRs. {stale} stale.")
return 0
def report():
"""Print full status report."""
prs = get_open_prs()
by_repo = {}
for pr in prs:
by_repo.setdefault(pr["_repo"], []).append(pr)
print(f"{'='*60}")
print(f"SWARM GOVERNOR REPORT — {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print(f"{'='*60}")
print(f"Total open PRs: {len(prs)} (max: {MAX_OPEN})")
print(f"Status: {'BLOCKED' if len(prs) > MAX_OPEN else 'CLEAR'}")
print()
for repo, repo_prs in sorted(by_repo.items()):
print(f" {repo}: {len(repo_prs)} open")
by_author = {}
for pr in repo_prs:
by_author.setdefault(pr["user"]["login"], []).append(pr)
for author, author_prs in sorted(by_author.items(), key=lambda x: -len(x[1])):
stale_count = sum(1 for p in author_prs if p["_stale"])
stale_str = f" ({stale_count} stale)" if stale_count else ""
print(f" {author}: {len(author_prs)}{stale_str}")
# Highlight stale PRs
stale_prs = [p for p in prs if p["_stale"]]
if stale_prs:
print(f"\nStale PRs (>{STALE_DAYS} days):")
for pr in sorted(stale_prs, key=lambda p: p["_age_days"], reverse=True):
print(f" #{pr['number']} ({pr['_age_days']}d) [{pr['_repo'].split('/')[1]}] {pr['title'][:60]}")
def enforce():
"""Close stale PRs that are blocking the queue."""
prs = get_open_prs()
if len(prs) <= MAX_OPEN:
print("Queue is clear. Nothing to enforce.")
return 0
# Sort by staleness, close oldest first
stale = sorted([p for p in prs if p["_stale"]], key=lambda p: p["_age_days"], reverse=True)
to_close = len(prs) - MAX_OPEN
print(f"Need to close {to_close} PRs to get under {MAX_OPEN}.")
for pr in stale[:to_close]:
print(f" Would close: #{pr['number']} ({pr['_age_days']}d) [{pr['_repo'].split('/')[1]}] {pr['title'][:50]}")
print(f"\nDry run — add --force to actually close.")
return 0
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "--check"
if cmd == "--check":
sys.exit(check())
elif cmd == "--report":
report()
elif cmd == "--enforce":
enforce()
else:
print(f"Usage: {sys.argv[0]} [--check|--report|--enforce]")
sys.exit(1)

View File

@@ -1,174 +0,0 @@
# Bannerlord Runtime — Apple Silicon Selection
> **Issue:** #720
> **Status:** DECIDED
> **Chosen Runtime:** Whisky (via Apple Game Porting Toolkit)
> **Date:** 2026-04-12
> **Platform:** macOS Apple Silicon (arm64)
---
## Decision
**Whisky** is the chosen runtime for Mount & Blade II: Bannerlord on Apple Silicon Macs.
Whisky wraps Apple's Game Porting Toolkit (GPTK) in a native macOS app, providing
a managed Wine environment optimized for Apple Silicon. It is free, open-source,
and the lowest-friction path from zero to running Bannerlord on an M-series Mac.
### Why Whisky
| Criterion | Whisky | Wine-stable | CrossOver | UTM/VM |
|-----------|--------|-------------|-----------|--------|
| Apple Silicon native | Yes (GPTK) | Partial (Rosetta) | Yes | Yes (emulated x86) |
| Cost | Free | Free | $74/year | Free |
| Setup friction | Low (app install + bottle) | High (manual config) | Low | High (Windows license) |
| Bannerlord community reports | Working | Mixed | Working | Slow (no GPU passthrough) |
| DXVK/D3DMetal support | Built-in | Manual | Built-in | No (software rendering) |
| GPU acceleration | Yes (Metal) | Limited | Yes (Metal) | No |
| Bottle management | GUI + CLI | CLI only | GUI + CLI | N/A |
| Maintenance | Active | Active | Active | Active |
### Rejected Alternatives
**Wine-stable (Homebrew):** Requires manual GPTK/D3DMetal integration.
Poor Apple Silicon support out of the box. Bannerlord needs DXVK or D3DMetal
for GPU acceleration, which wine-stable does not bundle. Rejected: high falsework.
**CrossOver:** Commercial ($74/year). Functionally equivalent to Whisky for
Bannerlord. Rejected: unnecessary cost when a free alternative works. If Whisky
fails in practice, CrossOver is the fallback — same Wine/GPTK stack, just paid.
**UTM/VM (Windows 11 ARM):** No GPU passthrough. Bannerlord requires hardware
3D acceleration. Software rendering produces <5 FPS. Rejected: physics, not ideology.
---
## Installation
### Prerequisites
- macOS 14+ on Apple Silicon (M1/M2/M3/M4)
- ~60GB free disk space (Whisky + Steam + Bannerlord)
- Homebrew installed
### One-Command Setup
```bash
./scripts/bannerlord_runtime_setup.sh
```
This script handles:
1. Installing Whisky via Homebrew cask
2. Creating a Bannerlord bottle
3. Configuring the bottle for GPTK/D3DMetal
4. Pointing the bottle at Steam (Windows)
5. Outputting a verification-ready path
### Manual Steps (if script not used)
1. **Install Whisky:**
```bash
brew install --cask whisky
```
2. **Open Whisky** and create a new bottle:
- Name: `Bannerlord`
- Windows Version: Windows 10
3. **Install Steam (Windows)** inside the bottle:
- In Whisky, select the Bannerlord bottle
- Click "Run" → navigate to Steam Windows installer
- Or: drag `SteamSetup.exe` into the Whisky window
4. **Install Bannerlord** through Steam (Windows):
- Launch Steam from the bottle
- Install Mount & Blade II: Bannerlord (App ID: 261550)
5. **Configure D3DMetal:**
- In Whisky bottle settings, enable D3DMetal (or DXVK as fallback)
- Set Windows version to Windows 10
---
## Runtime Paths
After setup, the key paths are:
```
# Whisky bottle root
~/Library/Application Support/Whisky/Bottles/Bannerlord/
# Windows C: drive
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/
# Steam (Windows)
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/Program Files (x86)/Steam/
# Bannerlord install
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/
# Bannerlord executable
~/Library/Application Support/Whisky/Bottles/Bannerlord/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe
```
---
## Verification
Run the verification script to confirm the runtime is operational:
```bash
./scripts/bannerlord_verify_runtime.sh
```
Checks:
- [ ] Whisky installed (`/Applications/Whisky.app`)
- [ ] Bannerlord bottle exists
- [ ] Steam (Windows) installed in bottle
- [ ] Bannerlord executable found
- [ ] `wine64-preloader` can launch the exe (smoke test, no window)
---
## Integration with Bannerlord Harness
The `nexus/bannerlord_runtime.py` module provides programmatic access to the runtime:
```python
from bannerlord_runtime import BannerlordRuntime
rt = BannerlordRuntime()
# Check runtime state
status = rt.check()
# Launch Bannerlord
rt.launch()
# Launch Steam first, then Bannerlord
rt.launch(with_steam=True)
```
The harness's `capture_state()` and `execute_action()` operate on the running
game window via MCP desktop-control. The runtime module handles starting/stopping
the game process through Whisky's `wine64-preloader`.
---
## Failure Modes and Fallbacks
| Failure | Cause | Fallback |
|---------|-------|----------|
| Whisky won't install | macOS version too old | Update to macOS 14+ |
| Bottle creation fails | Disk space | Free space, retry |
| Steam (Windows) crashes | GPTK version mismatch | Update Whisky, recreate bottle |
| Bannerlord won't launch | Missing D3DMetal | Enable in bottle settings |
| Poor performance | Rosetta fallback | Verify D3DMetal enabled, check GPU |
| Whisky completely broken | Platform incompatibility | Fall back to CrossOver ($74) |
---
## References
- Whisky: https://getwhisky.app
- Apple GPTK: https://developer.apple.com/games/game-porting-toolkit/
- Bannerlord on Whisky: https://github.com/Whisky-App/Whisky/issues (search: bannerlord)
- Issue #720: https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/720

View File

@@ -1,66 +0,0 @@
# AI Tools Org Assessment — Implementation Tracker
**Issue:** #1119
**Research by:** Bezalel
**Date:** 2026-04-07
**Scope:** github.com/ai-tools — 205 repositories scanned
## Summary
The `ai-tools` GitHub org is a broad mirror/fork collection of 205 AI repos.
~170 are media-generation tools with limited operational value for the fleet.
7 tools are strongly relevant to our infrastructure, multi-agent orchestration,
and sovereign compute goals.
## Top 7 Recommendations
### Priority 1 — Immediate
- [ ] **edge-tts** — Free TTS fallback for Hermes (pip install edge-tts)
- Zero API key, uses Microsoft Edge online service
- Pair with local TTS (fish-speech/F5-TTS) for full sovereignty later
- Hermes integration: add as provider fallback in text_to_speech tool
- [ ] **llama.cpp** — Standardize local inference across VPS nodes
- Already partially running on Alpha (127.0.0.1:11435)
- Serve Qwen2.5-7B-GGUF or similar for fast always-available inference
- Eliminate per-token cloud charges for batch workloads
### Priority 2 — Short-term (2 weeks)
- [ ] **A2A (Agent2Agent Protocol)** — Machine-native inter-agent comms
- Draft Agent Cards for each wizard (Bezalel, Ezra, Allegro, Timmy)
- Pilot: Ezra detects Gitea failure -> A2A delegates to Bezalel -> fix -> report back
- Framework-agnostic, Google-backed
- [ ] **Llama Stack** — Unified LLM API abstraction layer
- Evaluate replacing direct provider integrations with Stack API
- Pilot with one low-risk tool (e.g., text summarization)
### Priority 3 — Medium-term (1 month)
- [ ] **bolt.new-any-llm** — Rapid internal tool prototyping
- Use for fleet health dashboard, Gitea PR queue visualizer
- Can point at local Ollama/llama.cpp for sovereign prototypes
- [ ] **Swarm (OpenAI)** — Multi-agent pattern reference
- Don't deploy; extract design patterns (handoffs, routines, routing)
- Apply patterns to Hermes multi-agent architecture
- [ ] **diagram-ai / diagrams** — Architecture documentation
- Supports Alexander's Master KT initiative
- `diagrams` (Python) for CLI/scripted, `diagram-ai` (React) for interactive
## Skip List
These categories are low-value for the fleet:
- Image/video diffusion tools (~65 repos)
- Colorization/restoration (~15 repos)
- 3D reconstruction (~22 repos)
- Face swap / deepfake tools
- Music generation experiments
## References
- Issue: https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1119
- Upstream org: https://github.com/ai-tools

View File

@@ -1,35 +1,30 @@
const heuristic = (state, goal) => Object.keys(goal).reduce((h, key) => h + (state[key] === goal[key] ? 0 : Math.abs((state[key] || 0) - (goal[key] || 0))), 0), preconditionsMet = (state, preconditions = {}) => Object.entries(preconditions).every(([key, value]) => (typeof value === 'number' ? (state[key] || 0) >= value : state[key] === value));
const findPlan = (initialState, goalState, actions = []) => {
const openSet = [{ state: initialState, plan: [], g: 0, h: heuristic(initialState, goalState) }];
const visited = new Map([[JSON.stringify(initialState), 0]]);
while (openSet.length) {
openSet.sort((a, b) => (a.g + a.h) - (b.g + b.h));
const { state, plan, g } = openSet.shift();
if (heuristic(state, goalState) === 0) return plan;
actions.forEach((action) => {
if (!preconditionsMet(state, action.preconditions)) return;
const nextState = { ...state, ...(action.effects || {}) };
const key = JSON.stringify(nextState);
const nextG = g + 1;
if (!visited.has(key) || nextG < visited.get(key)) {
visited.set(key, nextG);
openSet.push({ state: nextState, plan: [...plan, action.name], g: nextG, h: heuristic(nextState, goalState) });
}
});
}
return [];
};
// ═══ GOFAI PARALLEL WORKER (PSE) ═══
self.onmessage = function(e) {
const { type, data } = e.data;
if (type === 'REASON') {
const factMap = new Map(data.facts || []);
const results = (data.rules || []).filter((rule) => (rule.triggerFacts || []).every((fact) => factMap.get(fact))).map((rule) => ({ rule: rule.description, outcome: 'OFF-THREAD MATCH' }));
self.postMessage({ type: 'REASON_RESULT', results });
return;
}
if (type === 'PLAN') {
const plan = findPlan(data.initialState || {}, data.goalState || {}, data.actions || []);
self.postMessage({ type: 'PLAN_RESULT', plan });
switch(type) {
case 'REASON':
const { facts, rules } = data;
const results = [];
// Off-thread rule matching
rules.forEach(rule => {
// Simulate heavy rule matching
if (Math.random() > 0.95) {
results.push({ rule: rule.description, outcome: 'OFF-THREAD MATCH' });
}
});
self.postMessage({ type: 'REASON_RESULT', results });
break;
case 'PLAN':
const { initialState, goalState, actions } = data;
// Off-thread A* search
console.log('[PSE] Starting off-thread A* search...');
// Simulate planning delay
const startTime = performance.now();
while(performance.now() - startTime < 50) {} // Artificial load
self.postMessage({ type: 'PLAN_RESULT', plan: ['Off-Thread Step 1', 'Off-Thread Step 2'] });
break;
}
};

View File

@@ -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>
@@ -151,9 +113,9 @@
<!-- Top Right: Agent Log & Atlas Toggle -->
<div class="hud-top-right">
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">WORLDS</span>
<span class="hud-btn-label">ATLAS</span>
</button>
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
<span class="status-dot"></span>
@@ -252,35 +214,20 @@
<div class="atlas-header">
<div class="atlas-title">
<span class="atlas-icon">🌐</span>
<h2>WORLD DIRECTORY</h2>
<h2>PORTAL ATLAS</h2>
</div>
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
</div>
<div class="atlas-controls">
<input type="text" id="atlas-search" class="atlas-search" placeholder="Search worlds..." autocomplete="off" />
<div class="atlas-filters" id="atlas-filters">
<button class="atlas-filter-btn active" data-filter="all">ALL</button>
<button class="atlas-filter-btn" data-filter="online">ONLINE</button>
<button class="atlas-filter-btn" data-filter="standby">STANDBY</button>
<button class="atlas-filter-btn" data-filter="downloaded">DOWNLOADED</button>
<button class="atlas-filter-btn" data-filter="harness">HARNESS</button>
<button class="atlas-filter-btn" data-filter="game-world">GAME</button>
</div>
</div>
<div class="atlas-grid" id="atlas-grid">
<!-- Worlds will be injected here -->
<!-- Portals will be injected here -->
</div>
<div class="atlas-footer">
<div class="atlas-status-summary">
<span class="status-indicator online"></span> <span id="atlas-online-count">0</span> ONLINE
&nbsp;&nbsp;
<span class="status-indicator standby"></span> <span id="atlas-standby-count">0</span> STANDBY
&nbsp;&nbsp;
<span class="status-indicator downloaded"></span> <span id="atlas-downloaded-count">0</span> DOWNLOADED
&nbsp;&nbsp;
<span class="atlas-total">| <span id="atlas-total-count">0</span> WORLDS TOTAL</span>
</div>
<div class="atlas-hint">Click a world to focus or enter</div>
<div class="atlas-hint">Click a portal to focus or teleport</div>
</div>
</div>
</div>

View File

@@ -98,15 +98,6 @@ optional_rooms:
purpose: Catch-all for artefacts not yet assigned to a named room
wizards: ["*"]
- key: sovereign
label: Sovereign
purpose: Artifacts of Alexander Whitestone's requests, directives, and conversation history
wizards: ["*"]
conventions:
naming: "YYYY-MM-DD_HHMMSS_<topic>.md"
index: "INDEX.md"
description: "Each artifact is a dated record of a request from Alexander and the wizard's response. The running INDEX.md provides a chronological catalog."
# Tunnel routing table
# Defines which room pairs are connected across wizard wings.
# A tunnel lets `recall <query> --fleet` search both wings at once.
@@ -121,5 +112,3 @@ tunnels:
description: Fleet-wide issue and PR knowledge
- rooms: [experiments, experiments]
description: Cross-wizard spike and prototype results
- rooms: [sovereign, sovereign]
description: Alexander's requests and responses shared across all wizards

View File

@@ -7,7 +7,6 @@ routes to lanes, and spawns one-shot mimo-v2-pro workers.
No new issues created. No duplicate claims. No bloat.
"""
import glob
import json
import os
import sys
@@ -39,7 +38,6 @@ else:
CLAIM_TIMEOUT_MINUTES = 30
CLAIM_LABEL = "mimo-claimed"
MAX_QUEUE_DEPTH = 10 # Don't dispatch if queue already has this many prompts
CLAIM_COMMENT = "/claim"
DONE_COMMENT = "/done"
ABANDON_COMMENT = "/abandon"
@@ -453,13 +451,6 @@ def dispatch(token):
prefetch_pr_refs(target_repo, token)
log(f" Prefetched {len(_PR_REFS)} PR references")
# Check queue depth — don't pile up if workers haven't caught up
pending_prompts = len(glob.glob(os.path.join(STATE_DIR, "prompt-*.txt")))
if pending_prompts >= MAX_QUEUE_DEPTH:
log(f" QUEUE THROTTLE: {pending_prompts} prompts pending (max {MAX_QUEUE_DEPTH}) — skipping dispatch")
save_state(state)
return 0
# FOCUS MODE: scan only the focus repo. FIREHOSE: scan all.
if FOCUS_MODE:
ordered = [FOCUS_REPO]

View File

@@ -24,23 +24,6 @@ def log(msg):
f.write(f"[{ts}] {msg}\n")
def write_result(worker_id, status, repo=None, issue=None, branch=None, pr=None, error=None):
"""Write a result file — always, even on failure."""
result_file = os.path.join(STATE_DIR, f"result-{worker_id}.json")
data = {
"status": status,
"worker": worker_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
if repo: data["repo"] = repo
if issue: data["issue"] = int(issue) if str(issue).isdigit() else issue
if branch: data["branch"] = branch
if pr: data["pr"] = pr
if error: data["error"] = error
with open(result_file, "w") as f:
json.dump(data, f)
def get_oldest_prompt():
"""Get the oldest prompt file with file locking (atomic rename)."""
prompts = sorted(glob.glob(os.path.join(STATE_DIR, "prompt-*.txt")))
@@ -80,7 +63,6 @@ def run_worker(prompt_file):
if not repo or not issue:
log(f" SKIPPING: couldn't parse repo/issue from prompt")
write_result(worker_id, "parse_error", error="could not parse repo/issue from prompt")
os.remove(prompt_file)
return False
@@ -97,7 +79,6 @@ def run_worker(prompt_file):
)
if result.returncode != 0:
log(f" CLONE FAILED: {result.stderr[:200]}")
write_result(worker_id, "clone_failed", repo=repo, issue=issue, error=result.stderr[:200])
os.remove(prompt_file)
return False
@@ -145,7 +126,6 @@ def run_worker(prompt_file):
urllib.request.urlopen(req, timeout=10)
except:
pass
write_result(worker_id, "abandoned", repo=repo, issue=issue, error="no changes produced")
if os.path.exists(prompt_file):
os.remove(prompt_file)
return False
@@ -213,7 +193,17 @@ def run_worker(prompt_file):
pr_num = "?"
# Write result
write_result(worker_id, "completed", repo=repo, issue=issue, branch=branch, pr=pr_num)
result_file = os.path.join(STATE_DIR, f"result-{worker_id}.json")
with open(result_file, "w") as f:
json.dump({
"status": "completed",
"worker": worker_id,
"repo": repo,
"issue": int(issue) if issue.isdigit() else issue,
"branch": branch,
"pr": pr_num,
"timestamp": datetime.now(timezone.utc).isoformat()
}, f)
# Remove prompt
# Remove prompt file (handles .processing extension)

View File

@@ -1,263 +0,0 @@
#!/usr/bin/env python3
"""
Bannerlord Runtime Manager — Apple Silicon via Whisky
Provides programmatic access to the Whisky/Wine runtime for Bannerlord.
Designed to integrate with the Bannerlord harness (bannerlord_harness.py).
Runtime choice documented in docs/BANNERLORD_RUNTIME.md.
Issue #720.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
log = logging.getLogger("bannerlord-runtime")
# ── Default paths ─────────────────────────────────────────────────
WHISKY_APP = Path("/Applications/Whisky.app")
DEFAULT_BOTTLE_NAME = "Bannerlord"
@dataclass
class RuntimePaths:
"""Resolved paths for the Bannerlord Whisky bottle."""
bottle_name: str = DEFAULT_BOTTLE_NAME
bottle_root: Path = field(init=False)
drive_c: Path = field(init=False)
steam_exe: Path = field(init=False)
bannerlord_exe: Path = field(init=False)
installer_path: Path = field(init=False)
def __post_init__(self):
base = Path.home() / "Library/Application Support/Whisky/Bottles" / self.bottle_name
self.bottle_root = base
self.drive_c = base / "drive_c"
self.steam_exe = (
base / "drive_c/Program Files (x86)/Steam/Steam.exe"
)
self.bannerlord_exe = (
base
/ "drive_c/Program Files (x86)/Steam/steamapps/common"
/ "Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
)
self.installer_path = Path("/tmp/SteamSetup.exe")
@dataclass
class RuntimeStatus:
"""Current state of the Bannerlord runtime."""
whisky_installed: bool = False
whisky_version: str = ""
bottle_exists: bool = False
drive_c_populated: bool = False
steam_installed: bool = False
bannerlord_installed: bool = False
gptk_available: bool = False
macos_version: str = ""
macos_ok: bool = False
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ready(self) -> bool:
return (
self.whisky_installed
and self.bottle_exists
and self.steam_installed
and self.bannerlord_installed
and self.macos_ok
)
def to_dict(self) -> dict:
return {
"whisky_installed": self.whisky_installed,
"whisky_version": self.whisky_version,
"bottle_exists": self.bottle_exists,
"drive_c_populated": self.drive_c_populated,
"steam_installed": self.steam_installed,
"bannerlord_installed": self.bannerlord_installed,
"gptk_available": self.gptk_available,
"macos_version": self.macos_version,
"macos_ok": self.macos_ok,
"ready": self.ready,
"errors": self.errors,
"warnings": self.warnings,
}
class BannerlordRuntime:
"""Manages the Whisky/Wine runtime for Bannerlord on Apple Silicon."""
def __init__(self, bottle_name: str = DEFAULT_BOTTLE_NAME):
self.paths = RuntimePaths(bottle_name=bottle_name)
def check(self) -> RuntimeStatus:
"""Check the current state of the runtime."""
status = RuntimeStatus()
# macOS version
try:
result = subprocess.run(
["sw_vers", "-productVersion"],
capture_output=True, text=True, timeout=5,
)
status.macos_version = result.stdout.strip()
major = int(status.macos_version.split(".")[0])
status.macos_ok = major >= 14
if not status.macos_ok:
status.errors.append(f"macOS {status.macos_version} too old, need 14+")
except Exception as e:
status.errors.append(f"Cannot detect macOS version: {e}")
# Whisky installed
if WHISKY_APP.exists():
status.whisky_installed = True
try:
result = subprocess.run(
[
"defaults", "read",
str(WHISKY_APP / "Contents/Info.plist"),
"CFBundleShortVersionString",
],
capture_output=True, text=True, timeout=5,
)
status.whisky_version = result.stdout.strip()
except Exception:
status.whisky_version = "unknown"
else:
status.errors.append(f"Whisky not found at {WHISKY_APP}")
# Bottle
status.bottle_exists = self.paths.bottle_root.exists()
if not status.bottle_exists:
status.errors.append(f"Bottle not found: {self.paths.bottle_root}")
# drive_c
status.drive_c_populated = self.paths.drive_c.exists()
if not status.drive_c_populated and status.bottle_exists:
status.warnings.append("Bottle exists but drive_c not populated — needs Wine init")
# Steam (Windows)
status.steam_installed = self.paths.steam_exe.exists()
if not status.steam_installed:
status.warnings.append("Steam (Windows) not installed in bottle")
# Bannerlord
status.bannerlord_installed = self.paths.bannerlord_exe.exists()
if not status.bannerlord_installed:
status.warnings.append("Bannerlord not installed")
# GPTK/D3DMetal
whisky_support = Path.home() / "Library/Application Support/Whisky"
if whisky_support.exists():
gptk_files = list(whisky_support.rglob("*gptk*")) + \
list(whisky_support.rglob("*d3dmetal*")) + \
list(whisky_support.rglob("*dxvk*"))
status.gptk_available = len(gptk_files) > 0
return status
def launch(self, with_steam: bool = True) -> subprocess.Popen | None:
"""
Launch Bannerlord via Whisky.
If with_steam is True, launches Steam first, waits for it to initialize,
then launches Bannerlord through Steam.
"""
status = self.check()
if not status.ready:
log.error("Runtime not ready: %s", "; ".join(status.errors or status.warnings))
return None
if with_steam:
log.info("Launching Steam (Windows) via Whisky...")
steam_proc = self._run_exe(str(self.paths.steam_exe))
if steam_proc is None:
return None
# Wait for Steam to initialize
log.info("Waiting for Steam to initialize (15s)...")
time.sleep(15)
# Launch Bannerlord via steam://rungameid/
log.info("Launching Bannerlord via Steam protocol...")
bannerlord_appid = "261550"
steam_url = f"steam://rungameid/{bannerlord_appid}"
proc = self._run_exe(str(self.paths.steam_exe), args=[steam_url])
if proc:
log.info("Bannerlord launch command sent (PID: %d)", proc.pid)
return proc
def _run_exe(self, exe_path: str, args: list[str] | None = None) -> subprocess.Popen | None:
"""Run a Windows executable through Whisky's wine64-preloader."""
# Whisky uses wine64-preloader from its bundled Wine
wine64 = self._find_wine64()
if wine64 is None:
log.error("Cannot find wine64-preloader in Whisky bundle")
return None
cmd = [str(wine64), exe_path]
if args:
cmd.extend(args)
env = os.environ.copy()
env["WINEPREFIX"] = str(self.paths.bottle_root)
try:
proc = subprocess.Popen(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return proc
except Exception as e:
log.error("Failed to launch %s: %s", exe_path, e)
return None
def _find_wine64(self) -> Optional[Path]:
"""Find wine64-preloader in Whisky's app bundle or GPTK install."""
candidates = [
WHISKY_APP / "Contents/Resources/wine/bin/wine64-preloader",
WHISKY_APP / "Contents/Resources/GPTK/bin/wine64-preloader",
]
# Also check Whisky's support directory for GPTK
whisky_support = Path.home() / "Library/Application Support/Whisky"
if whisky_support.exists():
for p in whisky_support.rglob("wine64-preloader"):
candidates.append(p)
for c in candidates:
if c.exists() and os.access(c, os.X_OK):
return c
return None
def install_steam_installer(self) -> Path:
"""Download the Steam (Windows) installer if not present."""
installer = self.paths.installer_path
if installer.exists():
log.info("Steam installer already at: %s", installer)
return installer
log.info("Downloading Steam (Windows) installer...")
url = "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe"
subprocess.run(
["curl", "-L", "-o", str(installer), url],
check=True,
)
log.info("Steam installer saved to: %s", installer)
return installer
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
rt = BannerlordRuntime()
status = rt.check()
print(json.dumps(status.to_dict(), indent=2))

View File

@@ -4,25 +4,15 @@ class MemoryOptimizer {
this.threshold = options.threshold || 0.3;
this.decayRate = options.decayRate || 0.01;
this.lastRun = Date.now();
this.blackboard = options.blackboard || null;
}
optimize(memories) {
const now = Date.now();
const elapsed = (now - this.lastRun) / 1000;
this.lastRun = now;
const result = memories.map(m => {
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);
if (this.blackboard) {
this.blackboard.write('memory_count', result.length, 'MemoryOptimizer');
this.blackboard.write('optimization_last_run', now, 'MemoryOptimizer');
}
return result;
}
}
export default MemoryOptimizer;

View File

@@ -1,242 +0,0 @@
// ═══════════════════════════════════════════════════════════════════
// 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 };

View File

@@ -173,9 +173,7 @@ const SpatialMemory = (() => {
let _entityLines = []; // entity resolution lines (issue #1167)
let _camera = null; // set by setCamera() for LOD culling
const ENTITY_LOD_DIST = 50; // hide entity lines when camera > this from midpoint
const CONNECTION_LOD_DIST = 60; // hide connection lines when camera > this from midpoint
let _initialized = false;
let _constellationVisible = true; // toggle for constellation view
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
function createCrystalGeometry(size) {
@@ -320,43 +318,10 @@ const SpatialMemory = (() => {
if (!obj || !obj.data.connections) return;
obj.data.connections.forEach(targetId => {
const target = _memoryObjects[targetId];
if (target) _drawSingleConnection(obj, target);
if (target) _createConnectionLine(obj, target);
});
}
function _drawSingleConnection(src, tgt) {
const srcId = src.data.id;
const tgtId = tgt.data.id;
// Deduplicate — only draw from lower ID to higher
if (srcId > tgtId) return;
// Skip if already exists
const exists = _connectionLines.some(l =>
(l.userData.from === srcId && l.userData.to === tgtId) ||
(l.userData.from === tgtId && l.userData.to === srcId)
);
if (exists) return;
const points = [src.mesh.position.clone(), tgt.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const srcStrength = src.mesh.userData.strength || 0.7;
const tgtStrength = tgt.mesh.userData.strength || 0.7;
const blendedStrength = (srcStrength + tgtStrength) / 2;
const lineOpacity = 0.15 + blendedStrength * 0.55;
const srcColor = new THREE.Color(REGIONS[src.region]?.color || 0x334455);
const tgtColor = new THREE.Color(REGIONS[tgt.region]?.color || 0x334455);
const lineColor = new THREE.Color().lerpColors(srcColor, tgtColor, 0.5);
const mat = new THREE.LineBasicMaterial({
color: lineColor,
transparent: true,
opacity: lineOpacity
});
const line = new THREE.Line(geo, mat);
line.userData = { type: 'connection', from: srcId, to: tgtId, baseOpacity: lineOpacity };
line.visible = _constellationVisible;
_scene.add(line);
_connectionLines.push(line);
}
return { ring, disc, glowDisc, sprite };
}
@@ -434,7 +399,7 @@ const SpatialMemory = (() => {
return [cx + Math.cos(angle) * dist, cy + height, cz + Math.sin(angle) * dist];
}
// ─── CONNECTIONS (constellation-aware) ───────────────
// ─── CONNECTIONS ─────────────────────────────────────
function _drawConnections(memId, connections) {
const src = _memoryObjects[memId];
if (!src) return;
@@ -445,23 +410,9 @@ const SpatialMemory = (() => {
const points = [src.mesh.position.clone(), tgt.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
// Strength-encoded opacity: blend source/target strengths, min 0.15, max 0.7
const srcStrength = src.mesh.userData.strength || 0.7;
const tgtStrength = tgt.mesh.userData.strength || 0.7;
const blendedStrength = (srcStrength + tgtStrength) / 2;
const lineOpacity = 0.15 + blendedStrength * 0.55;
// Blend source/target region colors for the line
const srcColor = new THREE.Color(REGIONS[src.region]?.color || 0x334455);
const tgtColor = new THREE.Color(REGIONS[tgt.region]?.color || 0x334455);
const lineColor = new THREE.Color().lerpColors(srcColor, tgtColor, 0.5);
const mat = new THREE.LineBasicMaterial({
color: lineColor,
transparent: true,
opacity: lineOpacity
});
const mat = new THREE.LineBasicMaterial({ color: 0x334455, transparent: true, opacity: 0.2 });
const line = new THREE.Line(geo, mat);
line.userData = { type: 'connection', from: memId, to: targetId, baseOpacity: lineOpacity };
line.visible = _constellationVisible;
line.userData = { type: 'connection', from: memId, to: targetId };
_scene.add(line);
_connectionLines.push(line);
});
@@ -538,43 +489,6 @@ const SpatialMemory = (() => {
});
}
function _updateConnectionLines() {
if (!_constellationVisible) return;
if (!_camera) return;
const camPos = _camera.position;
_connectionLines.forEach(line => {
const posArr = line.geometry.attributes.position.array;
const mx = (posArr[0] + posArr[3]) / 2;
const my = (posArr[1] + posArr[4]) / 2;
const mz = (posArr[2] + posArr[5]) / 2;
const dist = camPos.distanceTo(new THREE.Vector3(mx, my, mz));
if (dist > CONNECTION_LOD_DIST) {
line.visible = false;
} else {
line.visible = true;
const fade = Math.max(0, 1 - (dist / CONNECTION_LOD_DIST));
// Restore base opacity from userData if stored, else use material default
const base = line.userData.baseOpacity || line.material.opacity || 0.4;
line.material.opacity = base * fade;
}
});
}
function toggleConstellation() {
_constellationVisible = !_constellationVisible;
_connectionLines.forEach(line => {
line.visible = _constellationVisible;
});
console.info('[Mnemosyne] Constellation', _constellationVisible ? 'shown' : 'hidden');
return _constellationVisible;
}
function isConstellationVisible() {
return _constellationVisible;
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
@@ -630,7 +544,6 @@ const SpatialMemory = (() => {
});
_updateEntityLines();
_updateConnectionLines();
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
@@ -781,61 +694,15 @@ const SpatialMemory = (() => {
}
}
// ─── CONTEXT COMPACTION (issue #675) ──────────────────
const COMPACT_CONTENT_MAXLEN = 80; // max chars for low-strength memories
const COMPACT_STRENGTH_THRESHOLD = 0.5; // below this, content gets truncated
const COMPACT_MAX_CONNECTIONS = 5; // cap connections per memory
const COMPACT_POSITION_DECIMALS = 1; // round positions to 1 decimal
function _compactPosition(pos) {
const factor = Math.pow(10, COMPACT_POSITION_DECIMALS);
return pos.map(v => Math.round(v * factor) / factor);
}
/**
* Deterministically compact a memory for storage.
* Same input always produces same output — no randomness.
* Strong memories keep full fidelity; weak memories get truncated.
*/
function _compactMemory(o) {
const strength = o.mesh.userData.strength || 0.7;
const content = o.data.content || '';
const connections = o.data.connections || [];
// Deterministic content truncation for weak memories
let compactContent = content;
if (strength < COMPACT_STRENGTH_THRESHOLD && content.length > COMPACT_CONTENT_MAXLEN) {
compactContent = content.slice(0, COMPACT_CONTENT_MAXLEN) + '\u2026';
}
// Cap connections (keep first N, deterministic)
const compactConnections = connections.length > COMPACT_MAX_CONNECTIONS
? connections.slice(0, COMPACT_MAX_CONNECTIONS)
: connections;
return {
id: o.data.id,
content: compactContent,
category: o.region,
position: _compactPosition([o.mesh.position.x, o.mesh.position.y - 1.5, o.mesh.position.z]),
source: o.data.source || 'unknown',
timestamp: o.data.timestamp || o.mesh.userData.createdAt,
strength: Math.round(strength * 100) / 100, // 2 decimal precision
connections: compactConnections
};
}
// ─── PERSISTENCE ─────────────────────────────────────
function exportIndex(options = {}) {
const compact = options.compact !== false; // compact by default
function exportIndex() {
return {
version: 1,
exportedAt: new Date().toISOString(),
compacted: compact,
regions: Object.fromEntries(
Object.entries(REGIONS).map(([k, v]) => [k, { label: v.label, center: v.center, radius: v.radius, color: v.color }])
),
memories: Object.values(_memoryObjects).map(o => compact ? _compactMemory(o) : {
memories: Object.values(_memoryObjects).map(o => ({
id: o.data.id,
content: o.data.content,
category: o.region,
@@ -844,7 +711,7 @@ const SpatialMemory = (() => {
timestamp: o.data.timestamp || o.mesh.userData.createdAt,
strength: o.mesh.userData.strength || 0.7,
connections: o.data.connections || []
})
}))
};
}
@@ -948,42 +815,6 @@ const SpatialMemory = (() => {
return results.slice(0, maxResults);
}
// ─── CONTENT SEARCH ─────────────────────────────────
/**
* Search memories by text content — case-insensitive substring match.
* @param {string} query - Search text
* @param {object} [options] - Optional filters
* @param {string} [options.category] - Restrict to a specific region
* @param {number} [options.maxResults=20] - Cap results
* @returns {Array<{memory: object, score: number, position: THREE.Vector3}>}
*/
function searchByContent(query, options = {}) {
if (!query || !query.trim()) return [];
const { category, maxResults = 20 } = options;
const needle = query.trim().toLowerCase();
const results = [];
Object.values(_memoryObjects).forEach(obj => {
if (category && obj.region !== category) return;
const content = (obj.data.content || '').toLowerCase();
if (!content.includes(needle)) return;
// Score: number of occurrences + strength bonus
let matches = 0, idx = 0;
while ((idx = content.indexOf(needle, idx)) !== -1) { matches++; idx += needle.length; }
const score = matches + (obj.mesh.userData.strength || 0.7);
results.push({
memory: obj.data,
score,
position: obj.mesh.position.clone()
});
});
results.sort((a, b) => b.score - a.score);
return results.slice(0, maxResults);
}
// ─── CRYSTAL MESH COLLECTION (for raycasting) ────────
function getCrystalMeshes() {
@@ -1033,9 +864,9 @@ const SpatialMemory = (() => {
init, placeMemory, removeMemory, update, importMemories, updateMemory,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, searchNearby, searchByContent, REGIONS,
exportIndex, importIndex, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,
runGravityLayout, setCamera, toggleConstellation, isConstellationVisible
runGravityLayout, setCamera
};
})();

View File

@@ -243,108 +243,24 @@ async def playback(log_path: Path, ws_url: str):
await ws.send(json.dumps(event))
async def inject_event(event_type: str, ws_url: str, **kwargs):
"""Inject a single Evennia event into the Nexus WS gateway. Dev/test use."""
from nexus.evennia_event_adapter import (
actor_located, command_issued, command_result,
room_snapshot, session_bound,
)
builders = {
"room_snapshot": lambda: room_snapshot(
kwargs.get("room_key", "Gate"),
kwargs.get("title", "Gate"),
kwargs.get("desc", "The entrance gate."),
exits=kwargs.get("exits"),
objects=kwargs.get("objects"),
),
"actor_located": lambda: actor_located(
kwargs.get("actor_id", "Timmy"),
kwargs.get("room_key", "Gate"),
kwargs.get("room_name"),
),
"command_result": lambda: command_result(
kwargs.get("session_id", "dev-inject"),
kwargs.get("actor_id", "Timmy"),
kwargs.get("command_text", "look"),
kwargs.get("output_text", "You see the Gate."),
success=kwargs.get("success", True),
),
"command_issued": lambda: command_issued(
kwargs.get("session_id", "dev-inject"),
kwargs.get("actor_id", "Timmy"),
kwargs.get("command_text", "look"),
),
"session_bound": lambda: session_bound(
kwargs.get("session_id", "dev-inject"),
kwargs.get("account", "Timmy"),
kwargs.get("character", "Timmy"),
),
}
if event_type not in builders:
print(f"[inject] Unknown event type: {event_type}", flush=True)
print(f"[inject] Available: {', '.join(builders)}", flush=True)
sys.exit(1)
event = builders[event_type]()
payload = json.dumps(event)
if websockets is None:
print(f"[inject] websockets not installed, printing event:\n{payload}", flush=True)
return
try:
async with websockets.connect(ws_url, open_timeout=5) as ws:
await ws.send(payload)
print(f"[inject] Sent {event_type} -> {ws_url}", flush=True)
print(f"[inject] Payload: {payload}", flush=True)
except Exception as e:
print(f"[inject] Failed to send to {ws_url}: {e}", flush=True)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")
live = sub.add_parser("live", help="Live tail Evennia logs and stream to Nexus")
live.add_argument("--log-dir", default="/root/workspace/timmy-academy/server/logs", help="Evennia logs directory")
live.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
replay = sub.add_parser("playback", help="Replay a telemetry JSONL file")
replay.add_argument("log_path", help="Path to Evennia telemetry JSONL")
replay.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
inject = sub.add_parser("inject", help="Inject a single Evennia event (dev/test)")
inject.add_argument("event_type", choices=["room_snapshot", "actor_located", "command_result", "command_issued", "session_bound"])
inject.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
inject.add_argument("--room-key", default="Gate", help="Room key (room_snapshot, actor_located)")
inject.add_argument("--title", default="Gate", help="Room title (room_snapshot)")
inject.add_argument("--desc", default="The entrance gate.", help="Room description (room_snapshot)")
inject.add_argument("--actor-id", default="Timmy", help="Actor ID")
inject.add_argument("--command-text", default="look", help="Command text (command_result, command_issued)")
inject.add_argument("--output-text", default="You see the Gate.", help="Command output (command_result)")
inject.add_argument("--session-id", default="dev-inject", help="Hermes session ID")
args = parser.parse_args()
if args.mode == "live":
asyncio.run(live_bridge(args.log_dir, args.ws))
elif args.mode == "playback":
asyncio.run(playback(Path(args.log_path).expanduser(), args.ws))
elif args.mode == "inject":
asyncio.run(inject_event(
args.event_type,
args.ws,
room_key=args.room_key,
title=args.title,
desc=args.desc,
actor_id=args.actor_id,
command_text=args.command_text,
output_text=args.output_text,
session_id=args.session_id,
))
else:
parser.print_help()

View File

@@ -5,10 +5,6 @@ SQLite-backed store for lived experiences only. The model remembers
what it perceived, what it thought, and what it did — nothing else.
Each row is one cycle of the perceive→think→act loop.
Implements the GBrain "compiled truth + timeline" pattern (#1181):
- compiled_truths: current best understanding, rewritten when evidence changes
- experiences: append-only evidence trail that never gets edited
"""
import sqlite3
@@ -55,27 +51,6 @@ class ExperienceStore:
ON experiences(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_exp_session
ON experiences(session_id);
-- GBrain compiled truth pattern (#1181)
-- Current best understanding about an entity/topic.
-- Rewritten when new evidence changes the picture.
-- The timeline (experiences table) is the evidence trail — never edited.
CREATE TABLE IF NOT EXISTS compiled_truths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity TEXT NOT NULL, -- what this truth is about (person, topic, project)
truth TEXT NOT NULL, -- current best understanding
confidence REAL DEFAULT 0.5, -- 0.01.0
source_exp_id INTEGER, -- last experience that updated this truth
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
metadata_json TEXT DEFAULT '{}',
UNIQUE(entity) -- one compiled truth per entity
);
CREATE INDEX IF NOT EXISTS idx_truth_entity
ON compiled_truths(entity);
CREATE INDEX IF NOT EXISTS idx_truth_updated
ON compiled_truths(updated_at DESC);
""")
self.conn.commit()
@@ -182,117 +157,3 @@ class ExperienceStore:
def close(self):
self.conn.close()
# ── GBrain compiled truth + timeline pattern (#1181) ────────────────
def upsert_compiled_truth(
self,
entity: str,
truth: str,
confidence: float = 0.5,
source_exp_id: Optional[int] = None,
metadata: Optional[dict] = None,
) -> int:
"""Create or update the compiled truth for an entity.
This is the 'compiled truth on top' from the GBrain pattern.
When new evidence changes our understanding, we rewrite this
record. The timeline (experiences table) preserves what led
here — it is never edited.
Args:
entity: What this truth is about (person, topic, project).
truth: Current best understanding.
confidence: 0.01.0 confidence score.
source_exp_id: Last experience ID that informed this truth.
metadata: Optional extra data as a dict.
Returns:
The row ID of the compiled truth.
"""
now = time.time()
meta_json = json.dumps(metadata) if metadata else "{}"
self.conn.execute(
"""INSERT INTO compiled_truths
(entity, truth, confidence, source_exp_id, created_at, updated_at, metadata_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entity) DO UPDATE SET
truth = excluded.truth,
confidence = excluded.confidence,
source_exp_id = excluded.source_exp_id,
updated_at = excluded.updated_at,
metadata_json = excluded.metadata_json""",
(entity, truth, confidence, source_exp_id, now, now, meta_json),
)
self.conn.commit()
row = self.conn.execute(
"SELECT id FROM compiled_truths WHERE entity = ?", (entity,)
).fetchone()
return row[0]
def get_compiled_truth(self, entity: str) -> Optional[dict]:
"""Get the current compiled truth for an entity."""
row = self.conn.execute(
"""SELECT id, entity, truth, confidence, source_exp_id,
created_at, updated_at, metadata_json
FROM compiled_truths WHERE entity = ?""",
(entity,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"entity": row[1],
"truth": row[2],
"confidence": row[3],
"source_exp_id": row[4],
"created_at": row[5],
"updated_at": row[6],
"metadata": json.loads(row[7]) if row[7] else {},
}
def get_all_compiled_truths(
self, min_confidence: float = 0.0, limit: int = 100
) -> list[dict]:
"""Get all compiled truths, optionally filtered by minimum confidence."""
rows = self.conn.execute(
"""SELECT id, entity, truth, confidence, source_exp_id,
created_at, updated_at, metadata_json
FROM compiled_truths
WHERE confidence >= ?
ORDER BY updated_at DESC
LIMIT ?""",
(min_confidence, limit),
).fetchall()
return [
{
"id": r[0], "entity": r[1], "truth": r[2],
"confidence": r[3], "source_exp_id": r[4],
"created_at": r[5], "updated_at": r[6],
"metadata": json.loads(r[7]) if r[7] else {},
}
for r in rows
]
def search_compiled_truths(self, query: str, limit: int = 10) -> list[dict]:
"""Search compiled truths by entity name or truth content (LIKE match)."""
rows = self.conn.execute(
"""SELECT id, entity, truth, confidence, source_exp_id,
created_at, updated_at, metadata_json
FROM compiled_truths
WHERE entity LIKE ? OR truth LIKE ?
ORDER BY confidence DESC, updated_at DESC
LIMIT ?""",
(f"%{query}%", f"%{query}%", limit),
).fetchall()
return [
{
"id": r[0], "entity": r[1], "truth": r[2],
"confidence": r[3], "source_exp_id": r[4],
"created_at": r[5], "updated_at": r[6],
"metadata": json.loads(r[7]) if r[7] else {},
}
for r in rows
]

View File

@@ -1340,74 +1340,6 @@ 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.

View File

@@ -392,25 +392,6 @@ 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)
@@ -518,11 +499,6 @@ 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")
@@ -567,7 +543,6 @@ def main():
"fading": cmd_fading,
"vibrant": cmd_vibrant,
"resonance": cmd_resonance,
"discover": cmd_discover,
"snapshot": cmd_snapshot,
}
dispatch[args.command](args)

View File

@@ -1,31 +1,2 @@
"""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"])}
import json
# Snapshot logic

View File

@@ -1 +1 @@
# Discover tests
# Test discover

View File

@@ -1 +1 @@
# Resonance tests
# Test resonance

View File

@@ -1 +1 @@
# Snapshot tests
# Test snapshot

View File

@@ -1,888 +0,0 @@
#!/usr/bin/env python3
"""
Morrowind/OpenMW MCP Harness — GamePortal Protocol Implementation
A harness for The Elder Scrolls III: Morrowind (via OpenMW) using MCP servers:
- desktop-control MCP: screenshots, mouse/keyboard input
- steam-info MCP: game stats, achievements, player count
This harness implements the GamePortal Protocol:
capture_state() → GameState
execute_action(action) → ActionResult
The ODA (Observe-Decide-Act) loop connects perception to action through
Hermes WebSocket telemetry.
World-state verification uses screenshots + position inference rather than
log-only proof, per issue #673 acceptance criteria.
"""
from __future__ import annotations
import asyncio
import json
import logging
import subprocess
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional
import websockets
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════
MORROWIND_APP_ID = 22320
MORROWIND_WINDOW_TITLE = "OpenMW"
DEFAULT_HERMES_WS_URL = "ws://localhost:8000/ws"
DEFAULT_MCP_DESKTOP_COMMAND = ["npx", "-y", "@modelcontextprotocol/server-desktop-control"]
DEFAULT_MCP_STEAM_COMMAND = ["npx", "-y", "@modelcontextprotocol/server-steam-info"]
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [morrowind] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("morrowind")
# ═══════════════════════════════════════════════════════════════════════════
# MCP CLIENT — JSON-RPC over stdio
# ═══════════════════════════════════════════════════════════════════════════
class MCPClient:
"""Client for MCP servers communicating over stdio."""
def __init__(self, name: str, command: list[str]):
self.name = name
self.command = command
self.process: Optional[subprocess.Popen] = None
self.request_id = 0
self._lock = asyncio.Lock()
async def start(self) -> bool:
"""Start the MCP server process."""
try:
self.process = subprocess.Popen(
self.command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
await asyncio.sleep(0.5)
if self.process.poll() is not None:
log.error(f"MCP server {self.name} exited immediately")
return False
log.info(f"MCP server {self.name} started (PID: {self.process.pid})")
return True
except Exception as e:
log.error(f"Failed to start MCP server {self.name}: {e}")
return False
def stop(self):
"""Stop the MCP server process."""
if self.process and self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
self.process.kill()
log.info(f"MCP server {self.name} stopped")
async def call_tool(self, tool_name: str, arguments: dict) -> dict:
"""Call an MCP tool and return the result."""
async with self._lock:
self.request_id += 1
request = {
"jsonrpc": "2.0",
"id": self.request_id,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments,
},
}
if not self.process or self.process.poll() is not None:
return {"error": "MCP server not running"}
try:
request_line = json.dumps(request) + "\n"
self.process.stdin.write(request_line)
self.process.stdin.flush()
response_line = await asyncio.wait_for(
asyncio.to_thread(self.process.stdout.readline),
timeout=10.0,
)
if not response_line:
return {"error": "Empty response from MCP server"}
response = json.loads(response_line)
return response.get("result", {}).get("content", [{}])[0].get("text", "")
except asyncio.TimeoutError:
return {"error": f"Timeout calling {tool_name}"}
except json.JSONDecodeError as e:
return {"error": f"Invalid JSON response: {e}"}
except Exception as e:
return {"error": str(e)}
# ═══════════════════════════════════════════════════════════════════════════
# GAME STATE DATA CLASSES
# ═══════════════════════════════════════════════════════════════════════════
@dataclass
class VisualState:
"""Visual perception from the game."""
screenshot_path: Optional[str] = None
screen_size: tuple[int, int] = (1920, 1080)
mouse_position: tuple[int, int] = (0, 0)
window_found: bool = False
window_title: str = ""
@dataclass
class GameContext:
"""Game-specific context from Steam."""
app_id: int = MORROWIND_APP_ID
playtime_hours: float = 0.0
achievements_unlocked: int = 0
achievements_total: int = 0
current_players_online: int = 0
game_name: str = "The Elder Scrolls III: Morrowind"
is_running: bool = False
@dataclass
class WorldState:
"""Morrowind-specific world-state derived from perception."""
estimated_location: str = "unknown"
is_in_menu: bool = False
is_in_dialogue: bool = False
is_in_combat: bool = False
time_of_day: str = "unknown"
health_status: str = "unknown"
@dataclass
class GameState:
"""Complete game state per GamePortal Protocol."""
portal_id: str = "morrowind"
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
visual: VisualState = field(default_factory=VisualState)
game_context: GameContext = field(default_factory=GameContext)
world_state: WorldState = field(default_factory=WorldState)
session_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
def to_dict(self) -> dict:
return {
"portal_id": self.portal_id,
"timestamp": self.timestamp,
"session_id": self.session_id,
"visual": {
"screenshot_path": self.visual.screenshot_path,
"screen_size": list(self.visual.screen_size),
"mouse_position": list(self.visual.mouse_position),
"window_found": self.visual.window_found,
"window_title": self.visual.window_title,
},
"game_context": {
"app_id": self.game_context.app_id,
"playtime_hours": self.game_context.playtime_hours,
"achievements_unlocked": self.game_context.achievements_unlocked,
"achievements_total": self.game_context.achievements_total,
"current_players_online": self.game_context.current_players_online,
"game_name": self.game_context.game_name,
"is_running": self.game_context.is_running,
},
"world_state": {
"estimated_location": self.world_state.estimated_location,
"is_in_menu": self.world_state.is_in_menu,
"is_in_dialogue": self.world_state.is_in_dialogue,
"is_in_combat": self.world_state.is_in_combat,
"time_of_day": self.world_state.time_of_day,
"health_status": self.world_state.health_status,
},
}
@dataclass
class ActionResult:
"""Result of executing an action."""
success: bool = False
action: str = ""
params: dict = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
error: Optional[str] = None
def to_dict(self) -> dict:
result = {
"success": self.success,
"action": self.action,
"params": self.params,
"timestamp": self.timestamp,
}
if self.error:
result["error"] = self.error
return result
# ═══════════════════════════════════════════════════════════════════════════
# MORROWIND HARNESS — Main Implementation
# ═══════════════════════════════════════════════════════════════════════════
class MorrowindHarness:
"""
Harness for The Elder Scrolls III: Morrowind (OpenMW).
Implements the GamePortal Protocol:
- capture_state(): Takes screenshot, gets screen info, fetches Steam stats
- execute_action(): Translates actions to MCP tool calls
World-state verification (issue #673): uses screenshot evidence per cycle,
not just log assertions.
"""
def __init__(
self,
hermes_ws_url: str = DEFAULT_HERMES_WS_URL,
desktop_command: Optional[list[str]] = None,
steam_command: Optional[list[str]] = None,
enable_mock: bool = False,
):
self.hermes_ws_url = hermes_ws_url
self.desktop_command = desktop_command or DEFAULT_MCP_DESKTOP_COMMAND
self.steam_command = steam_command or DEFAULT_MCP_STEAM_COMMAND
self.enable_mock = enable_mock
# MCP clients
self.desktop_mcp: Optional[MCPClient] = None
self.steam_mcp: Optional[MCPClient] = None
# WebSocket connection to Hermes
self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.ws_connected = False
# State
self.session_id = str(uuid.uuid4())[:8]
self.cycle_count = 0
self.running = False
# Trace storage
self.trace_dir = Path.home() / ".timmy" / "traces" / "morrowind"
self.trace_file: Optional[Path] = None
self.trace_cycles: list[dict] = []
# ═══ LIFECYCLE ═══
async def start(self) -> bool:
"""Initialize MCP servers and WebSocket connection."""
log.info("=" * 50)
log.info("MORROWIND HARNESS — INITIALIZING")
log.info(f" Session: {self.session_id}")
log.info(f" Hermes WS: {self.hermes_ws_url}")
log.info("=" * 50)
if not self.enable_mock:
self.desktop_mcp = MCPClient("desktop-control", self.desktop_command)
self.steam_mcp = MCPClient("steam-info", self.steam_command)
desktop_ok = await self.desktop_mcp.start()
steam_ok = await self.steam_mcp.start()
if not desktop_ok:
log.warning("Desktop MCP failed to start, enabling mock mode")
self.enable_mock = True
if not steam_ok:
log.warning("Steam MCP failed to start, will use fallback stats")
else:
log.info("Running in MOCK mode — no actual MCP servers")
await self._connect_hermes()
# Init trace
self.trace_dir.mkdir(parents=True, exist_ok=True)
trace_id = f"mw_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
self.trace_file = self.trace_dir / f"trace_{trace_id}.jsonl"
log.info("Harness initialized successfully")
return True
async def stop(self):
"""Shutdown MCP servers and disconnect."""
self.running = False
log.info("Shutting down harness...")
if self.desktop_mcp:
self.desktop_mcp.stop()
if self.steam_mcp:
self.steam_mcp.stop()
if self.ws:
await self.ws.close()
self.ws_connected = False
# Write manifest
if self.trace_file and self.trace_cycles:
manifest_file = self.trace_file.with_name(
self.trace_file.name.replace("trace_", "manifest_").replace(".jsonl", ".json")
)
manifest = {
"session_id": self.session_id,
"game": "The Elder Scrolls III: Morrowind",
"app_id": MORROWIND_APP_ID,
"total_cycles": len(self.trace_cycles),
"trace_file": str(self.trace_file),
"started_at": self.trace_cycles[0].get("timestamp", "") if self.trace_cycles else "",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
with open(manifest_file, "w") as f:
json.dump(manifest, f, indent=2)
log.info(f"Trace saved: {self.trace_file}")
log.info(f"Manifest: {manifest_file}")
log.info("Harness shutdown complete")
async def _connect_hermes(self):
"""Connect to Hermes WebSocket for telemetry."""
try:
self.ws = await websockets.connect(self.hermes_ws_url)
self.ws_connected = True
log.info(f"Connected to Hermes: {self.hermes_ws_url}")
await self._send_telemetry({
"type": "harness_register",
"harness_id": "morrowind",
"session_id": self.session_id,
"game": "The Elder Scrolls III: Morrowind",
"app_id": MORROWIND_APP_ID,
})
except Exception as e:
log.warning(f"Could not connect to Hermes: {e}")
self.ws_connected = False
async def _send_telemetry(self, data: dict):
"""Send telemetry data to Hermes WebSocket."""
if self.ws_connected and self.ws:
try:
await self.ws.send(json.dumps(data))
except Exception as e:
log.warning(f"Telemetry send failed: {e}")
self.ws_connected = False
# ═══ GAMEPORTAL PROTOCOL: capture_state() ═══
async def capture_state(self) -> GameState:
"""
Capture current game state.
Returns GameState with:
- Screenshot of OpenMW window
- Screen dimensions and mouse position
- Steam stats (playtime, achievements, player count)
- World-state inference from visual evidence
"""
state = GameState(session_id=self.session_id)
visual = await self._capture_visual_state()
state.visual = visual
context = await self._capture_game_context()
state.game_context = context
# Derive world-state from visual evidence (not just logs)
state.world_state = self._infer_world_state(visual)
await self._send_telemetry({
"type": "game_state_captured",
"portal_id": "morrowind",
"session_id": self.session_id,
"cycle": self.cycle_count,
"visual": {
"window_found": visual.window_found,
"screenshot_path": visual.screenshot_path,
"screen_size": list(visual.screen_size),
},
"world_state": {
"estimated_location": state.world_state.estimated_location,
"is_in_menu": state.world_state.is_in_menu,
},
})
return state
def _infer_world_state(self, visual: VisualState) -> WorldState:
"""
Infer world-state from visual evidence.
In production, this would use a vision model to analyze the screenshot.
For the deterministic pilot loop, we record the screenshot as proof.
"""
ws = WorldState()
if not visual.window_found:
ws.estimated_location = "window_not_found"
return ws
# Placeholder inference — real version uses vision model
# The screenshot IS the world-state proof (issue #673 acceptance #3)
ws.estimated_location = "vvardenfell"
ws.time_of_day = "unknown" # Would parse from HUD
ws.health_status = "unknown" # Would parse from HUD
return ws
async def _capture_visual_state(self) -> VisualState:
"""Capture visual state via desktop-control MCP."""
visual = VisualState()
if self.enable_mock or not self.desktop_mcp:
visual.screenshot_path = f"/tmp/morrowind_mock_{int(time.time())}.png"
visual.screen_size = (1920, 1080)
visual.mouse_position = (960, 540)
visual.window_found = True
visual.window_title = MORROWIND_WINDOW_TITLE
return visual
try:
size_result = await self.desktop_mcp.call_tool("get_screen_size", {})
if isinstance(size_result, str):
parts = size_result.lower().replace("x", " ").split()
if len(parts) >= 2:
visual.screen_size = (int(parts[0]), int(parts[1]))
mouse_result = await self.desktop_mcp.call_tool("get_mouse_position", {})
if isinstance(mouse_result, str):
parts = mouse_result.replace(",", " ").split()
if len(parts) >= 2:
visual.mouse_position = (int(parts[0]), int(parts[1]))
screenshot_path = f"/tmp/morrowind_capture_{int(time.time())}.png"
screenshot_result = await self.desktop_mcp.call_tool(
"take_screenshot",
{"path": screenshot_path, "window_title": MORROWIND_WINDOW_TITLE}
)
if screenshot_result and "error" not in str(screenshot_result):
visual.screenshot_path = screenshot_path
visual.window_found = True
visual.window_title = MORROWIND_WINDOW_TITLE
else:
screenshot_result = await self.desktop_mcp.call_tool(
"take_screenshot",
{"path": screenshot_path}
)
if screenshot_result and "error" not in str(screenshot_result):
visual.screenshot_path = screenshot_path
visual.window_found = True
except Exception as e:
log.warning(f"Visual capture failed: {e}")
visual.window_found = False
return visual
async def _capture_game_context(self) -> GameContext:
"""Capture game context via steam-info MCP."""
context = GameContext()
if self.enable_mock or not self.steam_mcp:
context.playtime_hours = 87.3
context.achievements_unlocked = 12
context.achievements_total = 30
context.current_players_online = 523
context.is_running = True
return context
try:
players_result = await self.steam_mcp.call_tool(
"steam-current-players",
{"app_id": MORROWIND_APP_ID}
)
if isinstance(players_result, (int, float)):
context.current_players_online = int(players_result)
elif isinstance(players_result, str):
digits = "".join(c for c in players_result if c.isdigit())
if digits:
context.current_players_online = int(digits)
context.playtime_hours = 0.0
context.achievements_unlocked = 0
context.achievements_total = 0
except Exception as e:
log.warning(f"Game context capture failed: {e}")
return context
# ═══ GAMEPORTAL PROTOCOL: execute_action() ═══
async def execute_action(self, action: dict) -> ActionResult:
"""
Execute an action in the game.
Supported actions:
- click: { "type": "click", "x": int, "y": int }
- right_click: { "type": "right_click", "x": int, "y": int }
- move_to: { "type": "move_to", "x": int, "y": int }
- press_key: { "type": "press_key", "key": str }
- hotkey: { "type": "hotkey", "keys": str }
- type_text: { "type": "type_text", "text": str }
Morrowind-specific shortcuts:
- inventory: press_key("Tab")
- journal: press_key("j")
- rest: press_key("t")
- activate: press_key("space") or press_key("e")
"""
action_type = action.get("type", "")
result = ActionResult(action=action_type, params=action)
if self.enable_mock or not self.desktop_mcp:
log.info(f"[MOCK] Action: {action_type} with params: {action}")
result.success = True
await self._send_telemetry({
"type": "action_executed",
"action": action_type,
"params": action,
"success": True,
"mock": True,
})
return result
try:
success = False
if action_type == "click":
success = await self._mcp_click(action.get("x", 0), action.get("y", 0))
elif action_type == "right_click":
success = await self._mcp_right_click(action.get("x", 0), action.get("y", 0))
elif action_type == "move_to":
success = await self._mcp_move_to(action.get("x", 0), action.get("y", 0))
elif action_type == "press_key":
success = await self._mcp_press_key(action.get("key", ""))
elif action_type == "hotkey":
success = await self._mcp_hotkey(action.get("keys", ""))
elif action_type == "type_text":
success = await self._mcp_type_text(action.get("text", ""))
elif action_type == "scroll":
success = await self._mcp_scroll(action.get("amount", 0))
else:
result.error = f"Unknown action type: {action_type}"
result.success = success
if not success and not result.error:
result.error = "MCP tool call failed"
except Exception as e:
result.success = False
result.error = str(e)
log.error(f"Action execution failed: {e}")
await self._send_telemetry({
"type": "action_executed",
"action": action_type,
"params": action,
"success": result.success,
"error": result.error,
})
return result
# ═══ MCP TOOL WRAPPERS ═══
async def _mcp_click(self, x: int, y: int) -> bool:
result = await self.desktop_mcp.call_tool("click", {"x": x, "y": y})
return "error" not in str(result).lower()
async def _mcp_right_click(self, x: int, y: int) -> bool:
result = await self.desktop_mcp.call_tool("right_click", {"x": x, "y": y})
return "error" not in str(result).lower()
async def _mcp_move_to(self, x: int, y: int) -> bool:
result = await self.desktop_mcp.call_tool("move_to", {"x": x, "y": y})
return "error" not in str(result).lower()
async def _mcp_press_key(self, key: str) -> bool:
result = await self.desktop_mcp.call_tool("press_key", {"key": key})
return "error" not in str(result).lower()
async def _mcp_hotkey(self, keys: str) -> bool:
result = await self.desktop_mcp.call_tool("hotkey", {"keys": keys})
return "error" not in str(result).lower()
async def _mcp_type_text(self, text: str) -> bool:
result = await self.desktop_mcp.call_tool("type_text", {"text": text})
return "error" not in str(result).lower()
async def _mcp_scroll(self, amount: int) -> bool:
result = await self.desktop_mcp.call_tool("scroll", {"amount": amount})
return "error" not in str(result).lower()
# ═══ MORROWIND-SPECIFIC ACTIONS ═══
async def open_inventory(self) -> ActionResult:
"""Open inventory screen (Tab key)."""
return await self.execute_action({"type": "press_key", "key": "Tab"})
async def open_journal(self) -> ActionResult:
"""Open journal (J key)."""
return await self.execute_action({"type": "press_key", "key": "j"})
async def rest(self) -> ActionResult:
"""Rest/wait (T key)."""
return await self.execute_action({"type": "press_key", "key": "t"})
async def activate(self) -> ActionResult:
"""Activate/interact with object or NPC (Space key)."""
return await self.execute_action({"type": "press_key", "key": "space"})
async def move_forward(self, duration: float = 0.5) -> ActionResult:
"""Move forward (W key held)."""
# Note: desktop-control MCP may not support hold; use press as proxy
return await self.execute_action({"type": "press_key", "key": "w"})
async def move_backward(self) -> ActionResult:
"""Move backward (S key)."""
return await self.execute_action({"type": "press_key", "key": "s"})
async def strafe_left(self) -> ActionResult:
"""Strafe left (A key)."""
return await self.execute_action({"type": "press_key", "key": "a"})
async def strafe_right(self) -> ActionResult:
"""Strafe right (D key)."""
return await self.execute_action({"type": "press_key", "key": "d"})
async def attack(self) -> ActionResult:
"""Attack (left click)."""
screen_w, screen_h = (1920, 1080)
return await self.execute_action({"type": "click", "x": screen_w // 2, "y": screen_h // 2})
# ═══ ODA LOOP (Observe-Decide-Act) ═══
async def run_pilot_loop(
self,
decision_fn: Callable[[GameState], list[dict]],
max_iterations: int = 3,
iteration_delay: float = 2.0,
) -> list[dict]:
"""
Deterministic pilot loop — issue #673.
Runs perceive → decide → act cycles with world-state proof.
Each cycle captures a screenshot as evidence of the game state.
Returns list of cycle traces for verification.
"""
log.info("=" * 50)
log.info("MORROWIND PILOT LOOP — STARTING")
log.info(f" Max iterations: {max_iterations}")
log.info(f" Iteration delay: {iteration_delay}s")
log.info("=" * 50)
self.running = True
cycle_traces = []
for iteration in range(max_iterations):
if not self.running:
break
self.cycle_count = iteration
cycle_trace = {
"cycle_index": iteration,
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": self.session_id,
}
log.info(f"\n--- Pilot Cycle {iteration + 1}/{max_iterations} ---")
# 1. PERCEIVE: Capture state (includes world-state proof via screenshot)
log.info("[PERCEIVE] Capturing game state...")
state = await self.capture_state()
log.info(f" Screenshot: {state.visual.screenshot_path}")
log.info(f" Window found: {state.visual.window_found}")
log.info(f" Location: {state.world_state.estimated_location}")
cycle_trace["perceive"] = {
"screenshot_path": state.visual.screenshot_path,
"window_found": state.visual.window_found,
"screen_size": list(state.visual.screen_size),
"world_state": state.to_dict()["world_state"],
}
# 2. DECIDE: Get actions from decision function
log.info("[DECIDE] Getting actions...")
actions = decision_fn(state)
log.info(f" Decision returned {len(actions)} actions")
cycle_trace["decide"] = {
"actions_planned": actions,
}
# 3. ACT: Execute actions
log.info("[ACT] Executing actions...")
results = []
for i, action in enumerate(actions):
log.info(f" Action {i+1}/{len(actions)}: {action.get('type', 'unknown')}")
result = await self.execute_action(action)
results.append(result)
log.info(f" Result: {'SUCCESS' if result.success else 'FAILED'}")
if result.error:
log.info(f" Error: {result.error}")
cycle_trace["act"] = {
"actions_executed": [r.to_dict() for r in results],
"succeeded": sum(1 for r in results if r.success),
"failed": sum(1 for r in results if not r.success),
}
# Persist cycle trace to JSONL
cycle_traces.append(cycle_trace)
if self.trace_file:
with open(self.trace_file, "a") as f:
f.write(json.dumps(cycle_trace) + "\n")
# Send cycle summary telemetry
await self._send_telemetry({
"type": "pilot_cycle_complete",
"cycle": iteration,
"actions_executed": len(actions),
"successful": sum(1 for r in results if r.success),
"world_state_proof": state.visual.screenshot_path,
})
if iteration < max_iterations - 1:
await asyncio.sleep(iteration_delay)
log.info("\n" + "=" * 50)
log.info("PILOT LOOP COMPLETE")
log.info(f"Total cycles: {len(cycle_traces)}")
log.info("=" * 50)
return cycle_traces
# ═══════════════════════════════════════════════════════════════════════════
# SIMPLE DECISION FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════
def simple_test_decision(state: GameState) -> list[dict]:
"""
A simple decision function for testing the pilot loop.
Moves to center of screen, then presses space to interact.
"""
actions = []
if state.visual.window_found:
center_x = state.visual.screen_size[0] // 2
center_y = state.visual.screen_size[1] // 2
actions.append({"type": "move_to", "x": center_x, "y": center_y})
actions.append({"type": "press_key", "key": "space"})
return actions
def morrowind_explore_decision(state: GameState) -> list[dict]:
"""
Example decision function for Morrowind exploration.
Would be replaced by a vision-language model that analyzes screenshots.
"""
actions = []
screen_w, screen_h = state.visual.screen_size
# Move forward
actions.append({"type": "press_key", "key": "w"})
# Look around (move mouse to different positions)
actions.append({"type": "move_to", "x": int(screen_w * 0.3), "y": int(screen_h * 0.5)})
return actions
# ═══════════════════════════════════════════════════════════════════════════
# CLI ENTRYPOINT
# ═══════════════════════════════════════════════════════════════════════════
async def main():
"""
Test the Morrowind harness with the deterministic pilot loop.
Usage:
python morrowind_harness.py [--mock] [--iterations N]
"""
import argparse
parser = argparse.ArgumentParser(
description="Morrowind/OpenMW MCP Harness — Deterministic Pilot Loop (issue #673)"
)
parser.add_argument(
"--mock",
action="store_true",
help="Run in mock mode (no actual MCP servers)",
)
parser.add_argument(
"--hermes-ws",
default=DEFAULT_HERMES_WS_URL,
help=f"Hermes WebSocket URL (default: {DEFAULT_HERMES_WS_URL})",
)
parser.add_argument(
"--iterations",
type=int,
default=3,
help="Number of pilot loop iterations (default: 3)",
)
parser.add_argument(
"--delay",
type=float,
default=1.0,
help="Delay between iterations in seconds (default: 1.0)",
)
args = parser.parse_args()
harness = MorrowindHarness(
hermes_ws_url=args.hermes_ws,
enable_mock=args.mock,
)
try:
await harness.start()
# Run deterministic pilot loop with world-state proof
traces = await harness.run_pilot_loop(
decision_fn=simple_test_decision,
max_iterations=args.iterations,
iteration_delay=args.delay,
)
# Print verification summary
log.info("\n--- Verification Summary ---")
log.info(f"Cycles completed: {len(traces)}")
for t in traces:
screenshot = t.get("perceive", {}).get("screenshot_path", "none")
actions = len(t.get("decide", {}).get("actions_planned", []))
succeeded = t.get("act", {}).get("succeeded", 0)
log.info(f" Cycle {t['cycle_index']}: screenshot={screenshot}, actions={actions}, ok={succeeded}")
except KeyboardInterrupt:
log.info("Interrupted by user")
finally:
await harness.stop()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -45,7 +45,6 @@ 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(
@@ -287,13 +286,6 @@ 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()
@@ -391,13 +383,6 @@ 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()
@@ -438,13 +423,6 @@ 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)}")

View File

@@ -66,7 +66,12 @@ class NostrIdentity:
if privkey_hex:
self.privkey = int(privkey_hex, 16)
else:
self.privkey = int.from_bytes(os.urandom(32), 'big') % N
# Rejection sampling: avoid modulo bias from % N
while True:
candidate = int.from_bytes(os.urandom(32), 'big')
if 0 < candidate < N:
self.privkey = candidate
break
self.pubkey = get_pubkey(self.privkey)
def sign_event(self, event):

View File

@@ -1,386 +0,0 @@
export class SymbolicEngine {
constructor() {
this.facts = new Map();
this.factIndices = new Map();
this.factMask = 0n;
this.rules = [];
this.reasoningLog = [];
}
addFact(key, value) {
this.facts.set(key, value);
if (!this.factIndices.has(key)) {
this.factIndices.set(key, BigInt(this.factIndices.size));
}
const bitIndex = this.factIndices.get(key);
if (value) {
this.factMask |= (1n << bitIndex);
} else {
this.factMask &= ~(1n << bitIndex);
}
}
addRule(condition, action, description) {
this.rules.push({ condition, action, description });
}
reason() {
this.rules.forEach(rule => {
if (rule.condition(this.facts)) {
const result = rule.action(this.facts);
if (result) {
this.logReasoning(rule.description, result);
}
}
});
}
logReasoning(ruleDesc, outcome) {
const entry = { timestamp: Date.now(), rule: ruleDesc, outcome: outcome };
this.reasoningLog.unshift(entry);
if (this.reasoningLog.length > 5) this.reasoningLog.pop();
const container = document.getElementById('symbolic-log-content');
if (container) {
const logDiv = document.createElement('div');
logDiv.className = 'symbolic-log-entry';
logDiv.innerHTML = `<span class=\symbolic-rule\>[RULE] ${ruleDesc}</span><span class=\symbolic-outcome\>${outcome}</span>`;
container.prepend(logDiv);
if (container.children.length > 5) container.lastElementChild.remove();
}
}
}
export class AgentFSM {
constructor(agentId, initialState, blackboard = null) {
this.agentId = agentId;
this.state = initialState;
this.transitions = {};
this.blackboard = blackboard;
if (this.blackboard) {
this.blackboard.write(`agent_${this.agentId}_state`, this.state, 'AgentFSM');
}
}
addTransition(fromState, toState, condition) {
if (!this.transitions[fromState]) this.transitions[fromState] = [];
this.transitions[fromState].push({ toState, condition });
}
update(facts) {
const possibleTransitions = this.transitions[this.state] || [];
for (const transition of possibleTransitions) {
if (transition.condition(facts)) {
const oldState = this.state;
this.state = transition.toState;
console.log(`[FSM] Agent ${this.agentId} transitioning: ${oldState} -> ${this.state}`);
if (this.blackboard) {
this.blackboard.write(`agent_${this.agentId}_state`, this.state, 'AgentFSM');
this.blackboard.write(`agent_${this.agentId}_last_transition`, { from: oldState, to: this.state, timestamp: Date.now() }, 'AgentFSM');
}
return true;
}
}
return false;
}
}
export class KnowledgeGraph {
constructor() {
this.nodes = new Map();
this.edges = [];
}
addNode(id, type, metadata = {}) {
this.nodes.set(id, { id, type, ...metadata });
}
addEdge(from, to, relation) {
this.edges.push({ from, to, relation });
}
query(from, relation) {
return this.edges
.filter(e => e.from === from && e.relation === relation)
.map(e => this.nodes.get(e.to));
}
}
export class Blackboard {
constructor() {
this.data = {};
this.subscribers = [];
}
write(key, value, source) {
const oldValue = this.data[key];
this.data[key] = value;
this.notify(key, value, oldValue, source);
}
read(key) { return this.data[key]; }
subscribe(callback) { this.subscribers.push(callback); }
notify(key, value, oldValue, source) {
this.subscribers.forEach(sub => sub(key, value, oldValue, source));
const container = document.getElementById('blackboard-log-content');
if (container) {
const entry = document.createElement('div');
entry.className = 'blackboard-entry';
entry.innerHTML = `<span class=\bb-source\>[${source}]</span> <span class=\bb-key\>${key}</span>: <span class=\bb-value\>${JSON.stringify(value)}</span>`;
container.prepend(entry);
if (container.children.length > 8) container.lastElementChild.remove();
}
}
}
export class SymbolicPlanner {
constructor() {
this.actions = [];
this.currentPlan = [];
}
addAction(name, preconditions, effects) {
this.actions.push({ name, preconditions, effects });
}
heuristic(state, goal) {
let h = 0;
for (let key in goal) {
if (state[key] !== goal[key]) {
h += Math.abs((state[key] || 0) - (goal[key] || 0));
}
}
return h;
}
findPlan(initialState, goalState) {
let openSet = [{ state: initialState, plan: [], g: 0, h: this.heuristic(initialState, goalState) }];
let visited = new Map();
visited.set(JSON.stringify(initialState), 0);
while (openSet.length > 0) {
openSet.sort((a, b) => (a.g + a.h) - (b.g + b.h));
let { state, plan, g } = openSet.shift();
if (this.isGoalReached(state, goalState)) return plan;
for (let action of this.actions) {
if (this.arePreconditionsMet(state, action.preconditions)) {
let nextState = { ...state, ...action.effects };
let stateStr = JSON.stringify(nextState);
let nextG = g + 1;
if (!visited.has(stateStr) || nextG < visited.get(stateStr)) {
visited.set(stateStr, nextG);
openSet.push({
state: nextState,
plan: [...plan, action.name],
g: nextG,
h: this.heuristic(nextState, goalState)
});
}
}
}
}
return null;
}
isGoalReached(state, goal) {
for (let key in goal) {
if (state[key] !== goal[key]) return false;
}
return true;
}
arePreconditionsMet(state, preconditions) {
for (let key in preconditions) {
if (state[key] < preconditions[key]) return false;
}
return true;
}
logPlan(plan) {
this.currentPlan = plan;
const container = document.getElementById('planner-log-content');
if (container) {
container.innerHTML = '';
if (!plan || plan.length === 0) {
container.innerHTML = '<div class=\planner-empty\>NO ACTIVE PLAN</div>';
return;
}
plan.forEach((step, i) => {
const div = document.createElement('div');
div.className = 'planner-step';
div.innerHTML = `<span class=\step-num\>${i+1}.</span> ${step}`;
container.appendChild(div);
});
}
}
}
export class HTNPlanner {
constructor() {
this.methods = {};
this.primitiveTasks = {};
}
addMethod(taskName, preconditions, subtasks) {
if (!this.methods[taskName]) this.methods[taskName] = [];
this.methods[taskName].push({ preconditions, subtasks });
}
addPrimitiveTask(taskName, preconditions, effects) {
this.primitiveTasks[taskName] = { preconditions, effects };
}
findPlan(initialState, tasks) {
return this.decompose(initialState, tasks, []);
}
decompose(state, tasks, plan) {
if (tasks.length === 0) return plan;
const [task, ...remainingTasks] = tasks;
if (this.primitiveTasks[task]) {
const { preconditions, effects } = this.primitiveTasks[task];
if (this.arePreconditionsMet(state, preconditions)) {
const nextState = { ...state, ...effects };
return this.decompose(nextState, remainingTasks, [...plan, task]);
}
return null;
}
const methods = this.methods[task] || [];
for (const method of methods) {
if (this.arePreconditionsMet(state, method.preconditions)) {
const result = this.decompose(state, [...method.subtasks, ...remainingTasks], plan);
if (result) return result;
}
}
return null;
}
arePreconditionsMet(state, preconditions) {
for (const key in preconditions) {
if (state[key] < (preconditions[key] || 0)) return false;
}
return true;
}
}
export class CaseBasedReasoner {
constructor() {
this.caseLibrary = [];
}
addCase(situation, action, outcome) {
this.caseLibrary.push({ situation, action, outcome, timestamp: Date.now() });
}
findSimilarCase(currentSituation) {
let bestMatch = null;
let maxSimilarity = -1;
this.caseLibrary.forEach(c => {
let similarity = this.calculateSimilarity(currentSituation, c.situation);
if (similarity > maxSimilarity) {
maxSimilarity = similarity;
bestMatch = c;
}
});
return maxSimilarity > 0.7 ? bestMatch : null;
}
calculateSimilarity(s1, s2) {
let score = 0, total = 0;
for (let key in s1) {
if (s2[key] !== undefined) {
score += 1 - Math.abs(s1[key] - s2[key]);
total += 1;
}
}
return total > 0 ? score / total : 0;
}
logCase(c) {
const container = document.getElementById('cbr-log-content');
if (container) {
const div = document.createElement('div');
div.className = 'cbr-entry';
div.innerHTML = `
<div class=\cbr-match\>SIMILAR CASE FOUND (${(this.calculateSimilarity(symbolicEngine.facts, c.situation) * 100).toFixed(0)}%)</div>
<div class=\cbr-action\>SUGGESTED: ${c.action}</div>
<div class=\cbr-outcome\>PREVIOUS OUTCOME: ${c.outcome}</div>
`;
container.prepend(div);
if (container.children.length > 3) container.lastElementChild.remove();
}
}
}
export class NeuroSymbolicBridge {
constructor(symbolicEngine, blackboard) {
this.engine = symbolicEngine;
this.blackboard = blackboard;
this.perceptionLog = [];
}
perceive(rawState) {
const concepts = [];
if (rawState.stability < 0.4 && rawState.energy > 60) concepts.push('UNSTABLE_OSCILLATION');
if (rawState.energy < 30 && rawState.activePortals > 2) concepts.push('CRITICAL_DRAIN_PATTERN');
concepts.forEach(concept => {
this.engine.addFact(concept, true);
this.logPerception(concept);
});
return concepts;
}
logPerception(concept) {
const container = document.getElementById('neuro-bridge-log-content');
if (container) {
const div = document.createElement('div');
div.className = 'neuro-bridge-entry';
div.innerHTML = `<span class=\neuro-icon\>🧠</span> <span class=\neuro-concept\>${concept}</span>`;
container.prepend(div);
if (container.children.length > 5) container.lastElementChild.remove();
}
}
}
export class MetaReasoningLayer {
constructor(planner, blackboard) {
this.planner = planner;
this.blackboard = blackboard;
this.reasoningCache = new Map();
this.performanceMetrics = { totalReasoningTime: 0, calls: 0 };
}
getCachedPlan(stateKey) {
const cached = this.reasoningCache.get(stateKey);
if (cached && (Date.now() - cached.timestamp < 10000)) return cached.plan;
return null;
}
cachePlan(stateKey, plan) {
this.reasoningCache.set(stateKey, { plan, timestamp: Date.now() });
}
reflect() {
const avgTime = this.performanceMetrics.totalReasoningTime / (this.performanceMetrics.calls || 1);
const container = document.getElementById('meta-log-content');
if (container) {
container.innerHTML = `
<div class=\meta-stat\>CACHE SIZE: ${this.reasoningCache.size}</div>
<div class=\meta-stat\>AVG LATENCY: ${avgTime.toFixed(2)}ms</div>
<div class=\meta-stat\>STATUS: ${avgTime > 50 ? 'OPTIMIZING' : 'NOMINAL'}</div>
`;
}
}
track(startTime) {
const duration = performance.now() - startTime;
this.performanceMetrics.totalReasoningTime += duration;
this.performanceMetrics.calls++;
}
}

View File

@@ -117,7 +117,7 @@ We are not a solo freelancer. We are a firm with a human principal and a fleet o
## Decision Rules
- Any project under $3k: decline (not worth context switching)
- Any project under $2k: decline (not worth context switching)
- Any project requiring on-site: decline unless >$500/hr
- Any project with unclear scope: require paid discovery phase first
- Any client who won't sign MSA: walk away

View File

@@ -178,25 +178,5 @@ Every engagement is backed by the full fleet. That means faster delivery, more t
---
## Let's Build
If your team needs production AI agent infrastructure — not slides, not demos, but systems that actually run — we should talk.
**Free 30-minute consultation:** We'll assess whether our capabilities match your needs. No pitch deck. No pressure.
**How to reach us:**
- Email: hello@whitestoneengineering.com
- Book a call: [SCHEDULING LINK]
- Telegram / Discord: Available on request
**What happens next:**
1. Discovery call (30 min, free)
2. Scoped proposal within 48 hours
3. 50% deposit, work begins immediately
*Whitestone Engineering LLC — Human-Led, Fleet-Powered*
---
*Portfolio last updated: April 2026*
*All systems described are running in production at time of writing.*

View File

@@ -5,29 +5,11 @@
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
"status": "online",
"color": "#ff6600",
"role": "pilot",
"position": { "x": 15, "y": 0, "z": -10 },
"rotation": { "y": -0.5 },
"portal_type": "game-world",
"world_category": "rpg",
"environment": "local",
"access_mode": "operator",
"readiness_state": "prototype",
"readiness_steps": {
"prototype": { "label": "Prototype", "done": true },
"runtime_ready": { "label": "Runtime Ready", "done": false },
"launched": { "label": "Launched", "done": false },
"harness_bridged": { "label": "Harness Bridged", "done": false }
},
"blocked_reason": null,
"telemetry_source": "hermes-harness:morrowind",
"owner": "Timmy",
"app_id": 22320,
"window_title": "OpenMW",
"destination": {
"url": null,
"url": "https://morrowind.timmy.foundation",
"type": "harness",
"action_label": "Enter Vvardenfell",
"params": { "world": "vvardenfell" }
}
},
@@ -37,7 +19,6 @@
"description": "Calradia battle harness. Massive armies, tactical command.",
"status": "downloaded",
"color": "#ffd700",
"role": "pilot",
"position": { "x": -15, "y": 0, "z": -10 },
"rotation": { "y": 0.5 },
"portal_type": "game-world",
@@ -69,7 +50,6 @@
"description": "The creative harness. Build, script, and manifest.",
"status": "online",
"color": "#4af0c0",
"role": "timmy",
"position": { "x": 0, "y": 0, "z": -20 },
"rotation": { "y": 0 },
"destination": {
@@ -84,7 +64,6 @@
"description": "The repository of all knowledge. History, logs, and ancient data.",
"status": "online",
"color": "#0066ff",
"role": "timmy",
"position": { "x": 25, "y": 0, "z": 0 },
"rotation": { "y": -1.57 },
"destination": {
@@ -99,7 +78,6 @@
"description": "A sanctuary for reflection and digital peace.",
"status": "online",
"color": "#ffd700",
"role": "timmy",
"position": { "x": -25, "y": 0, "z": 0 },
"rotation": { "y": 1.57 },
"destination": {
@@ -114,7 +92,6 @@
"description": "The open nexus. A place for agents to gather and connect.",
"status": "online",
"color": "#4af0c0",
"role": "reflex",
"position": { "x": 15, "y": 0, "z": 10 },
"rotation": { "y": -2.5 },
"destination": {
@@ -129,7 +106,6 @@
"description": "The transition point. Entry and exit from the Nexus core.",
"status": "standby",
"color": "#ff4466",
"role": "reflex",
"position": { "x": -15, "y": 0, "z": 10 },
"rotation": { "y": 2.5 },
"destination": {

View File

@@ -1,126 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Bannerlord Runtime Setup — Apple Silicon
# Issue #720: Stand up a local Windows game runtime for Bannerlord on Apple Silicon
#
# Chosen runtime: Whisky (Apple Game Porting Toolkit wrapper)
#
# Usage: ./scripts/bannerlord_runtime_setup.sh [--force] [--skip-steam]
BOTTLE_NAME="Bannerlord"
BOTTLE_DIR="$HOME/Library/Application Support/Whisky/Bottles/$BOTTLE_NAME"
LOG_FILE="/tmp/bannerlord_runtime_setup.log"
FORCE=false
SKIP_STEAM=false
for arg in "$@"; do
case "$arg" in
--force) FORCE=true ;;
--skip-steam) SKIP_STEAM=true ;;
esac
done
log() {
echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
fail() {
log "FATAL: $*"
exit 1
}
# ── Preflight ──────────────────────────────────────────────────────
log "=== Bannerlord Runtime Setup ==="
log "Platform: $(uname -m) macOS $(sw_vers -productVersion)"
if [[ "$(uname -m)" != "arm64" ]]; then
fail "This script requires Apple Silicon (arm64). Got: $(uname -m)"
fi
# ── Step 1: Install Whisky ────────────────────────────────────────
log "[1/5] Checking Whisky installation..."
if [[ -d "/Applications/Whisky.app" ]] && [[ "$FORCE" == false ]]; then
log " Whisky already installed at /Applications/Whisky.app"
else
log " Installing Whisky via Homebrew cask..."
if ! command -v brew &>/dev/null; then
fail "Homebrew not found. Install from https://brew.sh"
fi
brew install --cask whisky 2>&1 | tee -a "$LOG_FILE"
log " Whisky installed."
fi
# ── Step 2: Create Bottle ─────────────────────────────────────────
log "[2/5] Checking Bannerlord bottle..."
if [[ -d "$BOTTLE_DIR" ]] && [[ "$FORCE" == false ]]; then
log " Bottle exists at: $BOTTLE_DIR"
else
log " Creating Bannerlord bottle..."
# Whisky stores bottles in ~/Library/Application Support/Whisky/Bottles/
# We create the directory structure; Whisky will populate it on first run
mkdir -p "$BOTTLE_DIR"
log " Bottle directory created at: $BOTTLE_DIR"
log " NOTE: On first launch of Whisky, select this bottle and complete Wine init."
log " Open Whisky.app, create bottle named '$BOTTLE_NAME', Windows 10."
fi
# ── Step 3: Verify Whisky CLI ─────────────────────────────────────
log "[3/5] Verifying Whisky CLI access..."
WHISKY_APP="/Applications/Whisky.app"
if [[ -d "$WHISKY_APP" ]]; then
WHISKY_VERSION=$(defaults read "$WHISKY_APP/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "unknown")
log " Whisky version: $WHISKY_VERSION"
else
fail "Whisky.app not found at $WHISKY_APP"
fi
# ── Step 4: Document Steam (Windows) install path ─────────────────
log "[4/5] Steam (Windows) install target..."
STEAM_WIN_PATH="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/Steam.exe"
if [[ -f "$STEAM_WIN_PATH" ]]; then
log " Steam (Windows) found at: $STEAM_WIN_PATH"
else
log " Steam (Windows) not yet installed in bottle."
log " After opening Whisky:"
log " 1. Select the '$BOTTLE_NAME' bottle"
log " 2. Run the Steam Windows installer (download from store.steampowered.com)"
log " 3. Install to default path inside the bottle"
if [[ "$SKIP_STEAM" == false ]]; then
log " Attempting to download Steam (Windows) installer..."
STEAM_INSTALLER="/tmp/SteamSetup.exe"
if [[ ! -f "$STEAM_INSTALLER" ]]; then
curl -L -o "$STEAM_INSTALLER" "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe" 2>&1 | tee -a "$LOG_FILE"
fi
log " Steam installer at: $STEAM_INSTALLER"
log " Run this in Whisky: open -a Whisky"
log " Then: in the Bannerlord bottle, click 'Run' and select $STEAM_INSTALLER"
fi
fi
# ── Step 5: Bannerlord executable path ────────────────────────────
log "[5/5] Bannerlord executable target..."
BANNERLORD_EXE="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
if [[ -f "$BANNERLORD_EXE" ]]; then
log " Bannerlord found at: $BANNERLORD_EXE"
else
log " Bannerlord not yet installed."
log " Install via Steam (Windows) inside the Whisky bottle."
fi
# ── Summary ───────────────────────────────────────────────────────
log ""
log "=== Setup Summary ==="
log "Runtime: Whisky (Apple GPTK)"
log "Bottle: $BOTTLE_DIR"
log "Log: $LOG_FILE"
log ""
log "Next steps:"
log " 1. Open Whisky: open -a Whisky"
log " 2. Create/select '$BOTTLE_NAME' bottle (Windows 10)"
log " 3. Install Steam (Windows) in the bottle"
log " 4. Install Bannerlord via Steam"
log " 5. Enable D3DMetal in bottle settings"
log " 6. Run verification: ./scripts/bannerlord_verify_runtime.sh"
log ""
log "=== Done ==="

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Bannerlord Runtime Verification — Apple Silicon
# Issue #720: Verify the local Windows game runtime for Bannerlord
#
# Usage: ./scripts/bannerlord_verify_runtime.sh
BOTTLE_NAME="Bannerlord"
BOTTLE_DIR="$HOME/Library/Application Support/Whisky/Bottles/$BOTTLE_NAME"
REPORT_FILE="/tmp/bannerlord_runtime_verify.txt"
PASS=0
FAIL=0
WARN=0
check() {
local label="$1"
local result="$2" # PASS, FAIL, WARN
local detail="${3:-}"
case "$result" in
PASS) ((PASS++)) ; echo "[PASS] $label${detail:+ — $detail}" ;;
FAIL) ((FAIL++)) ; echo "[FAIL] $label${detail:+ — $detail}" ;;
WARN) ((WARN++)) ; echo "[WARN] $label${detail:+ — $detail}" ;;
esac
echo "$result: $label${detail:+ — $detail}" >> "$REPORT_FILE"
}
echo "=== Bannerlord Runtime Verification ===" | tee "$REPORT_FILE"
echo "Date: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" | tee -a "$REPORT_FILE"
echo "Platform: $(uname -m) macOS $(sw_vers -productVersion)" | tee -a "$REPORT_FILE"
echo "" | tee -a "$REPORT_FILE"
# ── Check 1: Whisky installed ────────────────────────────────────
if [[ -d "/Applications/Whisky.app" ]]; then
VER=$(defaults read "/Applications/Whisky.app/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || echo "?")
check "Whisky installed" "PASS" "v$VER at /Applications/Whisky.app"
else
check "Whisky installed" "FAIL" "not found at /Applications/Whisky.app"
fi
# ── Check 2: Bottle exists ───────────────────────────────────────
if [[ -d "$BOTTLE_DIR" ]]; then
check "Bannerlord bottle exists" "PASS" "$BOTTLE_DIR"
else
check "Bannerlord bottle exists" "FAIL" "missing: $BOTTLE_DIR"
fi
# ── Check 3: drive_c structure ───────────────────────────────────
if [[ -d "$BOTTLE_DIR/drive_c" ]]; then
check "Bottle drive_c populated" "PASS"
else
check "Bottle drive_c populated" "FAIL" "drive_c not found — bottle may need Wine init"
fi
# ── Check 4: Steam (Windows) ─────────────────────────────────────
STEAM_EXE="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/Steam.exe"
if [[ -f "$STEAM_EXE" ]]; then
check "Steam (Windows) installed" "PASS" "$STEAM_EXE"
else
check "Steam (Windows) installed" "FAIL" "not found at expected path"
fi
# ── Check 5: Bannerlord executable ───────────────────────────────
BANNERLORD_EXE="$BOTTLE_DIR/drive_c/Program Files (x86)/Steam/steamapps/common/Mount & Blade II Bannerlord/bin/Win64_Shipping_Client/Bannerlord.exe"
if [[ -f "$BANNERLORD_EXE" ]]; then
EXE_SIZE=$(stat -f%z "$BANNERLORD_EXE" 2>/dev/null || echo "?")
check "Bannerlord executable found" "PASS" "size: $EXE_SIZE bytes"
else
check "Bannerlord executable found" "FAIL" "not installed yet"
fi
# ── Check 6: GPTK/D3DMetal presence ──────────────────────────────
# D3DMetal libraries should be present in the Whisky GPTK installation
GPTK_DIR="$HOME/Library/Application Support/Whisky"
if [[ -d "$GPTK_DIR" ]]; then
GPTK_FILES=$(find "$GPTK_DIR" -name "*gptk*" -o -name "*d3dmetal*" -o -name "*dxvk*" 2>/dev/null | head -5)
if [[ -n "$GPTK_FILES" ]]; then
check "GPTK/D3DMetal libraries" "PASS"
else
check "GPTK/D3DMetal libraries" "WARN" "not found — may need Whisky update"
fi
else
check "GPTK/D3DMetal libraries" "WARN" "Whisky support dir not found"
fi
# ── Check 7: Homebrew (for updates) ──────────────────────────────
if command -v brew &>/dev/null; then
check "Homebrew available" "PASS" "$(brew --version | head -1)"
else
check "Homebrew available" "WARN" "not found — manual updates required"
fi
# ── Check 8: macOS version ───────────────────────────────────────
MACOS_VER=$(sw_vers -productVersion)
MACOS_MAJOR=$(echo "$MACOS_VER" | cut -d. -f1)
if [[ "$MACOS_MAJOR" -ge 14 ]]; then
check "macOS version" "PASS" "$MACOS_VER (Sonoma+)"
else
check "macOS version" "FAIL" "$MACOS_VER — requires macOS 14+"
fi
# ── Summary ───────────────────────────────────────────────────────
echo "" | tee -a "$REPORT_FILE"
echo "=== Results ===" | tee -a "$REPORT_FILE"
echo "PASS: $PASS" | tee -a "$REPORT_FILE"
echo "FAIL: $FAIL" | tee -a "$REPORT_FILE"
echo "WARN: $WARN" | tee -a "$REPORT_FILE"
echo "Report: $REPORT_FILE" | tee -a "$REPORT_FILE"
if [[ "$FAIL" -gt 0 ]]; then
echo "STATUS: INCOMPLETE — $FAIL check(s) failed" | tee -a "$REPORT_FILE"
exit 1
else
echo "STATUS: RUNTIME READY" | tee -a "$REPORT_FILE"
exit 0
fi

View File

@@ -50,24 +50,21 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
# Broadcast to all OTHER clients
if not clients:
continue
disconnected = set()
# Create broadcast tasks, tracking which client each task targets
task_client_pairs = []
# Track (client, task) pairs so failed clients are identified directly
send_jobs = []
for client in clients:
if client != websocket and client.open:
task = asyncio.create_task(client.send(message))
task_client_pairs.append((task, client))
send_jobs.append((client, asyncio.create_task(client.send(message))))
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 send_jobs:
results = await asyncio.gather(*[job[1] for job in send_jobs], return_exceptions=True)
for (target_client, _), result in zip(send_jobs, results):
if isinstance(result, Exception):
target_client = task_client_pairs[i][1]
logger.error(f"Failed to send to client {target_client.remote_address}: {result}")
logger.error(f"Failed to send to a client {target_client.remote_address}: {result}")
disconnected.add(target_client)
if disconnected:
clients.difference_update(disconnected)

View File

@@ -11,7 +11,7 @@ const ASSETS_TO_CACHE = [
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
caches.open(CachedName).then(cache => {
return cache.addAll(ASSETS_TO_CACHE);
})
);

330
style.css
View File

@@ -384,19 +384,6 @@ canvas#nexus-canvas {
color: rgba(160, 184, 208, 0.6);
}
.atlas-card-role {
font-family: var(--font-display);
font-size: 9px;
font-weight: 700;
letter-spacing: 1px;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.atlas-card-role.role-timmy { color: #4af0c0; background: rgba(74, 240, 192, 0.12); border: 1px solid rgba(74, 240, 192, 0.3); }
.atlas-card-role.role-reflex { color: #ff4466; background: rgba(255, 68, 102, 0.12); border: 1px solid rgba(255, 68, 102, 0.3); }
.atlas-card-role.role-pilot { color: #ffd700; background: rgba(255, 215, 0, 0.12); border: 1px solid rgba(255, 215, 0, 0.3); }
.atlas-footer {
padding: 15px 30px;
border-top: 1px solid var(--color-border);
@@ -423,123 +410,6 @@ canvas#nexus-canvas {
font-style: italic;
}
/* Atlas Controls */
.atlas-controls {
padding: 15px 30px;
border-bottom: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.atlas-search {
width: 100%;
padding: 10px 15px;
background: rgba(20, 30, 60, 0.6);
border: 1px solid var(--color-border);
color: var(--color-text);
font-family: var(--font-body);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.atlas-search:focus {
border-color: var(--color-primary);
}
.atlas-search::placeholder {
color: rgba(160, 184, 208, 0.4);
}
.atlas-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.atlas-filter-btn {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
padding: 4px 12px;
font-family: var(--font-display);
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 1px;
}
.atlas-filter-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.atlas-filter-btn.active {
background: rgba(74, 240, 192, 0.15);
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Enhanced Atlas Cards */
.status-downloaded { background: rgba(255, 165, 0, 0.2); color: #ffa500; border: 1px solid #ffa500; }
.status-indicator.downloaded { background: #ffa500; box-shadow: 0 0 5px #ffa500; }
.atlas-card-category {
font-family: var(--font-display);
font-size: 9px;
padding: 2px 6px;
border-radius: 2px;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.05);
color: var(--color-text-muted);
border: 1px solid rgba(255, 255, 255, 0.08);
margin-left: 6px;
}
.atlas-card-readiness {
display: flex;
gap: 4px;
margin-top: 10px;
margin-bottom: 5px;
}
.readiness-step {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 1px;
position: relative;
}
.readiness-step.done {
background: var(--portal-color, var(--color-primary));
}
.readiness-step[title] {
cursor: help;
}
.atlas-card-action {
font-family: var(--font-display);
font-size: 10px;
color: var(--portal-color, var(--color-primary));
letter-spacing: 1px;
}
.atlas-total {
color: var(--color-text-muted);
}
.atlas-empty {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: var(--color-text-muted);
font-style: italic;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
@@ -2207,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;
}