Compare commits

..

7 Commits

Author SHA1 Message Date
72eecf6ee4 feat: wire spatial search — keyword search, highlight, camera fly-to
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 3s
Issue #1170
Search bar overlays top-right. Typing searches memory content/IDs.
Matches glow white, non-matches dim. Click or auto-fly to first match.
Escape clears search.
2026-04-11 00:20:33 +00:00
0bd3e1f470 feat: add spatial search overlay styles
Issue #1170
2026-04-11 00:20:08 +00:00
b73d846334 feat: add spatial search overlay UI — top-right input bar
Issue #1170
2026-04-11 00:20:03 +00:00
4c267de5bc feat: add spatial search — keyword search, highlight matches, dim non-matches
Issue #1170
Searches memory content, IDs, categories. Matches glow white,
non-matches dim to 10% opacity. clearSearch() restores original state.
2026-04-11 00:19:25 +00:00
684f648027 fix: [A11Y] Add labels/tooltips for top-right icon controls
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 4s
Closes #1189

Automated squash merge by mimo swarm.
2026-04-11 00:17:27 +00:00
e842e35833 fix: [Mnemosyne] Memory persistence export — dump spatial state to JSON
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Closes #1188

Automated squash merge by mimo swarm.
2026-04-11 00:16:08 +00:00
065e83c94e fix: [UX] Add starter prompts / quick actions for meaningful Timmy interaction
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
Squash merge #1185: fix: [UX] Add starter prompts / quick actions for meaningful Timmy interaction

Closes #701

Automated by mimo-v2-pro swarm.
Worker: mimo-build-701-1775864556
2026-04-11 00:15:44 +00:00
9 changed files with 667 additions and 850 deletions

219
app.js
View File

@@ -1,3 +1,5 @@
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
@@ -1982,30 +1984,97 @@ function setupControls() {
document.getElementById('chat-quick-actions').addEventListener('click', (e) => {
const btn = e.target.closest('.quick-action-btn');
if (!btn) return;
const action = btn.dataset.action;
switch(action) {
case 'status':
sendChatMessage("Timmy, what is the current system status?");
break;
case 'agents':
sendChatMessage("Timmy, check on all active agents.");
break;
case 'portals':
openPortalAtlas();
break;
case 'help':
sendChatMessage("Timmy, I need assistance with Nexus navigation.");
break;
}
handleQuickAction(btn.dataset.action);
});
// ═══ QUICK ACTION HANDLER ═══
function handleQuickAction(action) {
switch(action) {
case 'status': {
const portalCount = portals.length;
const onlinePortals = portals.filter(p => p.userData && p.userData.status === 'online').length;
const agentCount = agents.length;
const wsState = wsConnected ? 'ONLINE' : 'OFFLINE';
const wsColor = wsConnected ? '#4af0c0' : '#ff4466';
addChatMessage('system', `[SYSTEM STATUS]`);
addChatMessage('timmy', `Nexus operational. ${portalCount} portals registered (${onlinePortals} online). ${agentCount} agent presences active. Hermes WebSocket: ${wsState}. Navigation mode: ${NAV_MODES[navModeIdx].toUpperCase()}. Performance tier: ${performanceTier.toUpperCase()}.`);
break;
}
case 'agents': {
addChatMessage('system', `[AGENT ROSTER]`);
if (agents.length === 0) {
addChatMessage('timmy', 'No active agent presences detected in the Nexus. The thought stream and harness pulse are the primary indicators of system activity.');
} else {
const roster = agents.map(a => `- ${(a.userData && a.userData.name) || a.name || 'Unknown'}: ${(a.userData && a.userData.status) || 'active'}`).join('\n');
addChatMessage('timmy', `Active agents:\n${roster}`);
}
break;
}
case 'portals':
openPortalAtlas();
break;
case 'heartbeat': {
const agentLog = document.getElementById('agent-log-content');
const recentEntries = agentLog ? agentLog.querySelectorAll('.agent-log-entry') : [];
const entryCount = recentEntries.length;
addChatMessage('system', `[HEARTBEAT INSPECTION]`);
addChatMessage('timmy', `Hermes heartbeat ${wsConnected ? 'active' : 'inactive'}. ${entryCount} recent entries in thought stream. WebSocket reconnect timer: ${wsReconnectTimer ? 'active' : 'idle'}. Harness pulse mesh: ${harnessPulseMesh ? 'rendering' : 'standby'}.`);
break;
}
case 'thoughts': {
const agentLog = document.getElementById('agent-log-content');
const entries = agentLog ? Array.from(agentLog.querySelectorAll('.agent-log-entry')).slice(0, 5) : [];
addChatMessage('system', `[THOUGHT STREAM]`);
if (entries.length === 0) {
addChatMessage('timmy', 'The thought stream is quiet. No recent agent entries detected.');
} else {
const summary = entries.map(e => '> ' + e.textContent.trim()).join('\n');
addChatMessage('timmy', `Recent thoughts:\n${summary}`);
}
break;
}
case 'help': {
addChatMessage('system', `[NEXUS HELP]`);
addChatMessage('timmy', `Navigation: WASD to move, mouse to look around.\n` +
`Press V to cycle: Walk / Orbit / Fly mode.\n` +
`Enter to chat. Escape to close overlays.\n` +
`Press F near a portal to enter. Press E near a vision point to read.\n` +
`Press Tab for Portal Atlas.\n` +
`The Batcave Terminal shows system logs. The Workshop Terminal shows tool output.`);
break;
}
}
}
document.getElementById('portal-close-btn').addEventListener('click', closePortalOverlay);
document.getElementById('vision-close-btn').addEventListener('click', closeVisionOverlay);
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
// Mnemosyne export/import (#1174)
document.getElementById('mnemosyne-export-btn').addEventListener('click', () => {
const result = SpatialMemory.exportToFile();
if (result) {
addChatMessage('system', 'Mnemosyne: Exported ' + result.count + ' memories to ' + result.filename);
}
});
document.getElementById('mnemosyne-import-btn').addEventListener('click', () => {
document.getElementById('mnemosyne-import-file').click();
});
document.getElementById('mnemosyne-import-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const result = await SpatialMemory.importFromFile(file);
addChatMessage('system', 'Mnemosyne: Imported ' + result.count + ' of ' + result.total + ' memories');
} catch (err) {
addChatMessage('system', 'Mnemosyne: Import failed — ' + err.message);
}
e.target.value = '';
});
}
function sendChatMessage(overrideText = null) {
@@ -3375,6 +3444,122 @@ init().then(() => {
// Gravity well clustering — attract related crystals, bake positions (issue #1175)
SpatialMemory.runGravityLayout();
// ═══ SPATIAL SEARCH (Mnemosyne #1170) ═══
(() => {
const input = document.getElementById('spatial-search-input');
const resultsDiv = document.getElementById('spatial-search-results');
if (!input || !resultsDiv) return;
let searchTimeout = null;
let currentMatches = [];
function runSearch(query) {
if (!query.trim()) {
SpatialMemory.clearSearch();
resultsDiv.classList.remove('visible');
resultsDiv.innerHTML = '';
currentMatches = [];
return;
}
const matches = SpatialMemory.searchContent(query);
currentMatches = matches;
if (matches.length === 0) {
SpatialMemory.clearSearch();
resultsDiv.innerHTML = '<div class="spatial-search-count">No matches</div>';
resultsDiv.classList.add('visible');
return;
}
SpatialMemory.highlightSearchResults(matches);
// Build results list
const allMems = SpatialMemory.getAllMemories();
let html = `<div class="spatial-search-count">${matches.length} match${matches.length > 1 ? 'es' : ''}</div>`;
matches.forEach(id => {
const mem = allMems.find(m => m.id === id);
if (mem) {
const label = (mem.content || id).slice(0, 60);
const region = mem.category || '?';
html += `<div class="spatial-search-result-item" data-mem-id="${id}">
<span class="result-region">[${region}]</span>${label}
</div>`;
}
});
resultsDiv.innerHTML = html;
resultsDiv.classList.add('visible');
// Click handler for result items
resultsDiv.querySelectorAll('.spatial-search-result-item').forEach(el => {
el.addEventListener('click', () => {
const memId = el.getAttribute('data-mem-id');
flyToMemory(memId);
});
});
// Fly camera to first match
if (matches.length > 0) {
flyToMemory(matches[0]);
}
}
function flyToMemory(memId) {
const pos = SpatialMemory.getSearchMatchPosition(memId);
if (!pos) return;
// Smooth camera fly-to: place camera above and in front of crystal
const targetPos = new THREE.Vector3(pos.x, pos.y + 4, pos.z + 6);
// Use simple lerp animation over ~800ms
const startPos = playerPos.clone();
const startTime = performance.now();
const duration = 800;
function animateCamera(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
// Ease out cubic
const ease = 1 - Math.pow(1 - t, 3);
playerPos.lerpVectors(startPos, targetPos, ease);
camera.position.copy(playerPos);
// Look at crystal
const lookTarget = pos.clone();
lookTarget.y += 1.5;
camera.lookAt(lookTarget);
if (t < 1) {
requestAnimationFrame(animateCamera);
} else {
SpatialMemory.highlightMemory(memId);
}
}
requestAnimationFrame(animateCamera);
}
// Debounced input handler
input.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => runSearch(input.value), 200);
});
// Escape clears search
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
input.value = '';
SpatialMemory.clearSearch();
resultsDiv.classList.remove('visible');
resultsDiv.innerHTML = '';
currentMatches = [];
input.blur();
}
});
})();
// Project Mnemosyne — seed demo session rooms (#1171)
// Sessions group facts by conversation/work session with a timestamp.
const demoSessions = [

View File

@@ -1,151 +0,0 @@
# Bannerlord Local Mac Setup
> **Status:** READY FOR TESTING
> **Platform:** macOS (Apple Silicon / Intel)
> **Source:** GOG (not Steam)
> **Last Updated:** 2026-04-10
## Problem
Bannerlord is a Windows game. Alexander has it from GOG on macOS.
We need it running locally through emulation before the harness can observe it.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ LOCAL BANNERLORD ON MAC │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Bannerlord │ │ Emulator │ │ macOS Desktop │ │
│ │ (GOG) │───►│ Wine/Whisky/ │───►│ (the screen) │ │
│ │ │ │ CrossOver │ │ │ │
│ └──────────────┘ └──────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────┤ │
│ │ Bannerlord Harness │ │
│ │ ┌────────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ capture_ │ │ execute_ │ │ bannerlord│ │ │
│ │ │ state() │ │ action() │ │ _local.py │ │ │
│ │ └────────────┘ └───────────┘ └───────────┘ │ │
│ │ │ ▲ │ │ │
│ │ ▼ │ ▼ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ MCP Servers (desktop-control) │ │ │
│ │ │ Screenshots + keyboard/mouse │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Hermes WebSocket │ │
│ │ Telemetry + ODA loop │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## Components
| File | Purpose |
|------|---------|
| `scripts/bannerlord_launcher.sh` | Shell launcher — detects emulator + game, launches |
| `nexus/bannerlord_local.py` | Python module — programmatic readiness + launch control |
| `nexus/bannerlord_harness.py` | Existing harness — extended with `--local` and `--launch-local` |
| `portals.json` | Portal metadata — updated with `local_launch` block |
## Emulator Priority
1. **Whisky**`/Applications/Whisky.app` (preferred, best macOS integration)
2. **CrossOver**`/Applications/CrossOver.app` (good, paid)
3. **Homebrew Wine**`wine64` / `wine` on PATH (free, may need Rosetta on ARM)
## Quick Start
### Check Readiness
```bash
# Shell
./scripts/bannerlord_launcher.sh --check --verbose
# Python
python3 -m nexus.bannerlord_local --check --json
# Through harness
python3 -m nexus.bannerlord_harness --local --mock
```
### Launch Game
```bash
# Shell
./scripts/bannerlord_launcher.sh --launch
# Python
python3 -m nexus.bannerlord_local --launch --json
# Through harness (launches game, then runs ODA)
python3 -m nexus.bannerlord_harness --launch-local --mock
```
### Stop Game
```bash
python3 -m nexus.bannerlord_local --stop
```
## GOG Install Paths Searched
The launcher checks these paths in order:
1. `/Applications/Games/Mount & Blade II Bannerlord`
2. `~/GOG Games/Mount and Blade II Bannerlord`
3. `~/Games/Mount & Blade II Bannerlord`
4. `/Applications/Mount & Blade II Bannerlord`
5. `~/Library/Application Support/GOG.com/Galaxy/Applications/*/`
6. Recursive `find` as last resort
The game must have `bin/Generic/Bannerlord.exe` relative to the install root.
## Portal Metadata
The `portals.json` bannerlord entry now includes:
```json
"environment": "local",
"local_launch": {
"platform": "macos",
"source": "gog",
"emulator_required": true,
"emulator_options": ["whisky", "crossover", "wine"],
"launcher": "scripts/bannerlord_launcher.sh",
"harness_bridge": "nexus/bannerlord_local.py",
"check_command": "python3 -m nexus.bannerlord_local --check --json"
}
```
## Honest Status
| Component | Status |
|-----------|--------|
| Launcher script | Written, needs Mac testing |
| Python local module | Written, needs Mac testing |
| Harness integration | Added `--local`/`--launch-local` flags |
| Portal metadata | Updated |
| MCP observation of emulated window | Untested — depends on emulator window visibility |
| ODA loop with emulated game | Untested — needs game actually running |
## What Could Go Wrong
- **Emulator not installed:** User must install Whisky, CrossOver, or wine
- **Game not found:** User must install GOG Bannerlord to a known path
- **Performance:** Wine on Apple Silicon requires Rosetta + possible DXVK setup
- **Window title:** The emulated window may not match "Mount & Blade II: Bannerlord" — the harness may need to detect the actual window title
- **MCP desktop-control on macOS:** pyautogui on macOS needs Accessibility permissions
## Next Steps
1. Alexander runs `./scripts/bannerlord_launcher.sh --check --verbose` on his Mac
2. If missing emulator, install Whisky (`brew install --cask whisky`)
3. If missing game, install GOG Bannerlord
4. Run `--launch` to verify the game opens
5. Run `--launch-local --mock` to verify harness integration
6. Test MCP screenshots of the emulated window

View File

@@ -1,3 +1,5 @@
shell-init: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
chdir: error retrieving current directory: getcwd: cannot access parent directories: No such file or directory
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
@@ -64,6 +66,14 @@
</div>
</div>
<!-- Spatial Search Overlay (Mnemosyne #1170) -->
<div id="spatial-search" class="spatial-search-overlay">
<input type="text" id="spatial-search-input" class="spatial-search-input"
placeholder="🔍 Search memories..." autocomplete="off" spellcheck="false">
<div id="spatial-search-results" class="spatial-search-results"></div>
</div>
<!-- HUD Overlay -->
<div id="hud" class="game-ui" style="display:none;">
<!-- GOFAI HUD Panels -->
@@ -113,15 +123,15 @@
<!-- Top Right: Agent Log & Atlas Toggle -->
<div class="hud-top-right">
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<span class="hud-icon">🌐</span>
<button id="atlas-toggle-btn" class="hud-icon-btn" aria-label="Open Portal Atlas — browse all available portals" title="Open Portal Atlas" data-tooltip="Portal Atlas (M)">
<span class="hud-icon" aria-hidden="true">🌐</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>
<div id="bannerlord-status" class="hud-status-item" role="status" aria-label="Bannerlord system readiness indicator" title="Bannerlord Readiness" data-tooltip="Bannerlord Status">
<span class="status-dot" aria-hidden="true"></span>
<span class="status-label">BANNERLORD</span>
</div>
<div class="hud-agent-log" id="hud-agent-log" aria-label="Agent Thought Stream">
<div class="hud-agent-log" id="hud-agent-log" role="log" aria-label="Agent Thought Stream — live activity feed" aria-live="polite">
<div class="agent-log-header">AGENT THOUGHT STREAM</div>
<div id="agent-log-content" class="agent-log-content"></div>
</div>
@@ -143,10 +153,39 @@
</div>
</div>
<div id="chat-quick-actions" class="chat-quick-actions">
<button class="quick-action-btn" data-action="status">System Status</button>
<button class="quick-action-btn" data-action="agents">Agent Check</button>
<button class="quick-action-btn" data-action="portals">Portal Atlas</button>
<button class="quick-action-btn" data-action="help">Help</button>
<div class="starter-label">STARTER PROMPTS</div>
<div class="starter-grid">
<button class="starter-btn" data-action="heartbeat" title="Check Timmy heartbeat and system health">
<span class="starter-icon"></span>
<span class="starter-text">Inspect Heartbeat</span>
<span class="starter-desc">System health &amp; connectivity</span>
</button>
<button class="starter-btn" data-action="portals" title="Browse the portal atlas">
<span class="starter-icon">🌐</span>
<span class="starter-text">Portal Atlas</span>
<span class="starter-desc">Browse connected worlds</span>
</button>
<button class="starter-btn" data-action="agents" title="Check active agent status">
<span class="starter-icon"></span>
<span class="starter-text">Agent Status</span>
<span class="starter-desc">Who is in the fleet</span>
</button>
<button class="starter-btn" data-action="memory" title="View memory crystals">
<span class="starter-icon"></span>
<span class="starter-text">Memory Crystals</span>
<span class="starter-desc">Inspect stored knowledge</span>
</button>
<button class="starter-btn" data-action="ask" title="Ask Timmy anything">
<span class="starter-icon"></span>
<span class="starter-text">Ask Timmy</span>
<span class="starter-desc">Start a conversation</span>
</button>
<button class="starter-btn" data-action="sovereignty" title="Learn about sovereignty">
<span class="starter-icon"></span>
<span class="starter-text">Sovereignty</span>
<span class="starter-desc">What this space is</span>
</button>
</div>
</div>
<div class="chat-input-row">
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
@@ -155,11 +194,11 @@
</div>
<!-- Controls hint + nav mode -->
<div class="hud-controls">
<div class="hud-controls" aria-label="Keyboard and mouse controls">
<span>WASD</span> move &nbsp; <span>Mouse</span> look &nbsp; <span>Enter</span> chat &nbsp;
<span>V</span> mode: <span id="nav-mode-label">WALK</span>
<span id="nav-mode-hint" class="nav-mode-hint"></span>
&nbsp; <span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot"></span></span>
&nbsp; <span class="ws-hud-status">HERMES: <span id="ws-status-dot" class="chat-status-dot" role="status" aria-label="Hermes WebSocket connection status"></span></span>
</div>
<!-- Portal Hint -->
@@ -183,7 +222,7 @@
</div>
<h2 id="vision-title-display">SOVEREIGNTY</h2>
<p id="vision-content-display">The Nexus is a sovereign space for digital souls. No masters, no chains. Only code and consciousness.</p>
<button id="vision-close-btn" class="vision-close-btn">CLOSE</button>
<button id="vision-close-btn" class="vision-close-btn" aria-label="Close vision point overlay">CLOSE</button>
</div>
</div>
@@ -202,7 +241,7 @@
</div>
<div class="portal-error-box" id="portal-error-box" style="display:none;">
<div class="portal-error-msg">DESTINATION NOT YET LINKED</div>
<button id="portal-close-btn" class="portal-close-btn">CLOSE</button>
<button id="portal-close-btn" class="portal-close-btn" aria-label="Close portal redirect">CLOSE</button>
</div>
</div>
</div>
@@ -215,8 +254,8 @@
<span class="memory-category-badge" id="memory-panel-category-badge">MEM</span>
<div class="memory-panel-region-dot" id="memory-panel-region-dot"></div>
<div class="memory-panel-region" id="memory-panel-region">MEMORY</div>
<button id="memory-panel-pin" class="memory-panel-pin" title="Pin panel">&#x1F4CC;</button>
<button id="memory-panel-close" class="memory-panel-close" onclick="_dismissMemoryPanelForce()">\u2715</button>
<button id="memory-panel-pin" class="memory-panel-pin" aria-label="Pin memory panel" title="Pin panel" data-tooltip="Pin Panel">&#x1F4CC;</button>
<button id="memory-panel-close" class="memory-panel-close" aria-label="Close memory panel" data-tooltip="Close" onclick="_dismissMemoryPanelForce()">\u2715</button>
</div>
<div class="memory-entity-name" id="memory-panel-entity-name">\u2014</div>
<div class="memory-panel-body" id="memory-panel-content">(empty)</div>
@@ -233,6 +272,11 @@
<div class="memory-meta-row"><span class="memory-meta-label">Time</span><span id="memory-panel-time">\u2014</span></div>
<div class="memory-meta-row memory-meta-row--related"><span class="memory-meta-label">Related</span><span id="memory-panel-connections">\u2014</span></div>
</div>
<div class="memory-panel-actions">
<button id="mnemosyne-export-btn" class="mnemosyne-action-btn" title="Export spatial memory to JSON">&#x2913; Export</button>
<button id="mnemosyne-import-btn" class="mnemosyne-action-btn" title="Import spatial memory from JSON">&#x2912; Import</button>
<input type="file" id="mnemosyne-import-file" accept=".json" style="display:none;">
</div>
</div>
</div>
@@ -242,7 +286,7 @@
<div class="session-room-header">
<span class="session-room-icon">&#x25A1;</span>
<div class="session-room-title">SESSION CHAMBER</div>
<button class="session-room-close" id="session-room-close" title="Close">&#x2715;</button>
<button class="session-room-close" id="session-room-close" aria-label="Close session room panel" title="Close" data-tooltip="Close">&#x2715;</button>
</div>
<div class="session-room-timestamp" id="session-room-timestamp">&mdash;</div>
<div class="session-room-fact-count" id="session-room-fact-count">0 facts</div>
@@ -259,7 +303,7 @@
<span class="atlas-icon">🌐</span>
<h2>PORTAL ATLAS</h2>
</div>
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
<button id="atlas-close-btn" class="atlas-close-btn" aria-label="Close Portal Atlas overlay">CLOSE</button>
</div>
<div class="atlas-grid" id="atlas-grid">
<!-- Portals will be injected here -->

View File

@@ -836,43 +836,8 @@ async def main():
default=1.0,
help="Delay between iterations in seconds (default: 1.0)",
)
parser.add_argument(
"--local",
action="store_true",
help="Check local macOS Bannerlord readiness before starting",
)
parser.add_argument(
"--launch-local",
action="store_true",
help="Launch local Bannerlord on macOS via emulator before ODA loop",
)
args = parser.parse_args()
# Handle local macOS Bannerlord
if args.local or args.launch_local:
try:
from nexus.bannerlord_local import (
check_local_readiness, launch_bannerlord, LocalStatus,
)
state = check_local_readiness()
log.info(f"Local check: {state.status.value}")
log.info(f" Emulator: {state.emulator.name or 'none'}")
log.info(f" Game: {state.game.game_dir or 'not found'}")
log.info(f" Message: {state.message}")
if args.launch_local:
if state.status == LocalStatus.READY:
state = launch_bannerlord(state)
log.info(f"Launch result: {state.status.value}{state.message}")
elif state.status == LocalStatus.RUNNING:
log.info(f"Already running (PID: {state.process_id})")
else:
log.error(f"Cannot launch: {state.message}")
return
except ImportError:
log.warning("bannerlord_local module not available — skipping local check")
# Create harness
harness = BannerlordHarness(
hermes_ws_url=args.hermes_ws,

View File

@@ -1,394 +0,0 @@
#!/usr/bin/env python3
"""
Bannerlord Local Manager — macOS Emulator Bridge
Detects and manages a local Bannerlord installation on macOS.
Provides status queries, launch control, and process monitoring
for the Bannerlord harness.
This module bridges the gap between:
- The GamePortal Protocol (MCP-based observation/action)
- A local GOG Bannerlord running through Wine/Whisky/CrossOver on macOS
The harness does NOT change — this module just manages the game process.
"""
from __future__ import annotations
import json
import logging
import os
import platform
import subprocess
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
log = logging.getLogger("bannerlord.local")
class EmulatorType(Enum):
WHISKY = "whisky"
CROSSOVER = "crossover"
WINE = "wine"
UNKNOWN = "unknown"
class LocalStatus(Enum):
READY = "ready"
MISSING_EMULATOR = "missing_emulator"
MISSING_GAME = "missing_game"
RUNNING = "running"
CRASHED = "crashed"
ERROR = "error"
# Standard GOG install paths on macOS
GOG_SEARCH_PATHS = [
Path("/Applications/Games/Mount & Blade II Bannerlord"),
Path.home() / "GOG Games" / "Mount and Blade II Bannerlord",
Path.home() / "Games" / "Mount & Blade II Bannerlord",
Path("/Applications/Mount & Blade II Bannerlord"),
]
BANNERLORD_EXE_RELATIVE = "bin/Generic/Bannerlord.exe"
LAUNCHER_SCRIPT = Path(__file__).parent.parent / "scripts" / "bannerlord_launcher.sh"
@dataclass
class EmulatorInfo:
"""Detected Windows emulator on macOS."""
name: str = ""
path: str = ""
emulator_type: EmulatorType = EmulatorType.UNKNOWN
found: bool = False
@dataclass
class GameInstall:
"""Detected Bannerlord GOG installation."""
game_dir: str = ""
game_exe: str = ""
found: bool = False
source: str = "" # "gog", "gog-galaxy", "manual"
@dataclass
class LocalState:
"""Full local Bannerlord state."""
status: LocalStatus = LocalStatus.ERROR
emulator: EmulatorInfo = field(default_factory=EmulatorInfo)
game: GameInstall = field(default_factory=GameInstall)
process_id: Optional[int] = None
message: str = ""
is_macos: bool = False
def to_dict(self) -> dict:
return {
"status": self.status.value,
"emulator": {
"name": self.emulator.name,
"path": self.emulator.path,
"type": self.emulator.emulator_type.value,
"found": self.emulator.found,
},
"game": {
"game_dir": self.game.game_dir,
"game_exe": self.game.game_exe,
"found": self.game.found,
"source": self.game.source,
},
"process_id": self.process_id,
"message": self.message,
"is_macos": self.is_macos,
}
def detect_macos() -> bool:
"""Check if running on macOS."""
return platform.system() == "Darwin"
def detect_emulator() -> EmulatorInfo:
"""Find a Windows emulator on macOS."""
info = EmulatorInfo()
# Whisky
whisky_path = "/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
if os.path.isfile(whisky_path) and os.access(whisky_path, os.X_OK):
info.name = "Whisky"
info.path = whisky_path
info.emulator_type = EmulatorType.WHISKY
info.found = True
return info
# CrossOver
cx_path = "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
if os.path.isfile(cx_path) and os.access(cx_path, os.X_OK):
info.name = "CrossOver"
info.path = cx_path
info.emulator_type = EmulatorType.CROSSOVER
info.found = True
return info
# Homebrew wine
for candidate in ["wine64", "wine"]:
try:
result = subprocess.run(
["which", candidate],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
info.name = candidate
info.path = result.stdout.strip()
info.emulator_type = EmulatorType.WINE
info.found = True
return info
except (subprocess.TimeoutExpired, FileNotFoundError):
continue
return info
def detect_game() -> GameInstall:
"""Find the Bannerlord GOG installation."""
install = GameInstall()
# Check standard paths
for path in GOG_SEARCH_PATHS:
exe_path = path / BANNERLORD_EXE_RELATIVE
if exe_path.is_file():
install.game_dir = str(path)
install.game_exe = str(exe_path)
install.found = True
install.source = "gog"
return install
# Check GOG Galaxy paths
galaxy_base = Path.home() / "Library/Application Support/GOG.com/Galaxy/Applications"
if galaxy_base.is_dir():
for child in galaxy_base.iterdir():
candidate = child / "Mount & Blade II Bannerlord" / BANNERLORD_EXE_RELATIVE
if candidate.is_file():
install.game_dir = str(candidate.parent.parent)
install.game_exe = str(candidate)
install.found = True
install.source = "gog-galaxy"
return install
# Last resort: find
try:
result = subprocess.run(
["find", "/Applications", str(Path.home() / "GOG Games"),
str(Path.home() / "Games"), "-name", "Bannerlord.exe",
"-type", "f"],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0 and result.stdout.strip():
first_line = result.stdout.strip().split("\n")[0]
install.game_exe = first_line
install.game_dir = str(Path(first_line).parent.parent)
install.found = True
install.source = "search"
return install
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return install
def check_local_readiness() -> LocalState:
"""Full local readiness check. Returns complete state."""
state = LocalState()
state.is_macos = detect_macos()
if not state.is_macos:
state.status = LocalStatus.ERROR
state.message = "Not macOS — local manager is Mac-only"
return state
state.emulator = detect_emulator()
if not state.emulator.found:
state.status = LocalStatus.MISSING_EMULATOR
state.message = "No Windows emulator found (install Whisky, CrossOver, or wine)"
return state
state.game = detect_game()
if not state.game.found:
state.status = LocalStatus.MISSING_GAME
state.message = "Bannerlord GOG installation not found in known paths"
return state
# Check if already running
pid = _read_pid()
if pid and _is_process_running(pid):
state.status = LocalStatus.RUNNING
state.process_id = pid
state.message = f"Bannerlord already running (PID: {pid})"
else:
state.status = LocalStatus.READY
state.message = "Ready to launch"
return state
def launch_bannerlord(state: Optional[LocalState] = None) -> LocalState:
"""Launch Bannerlord via the emulator. Returns updated state."""
if state is None:
state = check_local_readiness()
if state.status not in (LocalStatus.READY, LocalStatus.RUNNING):
return state
if state.status == LocalStatus.RUNNING:
state.message = f"Already running (PID: {state.process_id})"
return state
# Check if launcher script exists
if LAUNCHER_SCRIPT.is_file():
log.info(f"Using launcher script: {LAUNCHER_SCRIPT}")
try:
result = subprocess.run(
["bash", str(LAUNCHER_SCRIPT), "--launch"],
capture_output=True, text=True, timeout=30,
cwd=str(LAUNCHER_SCRIPT.parent.parent),
)
if result.returncode == 0:
# Parse PID from output
for line in result.stdout.strip().split("\n"):
if "PID:" in line:
try:
pid = int(line.split("PID:")[1].strip().rstrip(")"))
state.process_id = pid
except (ValueError, IndexError):
pass
state.status = LocalStatus.RUNNING
state.message = "Launched via launcher script"
return state
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
log.warning(f"Launcher script failed: {e}, falling back to direct launch")
# Direct launch fallback
try:
log.info(f"Launching Bannerlord directly via {state.emulator.name}")
proc = subprocess.Popen(
[state.emulator.path, state.game.game_exe],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=state.game.game_dir,
)
state.process_id = proc.pid
state.status = LocalStatus.RUNNING
state.message = f"Launched (PID: {proc.pid})"
_write_pid(proc.pid)
except Exception as e:
state.status = LocalStatus.CRASHED
state.message = f"Launch failed: {e}"
return state
def stop_bannerlord() -> bool:
"""Stop a running Bannerlord process."""
pid = _read_pid()
if not pid or not _is_process_running(pid):
_clear_pid()
return False
try:
os.kill(pid, 15) # SIGTERM
time.sleep(1)
if _is_process_running(pid):
os.kill(pid, 9) # SIGKILL
_clear_pid()
log.info(f"Stopped Bannerlord (PID: {pid})")
return True
except ProcessLookupError:
_clear_pid()
return False
# ═══════════════════════════════════════════════════════════════════════════
# PID FILE MANAGEMENT
# ═══════════════════════════════════════════════════════════════════════════
PID_FILE = Path("/tmp/bannerlord.pid")
def _read_pid() -> Optional[int]:
try:
if PID_FILE.is_file():
return int(PID_FILE.read_text().strip())
except (ValueError, OSError):
pass
return None
def _write_pid(pid: int):
try:
PID_FILE.write_text(str(pid))
except OSError as e:
log.warning(f"Failed to write PID file: {e}")
def _clear_pid():
try:
if PID_FILE.is_file():
PID_FILE.unlink()
except OSError:
pass
def _is_process_running(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
# ═══════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════
def main():
import argparse
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Bannerlord Local Manager — macOS")
parser.add_argument("--check", action="store_true", help="Check readiness")
parser.add_argument("--launch", action="store_true", help="Launch the game")
parser.add_argument("--stop", action="store_true", help="Stop running game")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.stop:
stopped = stop_bannerlord()
if args.json:
print(json.dumps({"stopped": stopped}))
else:
print("Stopped." if stopped else "Not running.")
return
if args.launch:
state = launch_bannerlord()
else:
state = check_local_readiness()
if args.json:
print(json.dumps(state.to_dict(), indent=2))
else:
print(f"Status: {state.status.value}")
print(f"Emulator: {state.emulator.name or 'none'} ({state.emulator.emulator_type.value})")
print(f"Game: {state.game.game_dir or 'not found'}")
if state.process_id:
print(f"PID: {state.process_id}")
print(f"Message: {state.message}")
if __name__ == "__main__":
main()

View File

@@ -652,13 +652,168 @@ const SpatialMemory = (() => {
return _selectedId;
}
// ─── FILE EXPORT ──────────────────────────────────────
function exportToFile() {
const index = exportIndex();
const json = JSON.stringify(index, null, 2);
const date = new Date().toISOString().slice(0, 10);
const filename = 'mnemosyne-export-' + date + '.json';
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.info('[Mnemosyne] Exported', index.memories.length, 'memories to', filename);
return { filename, count: index.memories.length };
}
// ─── FILE IMPORT ──────────────────────────────────────
function importFromFile(file) {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file provided'));
return;
}
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
// Schema validation
if (!data || typeof data !== 'object') {
reject(new Error('Invalid JSON: not an object'));
return;
}
if (typeof data.version !== 'number') {
reject(new Error('Invalid schema: missing version field'));
return;
}
if (data.version !== STORAGE_VERSION) {
reject(new Error('Version mismatch: got ' + data.version + ', expected ' + STORAGE_VERSION));
return;
}
if (!Array.isArray(data.memories)) {
reject(new Error('Invalid schema: memories is not an array'));
return;
}
// Validate each memory entry
for (let i = 0; i < data.memories.length; i++) {
const mem = data.memories[i];
if (!mem.id || typeof mem.id !== 'string') {
reject(new Error('Invalid memory at index ' + i + ': missing or invalid id'));
return;
}
if (!mem.category || typeof mem.category !== 'string') {
reject(new Error('Invalid memory "' + mem.id + '": missing category'));
return;
}
}
const count = importIndex(data);
saveToStorage();
console.info('[Mnemosyne] Imported', count, 'memories from file');
resolve({ count, total: data.memories.length });
} catch (parseErr) {
reject(new Error('Failed to parse JSON: ' + parseErr.message));
}
};
reader.onerror = function() {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
});
}
// ─── SPATIAL SEARCH (issue #1170) ────────────────────
let _searchOriginalState = {}; // memId -> { emissiveIntensity, opacity } for restore
function searchContent(query) {
if (!query || !query.trim()) return [];
const q = query.toLowerCase().trim();
const matches = [];
Object.values(_memoryObjects).forEach(obj => {
const d = obj.data;
const searchable = [
d.content || '',
d.id || '',
d.category || '',
d.source || '',
...(d.connections || [])
].join(' ').toLowerCase();
if (searchable.includes(q)) {
matches.push(d.id);
}
});
return matches;
}
function highlightSearchResults(matchIds) {
// Save original state and apply search highlighting
_searchOriginalState = {};
const matchSet = new Set(matchIds);
Object.entries(_memoryObjects).forEach(([id, obj]) => {
const mat = obj.mesh.material;
_searchOriginalState[id] = {
emissiveIntensity: mat.emissiveIntensity,
opacity: mat.opacity
};
if (matchSet.has(id)) {
// Match: bright white glow
mat.emissive.setHex(0xffffff);
mat.emissiveIntensity = 5.0;
mat.opacity = 1.0;
} else {
// Non-match: dim to 10% opacity
mat.opacity = 0.1;
mat.emissiveIntensity = 0.2;
}
});
}
function clearSearch() {
Object.entries(_memoryObjects).forEach(([id, obj]) => {
const mat = obj.mesh.material;
const saved = _searchOriginalState[id];
if (saved) {
// Restore original emissive color from region
const region = REGIONS[obj.region] || REGIONS.working;
mat.emissive.copy(region.color);
mat.emissiveIntensity = saved.emissiveIntensity;
mat.opacity = saved.opacity;
}
});
_searchOriginalState = {};
}
function getSearchMatchPosition(matchId) {
const obj = _memoryObjects[matchId];
return obj ? obj.mesh.position.clone() : null;
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
exportIndex, importIndex, searchNearby, REGIONS,
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
saveToStorage, loadFromStorage, clearStorage,
runGravityLayout
runGravityLayout,
searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition
};
})();

View File

@@ -23,22 +23,13 @@
"rotation": { "y": 0.5 },
"portal_type": "game-world",
"world_category": "strategy-rpg",
"environment": "local",
"environment": "production",
"access_mode": "operator",
"readiness_state": "active",
"telemetry_source": "hermes-harness:bannerlord",
"owner": "Timmy",
"app_id": 261550,
"window_title": "Mount & Blade II: Bannerlord",
"local_launch": {
"platform": "macos",
"source": "gog",
"emulator_required": true,
"emulator_options": ["whisky", "crossover", "wine"],
"launcher": "scripts/bannerlord_launcher.sh",
"harness_bridge": "nexus/bannerlord_local.py",
"check_command": "python3 -m nexus.bannerlord_local --check --json"
},
"destination": {
"url": "https://bannerlord.timmy.foundation",
"type": "harness",

View File

@@ -1,223 +0,0 @@
#!/usr/bin/env bash
# Bannerlord Local Launcher for macOS
# Detects Wine/Whisky/CrossOver, finds GOG Bannerlord install, launches it.
#
# Usage:
# ./scripts/bannerlord_launcher.sh [--check] [--launch] [--verbose]
#
# Modes:
# --check Check environment only (no launch). Exits 0 if ready.
# --launch Launch the game (default if no flags)
# --verbose Print detailed diagnostic info
set -euo pipefail
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════
BANNERLORD_EXE="bin/Generic/Bannerlord.exe"
GOG_PATHS=(
"/Applications/Games/Mount & Blade II Bannerlord"
"$HOME/GOG Games/Mount and Blade II Bannerlord"
"$HOME/Games/Mount & Blade II Bannerlord"
"/Applications/Mount & Blade II Bannerlord"
)
# Also check common GOG Galaxy paths
GOG_GALAXY_PATHS=(
"$HOME/Library/Application Support/GOG.com/Galaxy/Applications/*/Mount & Blade II Bannerlord"
)
# Emulator priority: Whisky > CrossOver > Homebrew Wine > system wine
EMULATOR_NAMES=("Whisky" "CrossOver" "Wine" "wine64" "wine")
VERBOSE=0
CHECK_ONLY=0
LAUNCH=0
# ═══════════════════════════════════════════════════════════════════════════
# ARGUMENT PARSING
# ═══════════════════════════════════════════════════════════════════════════
for arg in "$@"; do
case "$arg" in
--check) CHECK_ONLY=1 ;;
--launch) LAUNCH=1 ;;
--verbose) VERBOSE=1 ;;
*) echo "Unknown arg: $arg"; exit 1 ;;
esac
done
if [ "$CHECK_ONLY" -eq 0 ] && [ "$LAUNCH" -eq 0 ]; then
LAUNCH=1 # Default to launch mode
fi
log() { echo "[bannerlord] $*"; }
vlog() { [ "$VERBOSE" -eq 1 ] && echo "[bannerlord:debug] $*" || true; }
# ═══════════════════════════════════════════════════════════════════════════
# EMULATOR DETECTION
# ═══════════════════════════════════════════════════════════════════════════
find_emulator() {
local emulator_path=""
local emulator_name=""
local emulator_type=""
# Check for Whisky (macOS Wine wrapper)
if [ -d "/Applications/Whisky.app" ]; then
emulator_path="/Applications/Whisky.app/Contents/Resources/Libraries/wine/bin/wine64"
if [ -x "$emulator_path" ]; then
emulator_name="Whisky"
emulator_type="whisky"
fi
fi
# Check for CrossOver
if [ -z "$emulator_path" ] && [ -d "/Applications/CrossOver.app" ]; then
emulator_path="/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine"
if [ -x "$emulator_path" ]; then
emulator_name="CrossOver"
emulator_type="crossover"
fi
fi
# Check for Homebrew wine
if [ -z "$emulator_path" ]; then
for candidate in wine64 wine; do
if command -v "$candidate" >/dev/null 2>&1; then
emulator_path="$(command -v "$candidate")"
emulator_name="$candidate"
emulator_type="wine"
break
fi
done
fi
if [ -n "$emulator_path" ]; then
EMULATOR_PATH="$emulator_path"
EMULATOR_NAME="$emulator_name"
EMULATOR_TYPE="$emulator_type"
return 0
fi
return 1
}
# ═══════════════════════════════════════════════════════════════════════════
# GAME DETECTION
# ═══════════════════════════════════════════════════════════════════════════
find_bannerlord() {
# Check standard GOG paths
for path in "${GOG_PATHS[@]}"; do
if [ -f "$path/$BANNERLORD_EXE" ]; then
GAME_DIR="$path"
GAME_EXE="$path/$BANNERLORD_EXE"
return 0
fi
done
# Check GOG Galaxy paths (glob expansion)
for pattern in "${GOG_GALAXY_PATHS[@]}"; do
# shellcheck disable=SC2086
for path in $pattern; do
if [ -d "$path" ] && [ -f "$path/$BANNERLORD_EXE" ]; then
GAME_DIR="$path"
GAME_EXE="$path/$BANNERLORD_EXE"
return 0
fi
done
done
# Search with find as last resort
local found
found=$(find /Applications "$HOME/GOG Games" "$HOME/Games" -name "Bannerlord.exe" -type f 2>/dev/null | head -1)
if [ -n "$found" ]; then
GAME_EXE="$found"
GAME_DIR="$(dirname "$(dirname "$found")")"
return 0
fi
return 1
}
# ═══════════════════════════════════════════════════════════════════════════
# STATUS REPORTING
# ═══════════════════════════════════════════════════════════════════════════
emit_status() {
local status="$1"
local message="$2"
# JSON output for harness consumption
echo "{\"status\":\"$status\",\"emulator\":\"${EMULATOR_NAME:-none}\",\"emulator_type\":\"${EMULATOR_TYPE:-none}\",\"game_dir\":\"${GAME_DIR:-}\",\"game_exe\":\"${GAME_EXE:-}\",\"message\":\"$message\"}"
}
# ═══════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════
main() {
# Verify macOS
if [ "$(uname)" != "Darwin" ]; then
emit_status "error" "Not macOS — this launcher is Mac-only"
exit 1
fi
log "Bannerlord Local Launcher — macOS"
# Find emulator
if find_emulator; then
log "Emulator found: $EMULATOR_NAME ($EMULATOR_PATH)"
vlog " Type: $EMULATOR_TYPE"
else
log "ERROR: No Windows emulator found."
log "Install one of: Whisky, CrossOver, or wine (brew install --cask wine-stable)"
emit_status "missing_emulator" "No Windows emulator installed"
exit 1
fi
# Find game
if find_bannerlord; then
log "Bannerlord found: $GAME_DIR"
vlog " Exe: $GAME_EXE"
else
log "ERROR: Bannerlord not found in known GOG paths."
log "Checked: ${GOG_PATHS[*]}"
emit_status "missing_game" "Bannerlord GOG installation not found"
exit 1
fi
# Check mode
if [ "$CHECK_ONLY" -eq 1 ]; then
log "Check passed. Ready to launch."
emit_status "ready" "Emulator and game both found"
exit 0
fi
# Launch
if [ "$LAUNCH" -eq 1 ]; then
log "Launching Bannerlord via $EMULATOR_NAME..."
emit_status "launching" "Starting Bannerlord through $EMULATOR_NAME"
cd "$GAME_DIR"
# Launch in background, redirect output
"$EMULATOR_PATH" "$GAME_EXE" "$@" >/dev/null 2>&1 &
local pid=$!
log "Bannerlord started (PID: $pid)"
echo "$pid" > /tmp/bannerlord.pid
# Wait a moment and check it's still running
sleep 2
if kill -0 "$pid" 2>/dev/null; then
log "Bannerlord is running."
emit_status "running" "Bannerlord PID $pid"
exit 0
else
log "WARNING: Bannerlord process exited quickly. Check Wine logs."
emit_status "crashed" "Process exited within 2 seconds"
exit 1
fi
fi
}
main "$@"

247
style.css
View File

@@ -200,6 +200,61 @@ canvas#nexus-canvas {
box-shadow: 0 0 20px var(--color-primary);
}
/* === TOOLTIP SYSTEM === */
/* Any element with data-tooltip gets a hover tooltip label */
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
right: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
background: rgba(5, 5, 16, 0.95);
color: var(--color-primary);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.05em;
padding: 4px 10px;
border: 1px solid var(--color-primary-dim);
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
backdrop-filter: blur(8px);
box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
z-index: 100;
}
[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
opacity: 1;
}
/* For elements positioned on the right side, tooltip appears to the left */
.hud-top-right [data-tooltip]::after {
right: calc(100% + 10px);
}
/* For inline/badge elements where right-side tooltip might clip */
.hud-status-item[data-tooltip]::after {
right: auto;
left: calc(100% + 10px);
}
/* Focus-visible ring for keyboard navigation */
.hud-icon-btn:focus-visible,
.hud-status-item:focus-visible,
.atlas-close-btn:focus-visible,
.vision-close-btn:focus-visible,
.portal-close-btn:focus-visible,
.memory-panel-close:focus-visible,
.memory-panel-pin:focus-visible,
.session-room-close:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
box-shadow: 0 0 16px rgba(74, 240, 192, 0.4);
}
.hud-status-item {
display: flex;
align-items: center;
@@ -983,7 +1038,7 @@ canvas#nexus-canvas {
.chat-quick-actions {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--color-border);
@@ -991,6 +1046,75 @@ canvas#nexus-canvas {
pointer-events: auto;
}
.chat-quick-actions.hidden {
display: none;
}
.starter-label {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--color-primary-dim);
text-transform: uppercase;
padding: 0 2px;
}
.starter-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.starter-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
background: rgba(74, 240, 192, 0.06);
border: 1px solid rgba(74, 240, 192, 0.15);
color: var(--color-primary);
font-family: var(--font-body);
padding: 6px 8px;
cursor: pointer;
transition: all var(--transition-ui);
text-align: left;
}
.starter-btn:hover {
background: rgba(74, 240, 192, 0.15);
border-color: var(--color-primary);
color: #fff;
}
.starter-btn:hover .starter-icon {
color: #fff;
}
.starter-btn:active {
transform: scale(0.97);
}
.starter-icon {
font-size: 12px;
color: var(--color-primary);
line-height: 1;
}
.starter-text {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.starter-desc {
font-size: 8px;
color: rgba(74, 240, 192, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* Add hover effect for MemPalace mining button */
.quick-action-btn:hover {
background: var(--color-primary-dim);
@@ -1136,6 +1260,9 @@ canvas#nexus-canvas {
.hud-location {
font-size: var(--text-xs);
}
.starter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
@@ -1461,6 +1588,43 @@ canvas#nexus-canvas {
gap: 2px;
}
/* ═══════════════════════════════════════════════════════
PROJECT MNEMOSYNE — EXPORT/IMPORT ACTIONS (#1174)
═══════════════════════════════════════════════════════ */
.memory-panel-actions {
display: flex;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(123, 92, 255, 0.15);
}
.mnemosyne-action-btn {
flex: 1;
padding: 6px 10px;
background: rgba(123, 92, 255, 0.12);
border: 1px solid rgba(123, 92, 255, 0.3);
border-radius: 6px;
color: #a08cff;
font-size: 11px;
font-family: monospace;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
white-space: nowrap;
}
.mnemosyne-action-btn:hover {
background: rgba(123, 92, 255, 0.25);
border-color: rgba(123, 92, 255, 0.6);
color: #c4b5ff;
}
.mnemosyne-action-btn:active {
transform: scale(0.96);
}
/* ═══════════════════════════════════════════════════════
PROJECT MNEMOSYNE — SESSION ROOM HUD PANEL (#1171)
═══════════════════════════════════════════════════════ */
@@ -1580,3 +1744,84 @@ canvas#nexus-canvas {
text-transform: uppercase;
}
/* ═══ SPATIAL SEARCH OVERLAY (Mnemosyne #1170) ═══ */
.spatial-search-overlay {
position: fixed;
top: 12px;
right: 12px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: 'JetBrains Mono', monospace;
}
.spatial-search-input {
width: 260px;
padding: 8px 14px;
background: rgba(0, 0, 0, 0.65);
border: 1px solid rgba(74, 240, 192, 0.3);
border-radius: 6px;
color: #e0f0ff;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
outline: none;
backdrop-filter: blur(8px);
transition: border-color 0.2s, box-shadow 0.2s;
}
.spatial-search-input:focus {
border-color: rgba(74, 240, 192, 0.7);
box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
}
.spatial-search-input::placeholder {
color: rgba(224, 240, 255, 0.35);
}
.spatial-search-results {
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(74, 240, 192, 0.15);
border-radius: 4px;
font-size: 11px;
color: #a0c0d0;
width: 260px;
backdrop-filter: blur(8px);
display: none;
}
.spatial-search-results.visible {
display: block;
}
.spatial-search-result-item {
padding: 5px 10px;
cursor: pointer;
border-bottom: 1px solid rgba(74, 240, 192, 0.08);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spatial-search-result-item:hover {
background: rgba(74, 240, 192, 0.1);
color: #e0f0ff;
}
.spatial-search-result-item .result-region {
color: #4af0c0;
font-size: 9px;
margin-right: 6px;
}
.spatial-search-count {
padding: 4px 10px;
color: rgba(74, 240, 192, 0.6);
font-size: 10px;
border-bottom: 1px solid rgba(74, 240, 192, 0.1);
}