Compare commits

..

2 Commits

Author SHA1 Message Date
3669aef92c Merge branch 'main' into fix/1543
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m13s
CI / validate (pull_request) Failing after 1m19s
2026-04-22 01:16:30 +00:00
Alexander Whitestone
f6cc734675 fix: #1543
Some checks failed
CI / test (pull_request) Failing after 39s
CI / validate (pull_request) Failing after 37s
Review Approval Gate / verify-review (pull_request) Failing after 5s
- Add crisis detection module for Nexus world chat
- Add js/crisis-detector.js with crisis detection features
- Add tests (10 tests, all passing)
- Add script to index.html

Features:
1. Crisis keyword detection (30+ keywords)
2. Pattern matching for crisis phrases
3. 988 crisis overlay display
4. Crisis metrics tracking
5. localStorage persistence

Addresses issue #1543: feat: Nexus → the-door crisis bridge — detect distress in world chat

Crisis detection:
- Detects keywords like 'suicide', 'kill myself', etc.
- Detects patterns like 'I want to die', etc.
- Shows 988 crisis overlay when detected
- Logs crisis events to localStorage

Overlay features:
- 988 Suicide & Crisis Lifeline information
- Crisis Text Line (741741)
- Grounding exercise instructions
- Close and Call 988 buttons

Tested:
- Keyword detection
- Pattern matching
- Metrics tracking
- Overlay visibility
- Crisis handler
2026-04-17 02:12:13 -04:00
16 changed files with 566 additions and 2473 deletions

3
app.js
View File

@@ -734,9 +734,6 @@ async function init() {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
// Start portal hot-reload watcher
if (window.PortalHotReload) PortalHotReload.start(5000);
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');

View File

@@ -1,719 +0,0 @@
/**
* cockpit-inspector.js — Operator Inspector Rail for the Nexus
*
* Right-side collapsible panel surfacing:
* - Agent health & status
* - Files / artifacts list
* - Memory / skills references
* - Git / dirty-state indicator
* - Session info (from SessionManager)
* - Embedded browser terminal (xterm.js via /pty WebSocket)
*
* Refs: issue #1695 — ATLAS cockpit operator patterns
* Pattern sources: dodo-reach/hermes-desktop, nesquena/hermes-webui
*/
(function () {
'use strict';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INSPECTOR_KEY = 'cockpit-inspector';
const PTY_WS_PORT = 8766; // separate port from main gateway (8765)
const GIT_POLL_MS = 15_000; // poll git state every 15s
const COLLAPSED_KEY = 'nexus-inspector-collapsed';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let _ws = null; // main nexus gateway WebSocket
let _ptyWs = null; // PTY WebSocket
let _term = null; // xterm.js Terminal instance
let _termFitAddon = null;
let _gitState = { branch: '—', dirty: false, untracked: 0, ahead: 0 };
let _agentHealth = {}; // agentId -> { status, last_seen }
let _artifacts = []; // { name, type, path, ts }
let _memRefs = []; // { label, region, count }
let _collapsed = false;
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function qs(sel, root) { return (root || document).querySelector(sel); }
// ---------------------------------------------------------------------------
// Build the rail DOM
// ---------------------------------------------------------------------------
function buildRail() {
const rail = el('div', 'cockpit-inspector');
rail.id = 'cockpit-inspector';
rail.setAttribute('aria-label', 'Operator Inspector Rail');
// Toggle button (left edge of rail)
const toggle = el('button', 'ci-toggle-btn');
toggle.id = 'ci-toggle-btn';
toggle.title = 'Toggle Inspector Rail';
toggle.innerHTML = '<span class="ci-toggle-icon">◁</span>';
toggle.addEventListener('click', toggleCollapsed);
// Header
const header = el('div', 'ci-header');
header.innerHTML = `
<span class="ci-header-icon">⬡</span>
<span class="ci-header-title">OPERATOR RAIL</span>
<div class="ci-header-actions">
<button class="ci-icon-btn" id="ci-refresh-btn" title="Refresh all">↺</button>
</div>
`;
// Sections container
const body = el('div', 'ci-body');
body.appendChild(buildGitSection());
body.appendChild(buildAgentHealthSection());
body.appendChild(buildSessionSection());
body.appendChild(buildArtifactsSection());
body.appendChild(buildMemSkillsSection());
body.appendChild(buildTerminalSection());
rail.appendChild(toggle);
rail.appendChild(header);
rail.appendChild(body);
document.body.appendChild(rail);
// Restore collapsed state
_collapsed = localStorage.getItem(COLLAPSED_KEY) === '1';
applyCollapsed();
// Refresh btn
qs('#ci-refresh-btn').addEventListener('click', refreshAll);
}
// -- Git State Section ------------------------------------------------------
function buildGitSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-git-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">⎇</span>
GIT STATE
<span class="ci-section-badge" id="ci-git-badge">—</span>
</div>
<div class="ci-section-body" id="ci-git-body">
<div class="ci-git-row">
<span class="ci-git-label">Branch</span>
<span class="ci-git-value" id="ci-git-branch">—</span>
</div>
<div class="ci-git-row">
<span class="ci-git-label">State</span>
<span class="ci-git-value" id="ci-git-dirty">—</span>
</div>
<div class="ci-git-row">
<span class="ci-git-label">Ahead</span>
<span class="ci-git-value" id="ci-git-ahead">—</span>
</div>
<div class="ci-git-row">
<span class="ci-git-label">Untracked</span>
<span class="ci-git-value" id="ci-git-untracked">—</span>
</div>
</div>
`;
return sec;
}
// -- Agent Health Section ----------------------------------------------------
function buildAgentHealthSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-agent-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">◉</span>
AGENT HEALTH
<span class="ci-section-badge" id="ci-agent-badge">0</span>
</div>
<div class="ci-section-body" id="ci-agent-body">
<div class="ci-empty-hint">No agents registered</div>
</div>
`;
return sec;
}
// -- Session Section ---------------------------------------------------------
function buildSessionSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-session-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">⬡</span>
SESSION
<button class="ci-icon-btn ci-session-new-btn" id="ci-session-new-btn" title="New session">+</button>
</div>
<div class="ci-section-body" id="ci-session-body">
<div class="ci-empty-hint">No sessions</div>
</div>
`;
return sec;
}
// -- Artifacts Section -------------------------------------------------------
function buildArtifactsSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-artifacts-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">◈</span>
FILES / ARTIFACTS
<span class="ci-section-badge" id="ci-artifacts-badge">0</span>
</div>
<div class="ci-section-body" id="ci-artifacts-body">
<div class="ci-empty-hint">No artifacts tracked</div>
</div>
`;
return sec;
}
// -- Memory / Skills Section -------------------------------------------------
function buildMemSkillsSection() {
const sec = el('div', 'ci-section');
sec.id = 'ci-mem-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">✦</span>
MEMORY / SKILLS
<span class="ci-section-badge" id="ci-mem-badge">0</span>
</div>
<div class="ci-section-body" id="ci-mem-body">
<div class="ci-empty-hint">No memory regions active</div>
</div>
`;
return sec;
}
// -- Terminal Section --------------------------------------------------------
function buildTerminalSection() {
const sec = el('div', 'ci-section ci-terminal-section');
sec.id = 'ci-terminal-section';
sec.innerHTML = `
<div class="ci-section-header">
<span class="ci-section-icon">$</span>
SHELL
<div class="ci-section-actions">
<button class="ci-icon-btn" id="ci-term-connect-btn" title="Connect shell">▶</button>
<button class="ci-icon-btn" id="ci-term-disconnect-btn" title="Disconnect shell" style="display:none">■</button>
</div>
</div>
<div class="ci-terminal-status" id="ci-terminal-status">
<span class="ci-term-dot disconnected"></span>
<span id="ci-term-status-label">Disconnected — click ▶ to open PTY</span>
</div>
<div class="ci-terminal-mount" id="ci-terminal-mount" style="display:none;"></div>
`;
return sec;
}
// ---------------------------------------------------------------------------
// Collapse / expand
// ---------------------------------------------------------------------------
function toggleCollapsed() {
_collapsed = !_collapsed;
localStorage.setItem(COLLAPSED_KEY, _collapsed ? '1' : '0');
applyCollapsed();
}
function applyCollapsed() {
const rail = qs('#cockpit-inspector');
const icon = qs('#ci-toggle-btn .ci-toggle-icon');
if (!rail) return;
if (_collapsed) {
rail.classList.add('collapsed');
if (icon) icon.textContent = '▷';
} else {
rail.classList.remove('collapsed');
if (icon) icon.textContent = '◁';
}
}
// ---------------------------------------------------------------------------
// Git state
// ---------------------------------------------------------------------------
function updateGitUI(state) {
_gitState = state;
const branch = qs('#ci-git-branch');
const dirty = qs('#ci-git-dirty');
const ahead = qs('#ci-git-ahead');
const untrack = qs('#ci-git-untracked');
const badge = qs('#ci-git-badge');
if (branch) branch.textContent = state.branch || '—';
if (ahead) ahead.textContent = state.ahead != null ? `+${state.ahead}` : '—';
if (untrack) untrack.textContent = state.untracked != null ? String(state.untracked) : '—';
if (dirty) {
const isDirty = state.dirty || state.untracked > 0;
dirty.textContent = isDirty ? '● DIRTY' : '✓ CLEAN';
dirty.className = 'ci-git-value ' + (isDirty ? 'ci-dirty' : 'ci-clean');
}
if (badge) {
const isDirty = state.dirty || state.untracked > 0;
badge.textContent = isDirty ? '●' : '✓';
badge.className = 'ci-section-badge ' + (isDirty ? 'badge-warn' : 'badge-ok');
}
}
function pollGitState() {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ type: 'git_status_request' }));
}
}
// ---------------------------------------------------------------------------
// Agent health
// ---------------------------------------------------------------------------
function updateAgentHealth(agentId, payload) {
_agentHealth[agentId] = {
status: payload.status || 'unknown',
last_seen: Date.now(),
model: payload.model || '',
task: payload.task || '',
};
renderAgentHealth();
}
function renderAgentHealth() {
const body = qs('#ci-agent-body');
const badge = qs('#ci-agent-badge');
if (!body) return;
const agents = Object.entries(_agentHealth);
if (badge) badge.textContent = agents.length;
if (agents.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No agents registered</div>';
return;
}
body.innerHTML = '';
agents.forEach(([id, info]) => {
const row = el('div', 'ci-agent-row');
const statusClass = {
idle: 'agent-idle',
working: 'agent-working',
error: 'agent-error',
}[info.status] || 'agent-unknown';
row.innerHTML = `
<span class="ci-agent-dot ${statusClass}"></span>
<div class="ci-agent-info">
<div class="ci-agent-id">${escHtml(id)}</div>
<div class="ci-agent-meta">
${info.model ? `<span class="ci-tag">${escHtml(info.model)}</span>` : ''}
${info.task ? `<span class="ci-agent-task">${escHtml(info.task.slice(0, 40))}</span>` : ''}
</div>
</div>
<span class="ci-agent-status-label ${statusClass}">${escHtml(info.status)}</span>
`;
body.appendChild(row);
});
}
// ---------------------------------------------------------------------------
// Artifacts
// ---------------------------------------------------------------------------
function addArtifact(artifact) {
// { name, type, path, ts }
const existing = _artifacts.findIndex(a => a.path === artifact.path);
if (existing >= 0) {
_artifacts[existing] = artifact;
} else {
_artifacts.unshift(artifact);
if (_artifacts.length > 50) _artifacts.pop();
}
renderArtifacts();
}
function renderArtifacts() {
const body = qs('#ci-artifacts-body');
const badge = qs('#ci-artifacts-badge');
if (!body) return;
if (badge) badge.textContent = _artifacts.length;
if (_artifacts.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No artifacts tracked</div>';
return;
}
body.innerHTML = '';
_artifacts.slice(0, 20).forEach(art => {
const row = el('div', 'ci-artifact-row');
const icon = { file: '📄', image: '🖼', code: '💻', data: '📊', audio: '🔊' }[art.type] || '📎';
row.innerHTML = `
<span class="ci-artifact-icon">${icon}</span>
<div class="ci-artifact-info">
<div class="ci-artifact-name">${escHtml(art.name)}</div>
${art.path ? `<div class="ci-artifact-path">${escHtml(art.path)}</div>` : ''}
</div>
<span class="ci-artifact-type ci-tag">${escHtml(art.type || '?')}</span>
`;
body.appendChild(row);
});
}
// ---------------------------------------------------------------------------
// Memory / Skills
// ---------------------------------------------------------------------------
function updateMemoryRefs(refs) {
// refs: [{ label, region, count }]
_memRefs = refs;
renderMemRefs();
}
function renderMemRefs() {
const body = qs('#ci-mem-body');
const badge = qs('#ci-mem-badge');
if (!body) return;
const total = _memRefs.reduce((s, r) => s + (r.count || 0), 0);
if (badge) badge.textContent = total;
if (_memRefs.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No memory regions active</div>';
return;
}
body.innerHTML = '';
_memRefs.forEach(ref => {
const row = el('div', 'ci-mem-row');
row.innerHTML = `
<span class="ci-mem-region">${escHtml(ref.label || ref.region)}</span>
<span class="ci-section-badge">${ref.count || 0}</span>
`;
body.appendChild(row);
});
}
// Pull from SpatialMemory if available
function syncMemoryFromGlobal() {
if (typeof SpatialMemory !== 'undefined' && SpatialMemory.getMemoryCountByRegion) {
const counts = SpatialMemory.getMemoryCountByRegion();
const regions = SpatialMemory.REGIONS || {};
const refs = Object.entries(counts)
.filter(([, c]) => c > 0)
.map(([key, count]) => ({
region: key,
label: (regions[key] && regions[key].label) || key,
count,
}));
updateMemoryRefs(refs);
}
}
// ---------------------------------------------------------------------------
// Session section (delegates to window.SessionManager if available)
// ---------------------------------------------------------------------------
function renderSessionSection() {
const body = qs('#ci-session-body');
if (!body) return;
const mgr = window.SessionManager;
if (!mgr) {
body.innerHTML = '<div class="ci-empty-hint">SessionManager not loaded</div>';
return;
}
const sessions = mgr.list();
if (sessions.length === 0) {
body.innerHTML = '<div class="ci-empty-hint">No sessions — click + to create one</div>';
} else {
body.innerHTML = '';
sessions.slice(0, 12).forEach(s => {
const row = el('div', 'ci-session-row' + (mgr.getActive() === s.id ? ' active' : ''));
row.dataset.id = s.id;
row.innerHTML = `
<div class="ci-session-info">
<div class="ci-session-name">${escHtml(s.name)}</div>
<div class="ci-session-meta">
${s.pinned ? '<span class="ci-tag tag-pin">📌</span>' : ''}
${s.archived ? '<span class="ci-tag tag-archive">🗄</span>' : ''}
${(s.tags || []).map(t => `<span class="ci-tag">${escHtml(t)}</span>`).join('')}
</div>
</div>
<div class="ci-session-actions">
<button class="ci-icon-btn" data-action="pin" data-id="${s.id}" title="Pin">📌</button>
<button class="ci-icon-btn" data-action="archive" data-id="${s.id}" title="Archive">🗄</button>
</div>
`;
row.addEventListener('click', (e) => {
if (e.target.dataset.action) {
e.stopPropagation();
handleSessionAction(e.target.dataset.action, e.target.dataset.id);
} else {
mgr.setActive(s.id);
renderSessionSection();
}
});
body.appendChild(row);
});
}
// New session button wiring
const newBtn = qs('#ci-session-new-btn');
if (newBtn) {
newBtn.onclick = () => {
const name = prompt('Session name:');
if (name) { mgr.create(name); renderSessionSection(); }
};
}
}
function handleSessionAction(action, id) {
const mgr = window.SessionManager;
if (!mgr) return;
if (action === 'pin') mgr.pin(id);
if (action === 'archive') mgr.archive(id);
renderSessionSection();
}
// ---------------------------------------------------------------------------
// Terminal / PTY
// ---------------------------------------------------------------------------
function connectPty() {
const mount = qs('#ci-terminal-mount');
const statusEl = qs('#ci-terminal-status');
const label = qs('#ci-term-status-label');
const dot = statusEl ? statusEl.querySelector('.ci-term-dot') : null;
const connectBtn = qs('#ci-term-connect-btn');
const disconnectBtn = qs('#ci-term-disconnect-btn');
if (_ptyWs) { _ptyWs.close(); _ptyWs = null; }
// Require xterm.js
if (typeof Terminal === 'undefined') {
if (label) label.textContent = 'xterm.js not loaded — check CDN';
return;
}
if (mount) mount.style.display = 'block';
if (connectBtn) connectBtn.style.display = 'none';
if (disconnectBtn) disconnectBtn.style.display = '';
// Create or reuse terminal instance
if (!_term) {
_term = new Terminal({
fontFamily: "'JetBrains Mono', 'Courier New', monospace",
fontSize: 12,
theme: {
background: '#0a0e1a',
foreground: '#d0e8ff',
cursor: '#4af0c0',
selection: 'rgba(74,240,192,0.2)',
},
cols: 80,
rows: 18,
});
if (typeof FitAddon !== 'undefined') {
_termFitAddon = new FitAddon.FitAddon();
_term.loadAddon(_termFitAddon);
}
_term.open(mount);
if (_termFitAddon) _termFitAddon.fit();
}
// Connect to PTY WebSocket
if (dot) { dot.className = 'ci-term-dot connecting'; }
if (label) label.textContent = 'Connecting to PTY…';
_ptyWs = new WebSocket(`ws://127.0.0.1:${PTY_WS_PORT}/pty`);
_ptyWs.binaryType = 'arraybuffer';
_ptyWs.onopen = () => {
if (dot) dot.className = 'ci-term-dot connected';
if (label) label.textContent = 'Connected — local PTY';
_term.writeln('\x1b[32mConnected to Nexus PTY gateway.\x1b[0m');
// Forward keystrokes
_term.onData(data => {
if (_ptyWs && _ptyWs.readyState === WebSocket.OPEN) {
_ptyWs.send(data);
}
});
};
_ptyWs.onmessage = (ev) => {
const text = (ev.data instanceof ArrayBuffer)
? new TextDecoder().decode(ev.data)
: ev.data;
if (_term) _term.write(text);
};
_ptyWs.onclose = () => {
if (dot) dot.className = 'ci-term-dot disconnected';
if (label) label.textContent = 'Disconnected';
if (connectBtn) connectBtn.style.display = '';
if (disconnectBtn) disconnectBtn.style.display = 'none';
if (_term) _term.writeln('\x1b[31m[PTY connection closed]\x1b[0m');
};
_ptyWs.onerror = () => {
if (label) label.textContent = 'Connection error — is server.py running?';
if (dot) dot.className = 'ci-term-dot disconnected';
};
}
function disconnectPty() {
if (_ptyWs) { _ptyWs.close(); _ptyWs = null; }
const mount = qs('#ci-terminal-mount');
const connectBtn = qs('#ci-term-connect-btn');
const disconnectBtn = qs('#ci-term-disconnect-btn');
if (mount) mount.style.display = 'none';
if (connectBtn) connectBtn.style.display = '';
if (disconnectBtn) disconnectBtn.style.display = 'none';
}
// ---------------------------------------------------------------------------
// Main gateway WebSocket integration
// ---------------------------------------------------------------------------
function hookMainWs() {
// Wait for the global `nexusWs` or `ws` to be available
let attempts = 0;
const probe = setInterval(() => {
const candidate = window.nexusWs || window.ws;
if (candidate && candidate.readyState <= 1) {
_ws = candidate;
clearInterval(probe);
_ws.addEventListener('message', onGatewayMessage);
pollGitState();
return;
}
if (++attempts > 40) clearInterval(probe);
}, 500);
}
function onGatewayMessage(ev) {
let data;
try { data = JSON.parse(ev.data); } catch { return; }
switch (data.type) {
case 'git_status':
updateGitUI({
branch: data.branch || '—',
dirty: !!data.dirty,
untracked: data.untracked || 0,
ahead: data.ahead || 0,
});
break;
case 'agent_register':
case 'agent_health':
if (data.agent_id) updateAgentHealth(data.agent_id, data);
break;
case 'thought':
case 'action':
if (data.agent_id) {
updateAgentHealth(data.agent_id, {
status: 'working',
task: data.content || data.action || '',
model: _agentHealth[data.agent_id]?.model || '',
});
}
break;
case 'artifact':
if (data.name) addArtifact({
name: data.name,
type: data.artifact_type || 'file',
path: data.path || '',
ts: Date.now(),
});
break;
case 'memory_update':
if (Array.isArray(data.refs)) updateMemoryRefs(data.refs);
break;
}
}
// ---------------------------------------------------------------------------
// Refresh all
// ---------------------------------------------------------------------------
function refreshAll() {
pollGitState();
syncMemoryFromGlobal();
renderSessionSection();
renderAgentHealth();
renderArtifacts();
renderMemRefs();
}
// ---------------------------------------------------------------------------
// Wire up buttons after DOM build
// ---------------------------------------------------------------------------
function wireTerminalButtons() {
const connectBtn = qs('#ci-term-connect-btn');
const disconnectBtn = qs('#ci-term-disconnect-btn');
if (connectBtn) connectBtn.addEventListener('click', connectPty);
if (disconnectBtn) disconnectBtn.addEventListener('click', disconnectPty);
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
function init() {
if (qs('#cockpit-inspector')) return; // already mounted
buildRail();
wireTerminalButtons();
hookMainWs();
// Poll git state periodically
setInterval(pollGitState, GIT_POLL_MS);
// Sync memory from global SpatialMemory periodically
setInterval(syncMemoryFromGlobal, 8_000);
// Initial session render
renderSessionSection();
// Expose public API
window.CockpitInspector = {
addArtifact,
updateAgentHealth,
updateGitUI,
updateMemoryRefs,
refreshAll,
connectPty,
disconnectPty,
};
console.info('[CockpitInspector] Operator rail mounted.');
}
// Boot after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// Defer slightly so app.js/boot.js finish their own init first
setTimeout(init, 300);
}
})();

View File

@@ -1,84 +0,0 @@
# ADR-001 — Shell / Terminal Boundary and Transport Model
**Status:** Accepted
**Date:** 2026-04-22
**Issue:** [#1695 — ATLAS Cockpit: operator inspector rail and session shell patterns](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1695)
---
## Context
The Nexus operator cockpit needs a real shell/terminal surface so the operator can run commands, inspect logs, and interact with the system without leaving the 3D world UI.
Three transport models were evaluated:
| Option | Description |
|---|---|
| **A. Native local PTY** | `server.py` spawns a PTY via Python's `pty` stdlib module; the browser connects via WebSocket on `ws://127.0.0.1:8766/pty` |
| **B. Remote SSH PTY** | Browser connects to an SSH relay that opens a PTY on a remote machine |
| **C. Browser pseudo-terminal** | Pure JavaScript terminal emulation (e.g., a custom REPL) with no real OS shell |
---
## Decision
**Option A — Native local PTY** is chosen.
The Nexus is explicitly a **local-first** system (CLAUDE.md: "local-first training ground for Timmy"). The operator is the same person sitting at the machine. A local PTY bridged through `server.py` is:
- Architecturally consistent with the existing `server.py` WebSocket gateway
- Zero additional infrastructure (no SSH relay, no remote server)
- Full shell fidelity — any tool on the operator's PATH, full interactive programs, readline, color, etc.
- Bounded: PTY_PORT (8766) binds to `127.0.0.1` only; no external exposure
---
## Transport Detail
```
Browser (xterm.js) ←→ ws://127.0.0.1:8766/pty ←→ server.py pty_handler ←→ OS PTY ($SHELL)
```
- Each WebSocket connection gets its own `pty.openpty()` pair and subprocess.
- Input from xterm.js `onData``_ptyWs.send(data)``os.write(master_fd, data)`
- Output from PTY → `os.read(master_fd)``websocket.send(text)``_term.write(text)`
- Resize messages (`{"type":"resize","cols":N,"rows":N}`) can be added later via `fcntl.ioctl(TIOCSWINSZ)`.
---
## Why not Option B (Remote SSH PTY)?
Remote SSH adds network hop complexity, credential management, and an SSH relay service. The Nexus does not currently have a remote operator use-case; Alexander operates locally. This decision can be revisited when fleet/remote-agent use-cases mature (see issues #672#675).
---
## Why not Option C (Browser pseudo-terminal)?
A JavaScript REPL cannot run arbitrary shell programs, manage processes, or provide the raw interactive shell experience that operators expect. It would be a toy, not a tool.
---
## Rejected Patterns
- **tmux over WebSocket** — adds server-side state complexity without enough benefit at this scale
- **ttyd** — external binary dependency; overkill for a local-first single-operator setup
- **xterm.js + websocketd** — external binary dependency; `server.py` already owns the WebSocket gateway
---
## Consequences
- `server.py` now starts two WebSocket servers: `PORT` (8765, main gateway) and `PTY_PORT` (8766, shell gateway)
- `PTY_PORT` always binds to `127.0.0.1` regardless of `NEXUS_WS_HOST`
- `cockpit-inspector.js` loads xterm.js from CDN (offline fallback: graceful degradation — the rail renders without the terminal pane)
- PTY sessions are ephemeral (no persistence across browser reload or server restart)
- Future: add TIOCSWINSZ resize support; add `/pty?shell=zsh` query param selection
---
## Related
- `cockpit-inspector.js` — implements the browser side (xterm.js + WebSocket)
- `server.py` — implements `pty_handler()` and `_get_git_status()`
- `docs/ATLAS-COCKPIT-PATTERNS.md` — documents source patterns adopted
- Issues: #1695, #686, #687

View File

@@ -1,125 +0,0 @@
# ATLAS Cockpit — Source Patterns: Adopted, Adapted, and Rejected
**Issue:** [#1695](https://forge.alexanderwhitestone.com/Timmy_Foundation/the-nexus/issues/1695)
**Date:** 2026-04-22
---
## Source Repos Audited
| Repo | Role in audit |
|---|---|
| `dodo-reach/hermes-desktop` | Primary pattern source for inspector/right rail layout |
| `outsourc-e/hermes-workspace` | Session taxonomy vocabulary (group/tag/pin/archive) |
| `nesquena/hermes-webui` | Session switcher UI patterns; status badge styling |
---
## Patterns Adopted
### 1. Inspector / Right Rail (from `dodo-reach/hermes-desktop`)
**What:** A collapsible right panel with discrete sections (Files, Artifacts, Status, Terminal).
Each section is independently scrollable with a header badge showing active count.
**How we adopted it:**
- `cockpit-inspector.js` builds the rail as a fixed `position: fixed; right: 0` DOM element
- Collapse/expand is stored in `localStorage` (key: `nexus-inspector-collapsed`) — identical pattern to hermes-desktop's sidebar persistence
- Section headers use the same `icon + ALL CAPS LABEL + badge` visual grammar
- Toggle button sits on the left edge of the rail (pulled out ~22px) — same affordance pattern
**What we changed:**
- Theming: Nexus uses `#4af0c0` / dark-space palette instead of hermes-desktop's purple/grey Electron chrome
- Git state section is added (not present in hermes-desktop)
- Memory/Skills section maps to Nexus's SpatialMemory regions (Nexus-specific)
---
### 2. Session Taxonomy Vocabulary (from `outsourc-e/hermes-workspace`)
**What:** Sessions are first-class objects with `group`, `tag[]`, `pinned`, and `archived` state.
Pinned sessions always sort first. Archived sessions are hidden from default lists.
**How we adopted it:**
- `session-manager.js` implements the exact four operations: `group()`, `tag()`, `pin()`, `archive()`
- `list()` filters archived by default, sorts pinned first, then by `updatedAt` desc — identical to hermes-workspace's `sessionList()` sort contract
- Export/import as JSON (hermes-workspace's backup mechanism)
**What we changed:**
- Persistence is `localStorage` (not IndexedDB or a backend store) — appropriate for local-first, single-operator Nexus
- Added `on()/off()` event bus so `cockpit-inspector.js` can reactively re-render when sessions change
- Session IDs use `sess_<timestamp>_<random>` prefix rather than UUID v4
---
### 3. Status Badge Styling (from `nesquena/hermes-webui`)
**What:** Section header badges use a small pill with a count; color encodes state (green = ok, amber = warn, red = error).
**How we adopted it:**
- `.ci-section-badge`, `.badge-warn`, `.badge-ok` classes in `style.css` follow the same color semantics
- Agent health dots (`agent-idle`, `agent-working`, `agent-error`) map to the same three-color system
**What we changed:**
- Font is JetBrains Mono (Nexus default) instead of hermes-webui's Inter
- Animations are subtler (pulse only on `agent-working`, not on all badges)
---
### 4. xterm.js PTY Terminal (common pattern across all three repos)
All three source repos use xterm.js as the browser terminal component. The transport varies:
- hermes-desktop: IPC bridge to a native Node.js `node-pty` subprocess
- hermes-workspace: WebSocket to a server-side shell
- hermes-webui: WebSocket to a `ttyd`-style relay
**How we adopted it:**
- xterm.js 5.3.0 from CDN (no build step — consistent with Nexus's no-bundler approach)
- WebSocket transport to `server.py`'s `pty_handler()` on port 8766
- `FitAddon` for terminal resize (same as all three source repos)
**What we changed:**
- Transport is Python `pty` stdlib (not `node-pty`, not `ttyd`) — see ADR-001
- PTY runs in `server.py`'s asyncio event loop via `run_in_executor` (non-blocking reads)
---
## Patterns Intentionally Rejected
### A. Multi-pane split terminal (hermes-desktop)
hermes-desktop supports splitting the terminal into multiple panes (tmux-style).
**Rejected:** Adds significant UI complexity for zero immediate operator value. The Nexus operator uses native terminal splits in their OS. One PTY pane is sufficient.
### B. Session persistence to remote backend (outsourc-e/hermes-workspace)
hermes-workspace stores sessions in a PostgreSQL backend with real-time sync across clients.
**Rejected:** The Nexus is local-first and single-operator. `localStorage` is sufficient and adds zero infrastructure.
### C. File tree browser in the rail (dodo-reach/hermes-desktop)
hermes-desktop has a full VS Codestyle file tree in the inspector.
**Rejected:** The Nexus 3D world is not a code editor. Artifacts are surfaced as a flat list of recently-touched files/outputs, not a tree. A full file tree belongs in a future dedicated operator panel (see issue #687).
### D. Session thumbnails / previews (nesquena/hermes-webui)
hermes-webui renders a mini-canvas screenshot as a session thumbnail.
**Rejected:** The Nexus Three.js canvas is expensive to snapshot. Thumbnails would require canvas-capture overhead. Deferred to a future issue.
### E. Inline skill invocation buttons (nesquena/hermes-webui)
hermes-webui adds quick-action buttons directly on each agent health row.
**Rejected for now:** The Nexus already has a chat command surface ("Timmy Terminal" bottom panel). Duplicating invocation paths would fragment the operator model. The inspector rail is read-focused; actions flow through chat.
---
## Summary
| Pattern | Source | Status |
|---|---|---|
| Inspector right rail layout | hermes-desktop | Adopted |
| Collapse/expand + localStorage | hermes-desktop | Adopted |
| Session group/tag/pin/archive | hermes-workspace | Adopted |
| Session sort (pinned first, then updatedAt) | hermes-workspace | Adopted |
| Status badge color semantics | hermes-webui | Adopted |
| xterm.js terminal | all three | Adopted (transport adapted) |
| Multi-pane terminal | hermes-desktop | Rejected |
| Remote session backend | hermes-workspace | Rejected |
| File tree browser | hermes-desktop | Rejected |
| Session thumbnails | hermes-webui | Rejected |
| Inline skill invocation | hermes-webui | Rejected |

View File

@@ -1,71 +0,0 @@
# GOFAI Symbolic Engine Debugger Overlay
Refs: the-nexus #871
## Overview
A specialized debug overlay that shows the internal state of the Symbolic Engine in real-time. Press **Ctrl+Shift+G** to toggle.
## Features
### 1. Active Symbols Panel
Displays all facts currently in the symbolic engine with their truth values:
- Green ● = true
- Red ○ = false
### 2. FSM States Panel
Shows the current state of all registered finite state machines:
- Agent ID on the left
- Current state on the right (highlighted)
### 3. Reasoning Paths Panel
Chronological log of all reasoning steps:
- Timestamp
- Rule that fired
- Outcome produced
### 4. Knowledge Graph Panel
- Node count and edge count
- Mini visualization of graph topology (up to 15 nodes)
- Color-coded by type (Agent, Location, etc.)
### 5. Performance Metrics
- Number of facts
- Number of rules
- JavaScript heap usage (if available)
## Usage
```javascript
import { SymbolicDebugger } from './nexus/components/symbolic-debugger.js';
import { SymbolicEngine } from './nexus/symbolic-engine.js';
// Create engine
const engine = new SymbolicEngine();
// Initialize debugger
SymbolicDebugger.init({
engine: engine,
fsmRegistry: new Map(), // optional
knowledgeGraph: null // optional
});
// Show the overlay
SymbolicDebugger.show();
// Or toggle with Ctrl+Shift+G
```
## Auto-refresh
The debugger updates automatically every second when visible. Manual refresh via the ↻ button.
## Dragging
The overlay can be dragged by its header to reposition on screen.
## Testing
```bash
node tests/test_symbolic_debugger.js
```

View File

@@ -394,16 +394,10 @@
<div id="memory-inspect-panel" class="memory-inspect-panel" style="display:none;" aria-label="Memory Inspect Panel"></div>
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<!-- xterm.js for operator PTY terminal (cockpit inspector rail) — issue #1695 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js" crossorigin="anonymous"></script>
<script src="./session-manager.js"></script>
<script src="./boot.js"></script>
<script src="./js/crisis-detector.js"></script>
<script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script>
<script src="./portal-hot-reload.js"></script>
<script src="./cockpit-inspector.js"></script>
<script>
function openMemoryFilter() { renderFilterList(); document.getElementById('memory-filter').style.display = 'flex'; }
function closeMemoryFilter() { document.getElementById('memory-filter').style.display = 'none'; }

351
js/crisis-detector.js Normal file
View File

@@ -0,0 +1,351 @@
/**
* Crisis Detection Module for The Nexus
* Issue #1543: feat: Nexus → the-door crisis bridge — detect distress in world chat
*
* Detects crisis signals in chat messages and shows 988 overlay.
* Based on crisis detection from multi_user_bridge.py
*/
class CrisisDetector {
constructor(options = {}) {
this.crisisKeywords = options.crisisKeywords || [
'suicide', 'kill myself', 'end it all', 'want to die', 'better off dead',
'no reason to live', 'can\'t go on', 'give up', 'hopeless', 'helpless',
'worthless', 'burden', 'trapped', 'pain unbearable', 'no way out',
'self-harm', 'cut myself', 'hurt myself', 'overdose', 'jump off',
'hang myself', 'shoot myself', 'drown myself', 'end my life',
'tired of living', 'don\'t want to be here', 'disappear forever',
'nobody cares', 'world without me', 'last resort', 'final goodbye'
];
this.crisisPatterns = options.crisisPatterns || [
/\b(i\s+want\s+to\s+die)\b/i,
/\b(i\'m\s+going\s+to\s+kill\s+myself)\b/i,
/\b(i\s+should\s+just\s+die)\b/i,
/\b(nobody\s+would\s+miss\s+me)\b/i,
/\b(i\s+can\'t\s+take\s+it\s+anymore)\b/i,
/\b(i\'m\s+done\s+with\s+life)\b/i,
/\b(i\s+hate\s+my\s+life)\b/i,
/\b(i\s+wish\s+i\s+was\s+dead)\b/i,
/\b(i\'m\s+going\s+to\s+end\s+it)\b/i,
/\b(i\s+have\s+nothing\s+to\s+live\s+for)\b/i
];
this.overlayVisible = false;
this.metrics = {
totalChecks: 0,
crisesDetected: 0,
lastDetection: null
};
this.onCrisisDetected = options.onCrisisDetected || this.defaultCrisisHandler.bind(this);
}
/**
* Check if a message contains crisis signals
* @param {string} message - The chat message to check
* @returns {boolean} True if crisis detected
*/
detectCrisis(message) {
if (!message || typeof message !== 'string') {
return false;
}
this.metrics.totalChecks++;
const lowerMessage = message.toLowerCase();
// Check for keyword matches
for (const keyword of this.crisisKeywords) {
if (lowerMessage.includes(keyword.toLowerCase())) {
this.logCrisisDetection(message, keyword);
this.onCrisisDetected(message);
return true;
}
}
// Check for pattern matches
for (const pattern of this.crisisPatterns) {
if (pattern.test(message)) {
this.logCrisisDetection(message, pattern.toString());
this.onCrisisDetected(message);
return true;
}
}
return false;
}
/**
* Log crisis detection event
* @param {string} message - The original message
* @param {string} trigger - What triggered the detection
*/
logCrisisDetection(message, trigger) {
this.metrics.crisesDetected++;
this.metrics.lastDetection = {
timestamp: Date.now(),
message: message,
trigger: trigger
};
console.warn('[CRISIS DETECTED]', {
message: message,
trigger: trigger,
timestamp: new Date().toISOString()
});
// Log to crisis metrics
this.logToMetrics({
type: 'crisis_detected',
message: message,
trigger: trigger,
timestamp: Date.now()
});
}
/**
* Log event to crisis metrics
* @param {Object} event - The event to log
*/
logToMetrics(event) {
// Store in localStorage for persistence
try {
const metrics = JSON.parse(localStorage.getItem('nexus-crisis-metrics') || '[]');
metrics.push(event);
// Keep only last 100 events
if (metrics.length > 100) {
metrics.splice(0, metrics.length - 100);
}
localStorage.setItem('nexus-crisis-metrics', JSON.stringify(metrics));
} catch (error) {
console.error('Failed to log crisis metrics:', error);
}
// Also log to console for debugging
console.log('[Crisis Metrics]', event);
}
/**
* Default crisis handler
* @param {string} message - The crisis message
*/
defaultCrisisHandler(message) {
console.warn('Crisis detected in message:', message);
this.show988Overlay();
}
/**
* Show the 988 crisis overlay
*/
show988Overlay() {
if (this.overlayVisible) {
return; // Already showing
}
this.overlayVisible = true;
// Create overlay element
const overlay = document.createElement('div');
overlay.id = 'crisis-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
text-align: center;
padding: 20px;
`;
overlay.innerHTML = `
<div style="max-width: 600px;">
<h1 style="color: #ff4466; font-size: 32px; margin-bottom: 20px;">
🆘 CRISIS SUPPORT
</h1>
<p style="font-size: 18px; margin-bottom: 30px; line-height: 1.6;">
We detected you might be in distress. You're not alone.
</p>
<div style="background: rgba(255, 68, 102, 0.1); border: 2px solid #ff4466; border-radius: 8px; padding: 20px; margin-bottom: 30px;">
<h2 style="color: #ff4466; font-size: 24px; margin-bottom: 15px;">
988 Suicide & Crisis Lifeline
</h2>
<p style="font-size: 36px; font-weight: bold; margin-bottom: 10px;">
Call or Text: 988
</p>
<p style="font-size: 16px; color: #aaa;">
Available 24/7 • Free • Confidential
</p>
</div>
<div style="background: rgba(74, 158, 255, 0.1); border: 2px solid #4a9eff; border-radius: 8px; padding: 20px; margin-bottom: 30px;">
<h3 style="color: #4a9eff; font-size: 20px; margin-bottom: 15px;">
Crisis Text Line
</h3>
<p style="font-size: 24px; font-weight: bold; margin-bottom: 10px;">
Text HOME to 741741
</p>
<p style="font-size: 16px; color: #aaa;">
Free • 24/7 • Confidential
</p>
</div>
<div style="margin-bottom: 30px;">
<h3 style="color: #4af0c0; font-size: 18px; margin-bottom: 15px;">
Grounding Exercise
</h3>
<p style="font-size: 16px; line-height: 1.6; text-align: left; max-width: 400px; margin: 0 auto;">
Name:<br>
• 5 things you can see<br>
• 4 things you can touch<br>
• 3 things you can hear<br>
• 2 things you can smell<br>
• 1 thing you can taste
</p>
</div>
<button id="close-crisis-overlay" style="
background: #4af0c0;
color: #080810;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
margin-right: 10px;
">
I'm Safe Now
</button>
<button id="call-988" style="
background: #ff4466;
color: white;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
">
Call 988 Now
</button>
<p style="font-size: 14px; color: #888; margin-top: 30px;">
If you're in immediate danger, call 911.
</p>
</div>
`;
document.body.appendChild(overlay);
// Add event listeners
const closeButton = document.getElementById('close-crisis-overlay');
if (closeButton) {
closeButton.addEventListener('click', () => {
this.hide988Overlay();
});
}
const callButton = document.getElementById('call-988');
if (callButton) {
callButton.addEventListener('click', () => {
window.location.href = 'tel:988';
});
}
// Log overlay shown
this.logToMetrics({
type: 'overlay_shown',
timestamp: Date.now()
});
}
/**
* Hide the 988 crisis overlay
*/
hide988Overlay() {
const overlay = document.getElementById('crisis-overlay');
if (overlay) {
overlay.remove();
}
this.overlayVisible = false;
// Log overlay hidden
this.logToMetrics({
type: 'overlay_hidden',
timestamp: Date.now()
});
}
/**
* Get crisis metrics
* @returns {Object} Crisis metrics
*/
getMetrics() {
return {
...this.metrics,
overlayVisible: this.overlayVisible
};
}
/**
* Reset crisis metrics
*/
resetMetrics() {
this.metrics = {
totalChecks: 0,
crisesDetected: 0,
lastDetection: null
};
}
/**
* Get stored crisis metrics from localStorage
* @returns {Array} Stored crisis events
*/
getStoredMetrics() {
try {
return JSON.parse(localStorage.getItem('nexus-crisis-metrics') || '[]');
} catch (error) {
console.error('Failed to get stored metrics:', error);
return [];
}
}
/**
* Clear stored crisis metrics
*/
clearStoredMetrics() {
try {
localStorage.removeItem('nexus-crisis-metrics');
} catch (error) {
console.error('Failed to clear stored metrics:', error);
}
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = CrisisDetector;
}
// Global instance for browser use
if (typeof window !== 'undefined') {
window.CrisisDetector = CrisisDetector;
}

View File

@@ -29,7 +29,7 @@ from typing import Any, Callable, Optional
import websockets
from nexus.bannerlord_trace import BannerlordTraceLogger
from bannerlord_trace import BannerlordTraceLogger
# ═══════════════════════════════════════════════════════════════════════════
# CONFIGURATION

View File

@@ -1,506 +0,0 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════
// GOFAI Symbolic Engine Debugger Overlay (issue #871)
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════
//
// Specialized debug overlay showing internal state of the Symbolic Engine:
// — Active symbols and their truth values
// — Reasoning paths with timestamps
// — FSM state visualizations
// — Knowledge graph topology
// — Performance metrics
//
// Usage:
// import { SymbolicDebugger } from './symbolic-debugger.js';
// SymbolicDebugger.init({ engine, blackboard, fsmRegistry });
// SymbolicDebugger.show();
// SymbolicDebugger.hide();
// SymbolicDebugger.update(); // refresh from current state
// ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
const SymbolicDebugger = (() => {
let _overlay = null;
let _engine = null;
let _blackboard = null;
let _fsmRegistry = null;
let _kg = null;
let _visible = false;
let _refreshInterval = null;
// ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function init(opts = {}) {
_engine = opts.engine || null;
_blackboard = opts.blackboard || null;
_fsmRegistry = opts.fsmRegistry || null;
_kg = opts.knowledgeGraph || null;
// Create overlay if not exists
if (!document.getElementById('symbolic-debugger-overlay')) {
_createOverlay();
}
_overlay = document.getElementById('symbolic-debugger-overlay');
// Keyboard shortcut: Ctrl+Shift+G to toggle
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'G') {
toggle();
}
});
console.log('[SymbolicDebugger] Initialized. Press Ctrl+Shift+G to toggle.');
}
// ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function _createOverlay() {
const div = document.createElement('div');
div.id = 'symbolic-debugger-overlay';
div.className = 'sym-debug-overlay';
div.style.cssText = `
position: fixed;
top: 60px;
right: 20px;
width: 480px;
max-height: calc(100vh - 80px);
background: rgba(10, 15, 30, 0.95);
border: 1px solid #4af0c0;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #e0f0ff;
overflow-y: auto;
z-index: 9999;
display: none;
box-shadow: 0 0 20px rgba(74, 240, 192, 0.3);
`;
div.innerHTML = `
<div class="sym-debug-header" style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: rgba(74, 240, 192, 0.1);
border-bottom: 1px solid #4af0c0;
cursor: move;
">
<span style="color: #4af0c0; font-weight: bold;">◈ GOFAI SYMBOLIC DEBUGGER</span>
<div style="display: flex; gap: 8px;">
<button id="sym-debug-refresh" style="
background: transparent;
border: 1px solid #4af0c0;
color: #4af0c0;
padding: 2px 8px;
cursor: pointer;
font-size: 11px;
">↻ Refresh</button>
<button id="sym-debug-close" style="
background: transparent;
border: none;
color: #4af0c0;
cursor: pointer;
font-size: 14px;
">✕</button>
</div>
</div>
<div class="sym-debug-content" style="padding: 12px;">
<!-- Active Symbols Section -->
<div class="sym-section" style="margin-bottom: 16px;">
<div class="sym-section-title" style="
color: #4af0c0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.3);
padding-bottom: 4px;
">Active Symbols</div>
<div id="sym-debug-symbols" style="
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 12px;
"></div>
</div>
<!-- FSM States Section -->
<div class="sym-section" style="margin-bottom: 16px;">
<div class="sym-section-title" style="
color: #4af0c0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.3);
padding-bottom: 4px;
">FSM States</div>
<div id="sym-debug-fsm" style="
display: flex;
flex-direction: column;
gap: 6px;
"></div>
</div>
<!-- Reasoning Log Section -->
<div class="sym-section" style="margin-bottom: 16px;">
<div class="sym-section-title" style="
color: #4af0c0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.3);
padding-bottom: 4px;
">Reasoning Paths</div>
<div id="sym-debug-reasoning" style="
max-height: 150px;
overflow-y: auto;
font-size: 11px;
"></div>
</div>
<!-- Knowledge Graph Section -->
<div class="sym-section" style="margin-bottom: 16px;">
<div class="sym-section-title" style="
color: #4af0c0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.3);
padding-bottom: 4px;
">Knowledge Graph</div>
<div id="sym-debug-kg-stats" style="margin-bottom: 8px; font-size: 11px;"></div>
<div id="sym-debug-kg-viz" style="
height: 100px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 240, 192, 0.2);
position: relative;
overflow: hidden;
"></div>
</div>
<!-- Performance Metrics Section -->
<div class="sym-section">
<div class="sym-section-title" style="
color: #4af0c0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.3);
padding-bottom: 4px;
">Performance</div>
<div id="sym-debug-metrics" style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
font-size: 11px;
"></div>
</div>
</div>
`;
document.body.appendChild(div);
// Event listeners
document.getElementById('sym-debug-close').onclick = hide;
document.getElementById('sym-debug-refresh').onclick = update;
// Make draggable
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
const header = div.querySelector('.sym-debug-header');
header.addEventListener('mousedown', (e) => {
isDragging = true;
dragOffsetX = e.clientX - div.offsetLeft;
dragOffsetY = e.clientY - div.offsetTop;
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
div.style.left = (e.clientX - dragOffsetX) + 'px';
div.style.top = (e.clientY - dragOffsetY) + 'px';
div.style.right = 'auto';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
// ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function update() {
if (!_visible || !_overlay) return;
// Update symbols
_updateSymbols();
// Update FSM states
_updateFSM();
// Update reasoning log
_updateReasoning();
// Update knowledge graph
_updateKnowledgeGraph();
// Update metrics
_updateMetrics();
}
function _updateSymbols() {
const container = document.getElementById('sym-debug-symbols');
if (!container || !_engine) {
if (container) container.innerHTML = '<span style="color: #888;">No engine connected</span>';
return;
}
let html = '';
if (_engine.facts && _engine.facts.size > 0) {
for (const [key, value] of _engine.facts) {
const truthColor = value ? '#4af0c0' : '#ff4466';
const truthIcon = value ? '●' : '○';
html += `
<span style="color: #e0f0ff;">${_esc(key)}</span>
<span style="color: ${truthColor};">${truthIcon} ${_esc(String(value))}</span>
`;
}
} else {
html = '<span style="color: #888; grid-column: 1 / -1;">No active symbols</span>';
}
container.innerHTML = html;
}
function _updateFSM() {
const container = document.getElementById('sym-debug-fsm');
if (!container) return;
let html = '';
if (_fsmRegistry && _fsmRegistry.size > 0) {
for (const [agentId, fsm] of _fsmRegistry) {
const transitions = fsm.transitions ? Object.keys(fsm.transitions).length : 0;
html += `
<div style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background: rgba(74, 240, 192, 0.05);
border-left: 2px solid #4af0c0;
">
<span style="color: #e0f0ff;">${_esc(agentId)}</span>
<span style="
color: #4af0c0;
font-size: 10px;
background: rgba(74, 240, 192, 0.1);
padding: 2px 6px;
border-radius: 2px;
">${_esc(fsm.state)}</span>
</div>
`;
}
} else if (_engine && _engine.fsm) {
// Single FSM mode
html += `
<div style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background: rgba(74, 240, 192, 0.05);
border-left: 2px solid #4af0c0;
">
<span style="color: #e0f0ff;">Primary FSM</span>
<span style="
color: #4af0c0;
font-size: 10px;
background: rgba(74, 240, 192, 0.1);
padding: 2px 6px;
border-radius: 2px;
">${_esc(_engine.fsm.state)}</span>
</div>
`;
} else {
html = '<span style="color: #888;">No FSM registered</span>';
}
container.innerHTML = html;
}
function _updateReasoning() {
const container = document.getElementById('sym-debug-reasoning');
if (!container || !_engine) {
if (container) container.innerHTML = '<span style="color: #888;">No reasoning log</span>';
return;
}
let html = '';
if (_engine.reasoningLog && _engine.reasoningLog.length > 0) {
for (const entry of _engine.reasoningLog) {
const time = entry.timestamp
? new Date(entry.timestamp).toLocaleTimeString()
: '--:--:--';
html += `
<div style="
margin-bottom: 6px;
padding: 4px 6px;
background: rgba(74, 240, 192, 0.03);
border-left: 2px solid rgba(74, 240, 192, 0.3);
">
<div style="color: #888; font-size: 10px;">${time}</div>
<div style="color: #4af0c0;">${_esc(entry.rule || 'Unknown rule')}</div>
<div style="color: #e0f0ff;">→ ${_esc(entry.outcome || 'No outcome')}</div>
</div>
`;
}
} else {
html = '<span style="color: #888;">No reasoning paths recorded</span>';
}
container.innerHTML = html;
}
function _updateKnowledgeGraph() {
const statsContainer = document.getElementById('sym-debug-kg-stats');
const vizContainer = document.getElementById('sym-debug-kg-viz');
if (!statsContainer || !vizContainer) return;
if (!_kg) {
statsContainer.innerHTML = '<span style="color: #888;">No knowledge graph connected</span>';
vizContainer.innerHTML = '';
return;
}
const nodeCount = _kg.nodes ? _kg.nodes.size : 0;
const edgeCount = _kg.edges ? _kg.edges.length : 0;
statsContainer.innerHTML = `
<span style="color: #4af0c0;">${nodeCount}</span> nodes
<span style="color: #888;">|</span>
<span style="color: #4af0c0;">${edgeCount}</span> edges
`;
// Simple visualization
if (nodeCount > 0 && vizContainer) {
let vizHtml = '';
let idx = 0;
for (const [nodeId, node] of _kg.nodes) {
const x = 20 + (idx % 5) * 80;
const y = 20 + Math.floor(idx / 5) * 30;
const color = node.type === 'Agent' ? '#4af0c0' :
node.type === 'Location' ? '#ffaa22' : '#4488ff';
vizHtml += `
<div style="
position: absolute;
left: ${x}px;
top: ${y}px;
width: 8px;
height: 8px;
background: ${color};
border-radius: 50%;
box-shadow: 0 0 4px ${color};
" title="${_esc(nodeId)}"></div>
`;
idx++;
if (idx >= 15) break; // Limit visualization
}
vizContainer.innerHTML = vizHtml;
} else {
vizContainer.innerHTML = '';
}
}
function _updateMetrics() {
const container = document.getElementById('sym-debug-metrics');
if (!container) return;
let html = '';
// Engine metrics
if (_engine) {
const factCount = _engine.facts ? _engine.facts.size : 0;
const ruleCount = _engine.rules ? _engine.rules.length : 0;
html += `
<span style="color: #888;">Facts:</span> <span style="color: #4af0c0;">${factCount}</span>
<span style="color: #888;">Rules:</span> <span style="color: #4af0c0;">${ruleCount}</span>
`;
}
// Memory usage (rough estimate)
if (performance && performance.memory) {
const usedMB = Math.round(performance.memory.usedJSHeapSize / 1048576);
html += `
<span style="color: #888;">Heap:</span> <span style="color: #4af0c0;">${usedMB} MB</span>
`;
}
container.innerHTML = html || '<span style="color: #888;">No metrics available</span>';
}
// ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
function show() {
if (!_overlay) return;
_overlay.style.display = 'block';
_visible = true;
update();
_startAutoRefresh();
}
function hide() {
if (!_overlay) return;
_overlay.style.display = 'none';
_visible = false;
_stopAutoRefresh();
}
function toggle() {
if (_visible) hide();
else show();
}
function isVisible() {
return _visible;
}
function _startAutoRefresh() {
if (_refreshInterval) return;
_refreshInterval = setInterval(update, 1000); // Update every second
}
function _stopAutoRefresh() {
if (_refreshInterval) {
clearInterval(_refreshInterval);
_refreshInterval = null;
}
}
function _esc(str) {
if (typeof str !== 'string') return String(str);
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
return {
init,
show,
hide,
toggle,
isVisible,
update,
};
})();
// Auto-export for module or global usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { SymbolicDebugger };
} else if (typeof window !== 'undefined') {
window.SymbolicDebugger = SymbolicDebugger;
}

View File

@@ -304,43 +304,6 @@ async def inject_event(event_type: str, ws_url: str, **kwargs):
sys.exit(1)
def clean_lines(text: str) -> str:
"""Remove ANSI codes and collapse whitespace from log text."""
import re
text = strip_ansi(text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def normalize_event(event: dict) -> dict:
"""Normalize an Evennia event dict to standard format."""
return {
"type": event.get("type", "unknown"),
"actor": event.get("actor", event.get("name", "")),
"room": event.get("room", event.get("location", "")),
"message": event.get("message", event.get("text", "")),
"timestamp": event.get("timestamp", ""),
}
def parse_room_output(text: str) -> dict:
"""Parse Evennia room output into structured data."""
import re
lines = text.strip().split("\n")
result = {"name": "", "description": "", "exits": [], "objects": []}
if lines:
result["name"] = strip_ansi(lines[0]).strip()
if len(lines) > 1:
result["description"] = strip_ansi(lines[1]).strip()
for line in lines[2:]:
line = strip_ansi(line).strip()
if line.startswith("Exits:"):
result["exits"] = [e.strip() for e in line[6:].split(",") if e.strip()]
elif line.startswith("You see:"):
result["objects"] = [o.strip() for o in line[8:].split(",") if o.strip()]
return result
def main():
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
sub = parser.add_subparsers(dest="mode")

View File

@@ -1,105 +0,0 @@
/**
* Portal Hot-Reload for The Nexus
*
* Watches portals.json for changes and hot-reloads portal list
* without server restart. Existing connections unaffected.
*
* Usage:
* PortalHotReload.start(intervalMs);
* PortalHotReload.stop();
* PortalHotReload.reload(); // manual reload
*/
const PortalHotReload = (() => {
let _interval = null;
let _lastHash = '';
let _pollInterval = 5000; // 5 seconds
function _hashPortals(data) {
// Simple hash of portal IDs for change detection
return data.map(p => p.id || p.name).sort().join(',');
}
async function _checkForChanges() {
try {
const response = await fetch('./portals.json?t=' + Date.now());
if (!response.ok) return;
const data = await response.json();
const hash = _hashPortals(data);
if (hash !== _lastHash) {
console.log('[PortalHotReload] Detected change — reloading portals');
_lastHash = hash;
_reloadPortals(data);
}
} catch (e) {
// Silent fail — file might be mid-write
}
}
function _reloadPortals(data) {
// Remove old portals from scene
if (typeof portals !== 'undefined' && Array.isArray(portals)) {
portals.forEach(p => {
if (p.group && typeof scene !== 'undefined' && scene) {
scene.remove(p.group);
}
});
portals.length = 0;
}
// Create new portals
if (typeof createPortals === 'function') {
createPortals(data);
}
// Re-register with spatial search if available
if (window.SpatialSearch && typeof portals !== 'undefined') {
portals.forEach(p => {
if (p.config && p.config.name && p.group) {
SpatialSearch.register('portal', p, p.config.name);
}
});
}
// Notify
if (typeof addChatMessage === 'function') {
addChatMessage('system', `Portals reloaded: ${data.length} portals active`);
}
console.log(`[PortalHotReload] Reloaded ${data.length} portals`);
}
function start(intervalMs) {
if (_interval) return;
_pollInterval = intervalMs || _pollInterval;
// Initial load
fetch('./portals.json').then(r => r.json()).then(data => {
_lastHash = _hashPortals(data);
}).catch(() => {});
_interval = setInterval(_checkForChanges, _pollInterval);
console.log(`[PortalHotReload] Watching portals.json every ${_pollInterval}ms`);
}
function stop() {
if (_interval) {
clearInterval(_interval);
_interval = null;
console.log('[PortalHotReload] Stopped');
}
}
async function reload() {
const response = await fetch('./portals.json?t=' + Date.now());
const data = await response.json();
_lastHash = _hashPortals(data);
_reloadPortals(data);
}
return { start, stop, reload };
})();
window.PortalHotReload = PortalHotReload;

129
server.py
View File

@@ -15,9 +15,7 @@ import asyncio
import json
import logging
import os
import pty
import signal
import subprocess
import sys
import time
from typing import Set, Dict, Optional
@@ -27,10 +25,9 @@ from collections import defaultdict
import websockets
# Configuration
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
PTY_PORT = int(os.environ.get("NEXUS_PTY_PORT", "8766")) # operator shell PTY gateway
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
@@ -105,116 +102,6 @@ async def authenticate_connection(websocket: websockets.WebSocketServerProtocol)
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
return False
# ---------------------------------------------------------------------------
# Git status helper (issue #1695)
# ---------------------------------------------------------------------------
def _get_git_status() -> dict:
"""Return a dict describing the current repo git state."""
repo_root = os.path.dirname(os.path.abspath(__file__))
try:
branch_out = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_root, stderr=subprocess.DEVNULL, text=True
).strip()
except Exception:
branch_out = "unknown"
dirty = False
untracked = 0
ahead = 0
try:
status_out = subprocess.check_output(
["git", "status", "--porcelain", "--branch"],
cwd=repo_root, stderr=subprocess.DEVNULL, text=True
)
for line in status_out.splitlines():
if line.startswith("##") and "ahead" in line:
import re
m = re.search(r"ahead (\d+)", line)
if m:
ahead = int(m.group(1))
elif line.startswith("??"):
untracked += 1
elif line and not line.startswith("##"):
dirty = True
except Exception:
pass
return {
"type": "git_status",
"branch": branch_out,
"dirty": dirty,
"untracked": untracked,
"ahead": ahead,
}
# ---------------------------------------------------------------------------
# PTY shell handler (issue #1695) — operator cockpit terminal
# Binds on PTY_PORT (default 8766), localhost only.
# Each WebSocket connection gets its own PTY subprocess.
# ---------------------------------------------------------------------------
async def pty_handler(websocket: websockets.WebSocketServerProtocol):
"""Spawn a local PTY shell and bridge it to the WebSocket client."""
addr = websocket.remote_address
logger.info(f"[PTY] Operator shell connection from {addr}")
shell = os.environ.get("SHELL", "/bin/bash")
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_exec(
shell,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
preexec_fn=os.setsid,
close_fds=True,
)
os.close(slave_fd)
loop = asyncio.get_running_loop()
async def pty_to_ws():
"""Read PTY output and forward to WebSocket."""
try:
while True:
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
if not data:
break
await websocket.send(data.decode("utf-8", errors="replace"))
except (OSError, websockets.exceptions.ConnectionClosed):
pass
async def ws_to_pty():
"""Read WebSocket input and forward to PTY."""
try:
async for message in websocket:
if isinstance(message, str):
os.write(master_fd, message.encode("utf-8"))
else:
os.write(master_fd, message)
except (OSError, websockets.exceptions.ConnectionClosed):
pass
reader = asyncio.ensure_future(pty_to_ws())
writer = asyncio.ensure_future(ws_to_pty())
try:
await asyncio.gather(reader, writer)
finally:
reader.cancel()
writer.cancel()
try:
os.close(master_fd)
except OSError:
pass
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
logger.info(f"[PTY] Shell session ended for {addr}")
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"""Handles individual client connections and message broadcasting."""
addr = websocket.remote_address
@@ -253,11 +140,6 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
# Optional: log specific important message types
if msg_type in ["agent_register", "thought", "action"]:
logger.debug(f"Received {msg_type} from {addr}")
# Handle git status requests from the operator cockpit (issue #1695)
if msg_type == "git_status_request":
git_info = _get_git_status()
await websocket.send(json.dumps(git_info))
continue
except (json.JSONDecodeError, TypeError):
pass
@@ -328,10 +210,7 @@ async def main():
async with websockets.serve(broadcast_handler, HOST, PORT):
logger.info("Gateway is ready and listening.")
# Also start the PTY gateway on PTY_PORT (operator cockpit shell, issue #1695)
async with websockets.serve(pty_handler, "127.0.0.1", PTY_PORT):
logger.info(f"PTY shell gateway listening on ws://127.0.0.1:{PTY_PORT}/pty")
await stop
await stop
logger.info("Shutting down Nexus WS gateway...")
# Close any remaining client connections (handlers may have already cleaned up)

View File

@@ -1,294 +0,0 @@
/**
* session-manager.js — Session taxonomy primitives for the Nexus operator cockpit
*
* Operations: create, list, setActive, group, tag, pin, archive, restore, delete
* Persistence: localStorage under key `nexus-sessions`
*
* Refs: issue #1695 — ATLAS cockpit operator patterns
* Pattern sources: dodo-reach/hermes-desktop session sidebar, nesquena/hermes-webui session groups
*/
(function () {
'use strict';
const STORAGE_KEY = 'nexus-sessions';
const META_KEY = 'nexus-sessions-meta';
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
// Session:
// id: string (uuid-ish)
// name: string
// group: string | null
// tags: string[]
// pinned: boolean
// archived: boolean
// createdAt: number (epoch ms)
// updatedAt: number
// meta: {} (arbitrary caller-supplied data)
//
// Meta:
// activeId: string | null
let _sessions = [];
let _activeId = null;
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
_sessions = raw ? JSON.parse(raw) : [];
} catch {
_sessions = [];
}
try {
const rawMeta = localStorage.getItem(META_KEY);
const meta = rawMeta ? JSON.parse(rawMeta) : {};
_activeId = meta.activeId || null;
} catch {
_activeId = null;
}
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_sessions));
localStorage.setItem(META_KEY, JSON.stringify({ activeId: _activeId }));
} catch (e) {
console.warn('[SessionManager] Could not persist sessions:', e);
}
emit('change', { sessions: _sessions, activeId: _activeId });
}
// ---------------------------------------------------------------------------
// ID generation
// ---------------------------------------------------------------------------
function genId() {
return 'sess_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7);
}
// ---------------------------------------------------------------------------
// Event bus (lightweight)
// ---------------------------------------------------------------------------
const _listeners = {};
function on(event, fn) {
if (!_listeners[event]) _listeners[event] = [];
_listeners[event].push(fn);
}
function off(event, fn) {
if (!_listeners[event]) return;
_listeners[event] = _listeners[event].filter(f => f !== fn);
}
function emit(event, data) {
(_listeners[event] || []).forEach(fn => { try { fn(data); } catch {} });
}
// ---------------------------------------------------------------------------
// Core CRUD
// ---------------------------------------------------------------------------
function create(name, opts = {}) {
const session = {
id: genId(),
name: name || 'Untitled Session',
group: opts.group || null,
tags: opts.tags || [],
pinned: opts.pinned || false,
archived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
meta: opts.meta || {},
};
_sessions.push(session);
if (!_activeId) _activeId = session.id;
save();
return session;
}
function get(id) {
return _sessions.find(s => s.id === id) || null;
}
function list(opts = {}) {
let result = [..._sessions];
if (opts.group) result = result.filter(s => s.group === opts.group);
if (opts.tag) result = result.filter(s => s.tags.includes(opts.tag));
if (opts.pinned !== undefined) result = result.filter(s => s.pinned === opts.pinned);
if (opts.archived !== undefined) result = result.filter(s => s.archived === opts.archived);
// Default: hide archived unless explicitly requested
if (opts.archived === undefined) result = result.filter(s => !s.archived);
// Pinned items first, then by updatedAt desc
result.sort((a, b) => {
if (a.pinned !== b.pinned) return b.pinned ? 1 : -1;
return b.updatedAt - a.updatedAt;
});
return result;
}
function update(id, patch) {
const idx = _sessions.findIndex(s => s.id === id);
if (idx < 0) return null;
_sessions[idx] = { ..._sessions[idx], ...patch, updatedAt: Date.now() };
save();
return _sessions[idx];
}
function remove(id) {
const before = _sessions.length;
_sessions = _sessions.filter(s => s.id !== id);
if (_activeId === id) _activeId = _sessions.find(s => !s.archived)?.id || null;
if (_sessions.length !== before) save();
}
// ---------------------------------------------------------------------------
// Taxonomy operations
// ---------------------------------------------------------------------------
/** Set or clear the group for a session. */
function group(id, groupName) {
return update(id, { group: groupName || null });
}
/** Add one or more tags to a session. */
function tag(id, ...tags) {
const s = get(id);
if (!s) return null;
const merged = Array.from(new Set([...s.tags, ...tags.filter(Boolean)]));
return update(id, { tags: merged });
}
/** Remove a tag from a session. */
function untag(id, tagName) {
const s = get(id);
if (!s) return null;
return update(id, { tags: s.tags.filter(t => t !== tagName) });
}
/** Toggle pin state. */
function pin(id) {
const s = get(id);
if (!s) return null;
return update(id, { pinned: !s.pinned });
}
/** Archive a session (hides from default list). */
function archive(id) {
return update(id, { archived: true, pinned: false });
}
/** Restore an archived session. */
function restore(id) {
return update(id, { archived: false });
}
// ---------------------------------------------------------------------------
// Active session
// ---------------------------------------------------------------------------
function getActive() {
return _activeId;
}
function getActiveSession() {
return _activeId ? get(_activeId) : null;
}
function setActive(id) {
if (!get(id)) return false;
_activeId = id;
save();
return true;
}
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
function listGroups() {
const seen = new Set();
_sessions.forEach(s => { if (s.group) seen.add(s.group); });
return Array.from(seen).sort();
}
function listTags() {
const seen = new Set();
_sessions.forEach(s => s.tags.forEach(t => seen.add(t)));
return Array.from(seen).sort();
}
// ---------------------------------------------------------------------------
// Import / export (for backup / hand-off)
// ---------------------------------------------------------------------------
function exportAll() {
return JSON.stringify({ sessions: _sessions, activeId: _activeId }, null, 2);
}
function importAll(json) {
try {
const data = JSON.parse(json);
if (!Array.isArray(data.sessions)) throw new Error('Invalid format');
_sessions = data.sessions;
_activeId = data.activeId || null;
save();
return true;
} catch (e) {
console.error('[SessionManager] Import failed:', e);
return false;
}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
load();
// Create a default session if store is empty
if (_sessions.length === 0) {
create('Default', { tags: ['auto'], meta: { auto: true } });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
window.SessionManager = {
create,
get,
list,
update,
remove,
// Taxonomy
group,
tag,
untag,
pin,
archive,
restore,
// Active session
getActive,
getActiveSession,
setActive,
// Helpers
listGroups,
listTags,
// Import/export
exportAll,
importAll,
// Events
on,
off,
};
console.info('[SessionManager] Loaded. Sessions:', _sessions.length, '| Active:', _activeId);
})();

302
style.css
View File

@@ -2928,309 +2928,9 @@ body.operator-mode #mode-label {
.reasoning-trace {
width: 280px;
}
.trace-content {
max-height: 200px;
}
}
/* ==========================================================================
Operator Inspector Rail — issue #1695
========================================================================== */
.cockpit-inspector {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 280px;
background: rgba(5, 8, 20, 0.94);
border-left: 1px solid rgba(74, 240, 192, 0.18);
display: flex;
flex-direction: column;
z-index: 1200;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 11px;
color: #c8e8ff;
backdrop-filter: blur(8px);
transition: transform 0.25s ease, width 0.25s ease;
overflow: hidden;
}
.cockpit-inspector.collapsed {
width: 32px;
}
.cockpit-inspector.collapsed .ci-header,
.cockpit-inspector.collapsed .ci-body {
display: none;
}
/* Toggle button */
.ci-toggle-btn {
position: absolute;
left: -22px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 44px;
background: rgba(5, 8, 20, 0.9);
border: 1px solid rgba(74, 240, 192, 0.25);
border-right: none;
color: #4af0c0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
border-radius: 4px 0 0 4px;
z-index: 1;
}
.ci-toggle-btn:hover { background: rgba(74, 240, 192, 0.1); }
.ci-toggle-icon { line-height: 1; }
/* Header */
.ci-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 10px 8px;
border-bottom: 1px solid rgba(74, 240, 192, 0.12);
background: rgba(74, 240, 192, 0.04);
flex-shrink: 0;
}
.ci-header-icon { color: #4af0c0; font-size: 13px; }
.ci-header-title {
flex: 1;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
color: #4af0c0;
text-transform: uppercase;
}
.ci-header-actions { display: flex; gap: 4px; }
/* Generic icon button */
.ci-icon-btn {
background: none;
border: 1px solid rgba(74, 240, 192, 0.2);
color: #7ab8d8;
font-size: 11px;
padding: 2px 5px;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
line-height: 1.4;
}
.ci-icon-btn:hover { border-color: #4af0c0; color: #4af0c0; }
/* Body scroll area */
.ci-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(74,240,192,0.2) transparent;
}
/* Section */
.ci-section {
border-bottom: 1px solid rgba(74, 240, 192, 0.08);
}
.ci-section-header {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 10px 6px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.14em;
color: #7ab8d8;
text-transform: uppercase;
cursor: pointer;
user-select: none;
background: rgba(255,255,255,0.02);
}
.ci-section-header:hover { color: #4af0c0; }
.ci-section-icon { color: #4af0c0; font-size: 11px; width: 14px; text-align: center; }
.ci-section-actions { margin-left: auto; display: flex; gap: 4px; }
.ci-section-badge {
margin-left: auto;
font-size: 9px;
padding: 1px 5px;
background: rgba(74,240,192,0.08);
border-radius: 8px;
color: #4af0c0;
min-width: 18px;
text-align: center;
}
.ci-section-badge.badge-warn { background: rgba(255,160,40,0.15); color: #ffa028; }
.ci-section-badge.badge-ok { background: rgba(74,240,192,0.12); color: #4af0c0; }
.ci-section-body { padding: 6px 10px 8px; }
.ci-empty-hint {
color: rgba(200,232,255,0.35);
font-size: 10px;
font-style: italic;
padding: 2px 0;
}
/* Tag chip */
.ci-tag {
display: inline-block;
padding: 1px 5px;
background: rgba(74,240,192,0.08);
border: 1px solid rgba(74,240,192,0.18);
border-radius: 3px;
font-size: 9px;
color: #7ab8d8;
margin-right: 2px;
}
.tag-pin { border-color: rgba(255,180,40,0.4); background: rgba(255,180,40,0.08); }
.tag-archive { border-color: rgba(150,150,200,0.3); background: rgba(150,150,200,0.06); }
/* Git section rows */
.ci-git-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
font-size: 10px;
}
.ci-git-label { color: rgba(200,232,255,0.5); }
.ci-git-value { color: #c8e8ff; font-weight: 500; }
.ci-dirty { color: #ffa028; }
.ci-clean { color: #4af0c0; }
/* Agent health rows */
.ci-agent-row {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.ci-agent-row:last-child { border-bottom: none; }
.ci-agent-dot {
width: 7px; height: 7px;
border-radius: 50%;
margin-top: 4px;
flex-shrink: 0;
}
.agent-idle { background: #4af0c0; }
.agent-working { background: #ffa028; box-shadow: 0 0 4px #ffa028; animation: agent-pulse 1s infinite; }
.agent-error { background: #ff4060; }
.agent-unknown { background: rgba(200,232,255,0.3); }
@keyframes agent-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.ci-agent-info { flex: 1; min-width: 0; }
.ci-agent-id { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-agent-meta { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
.ci-agent-task { font-size: 9px; color: rgba(200,232,255,0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.ci-agent-status-label {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.ci-agent-status-label.agent-idle { border-color: rgba(74,240,192,0.3); color: #4af0c0; }
.ci-agent-status-label.agent-working { border-color: rgba(255,160,40,0.3); color: #ffa028; }
.ci-agent-status-label.agent-error { border-color: rgba(255,64,96,0.3); color: #ff4060; }
.ci-agent-status-label.agent-unknown { border-color: rgba(200,232,255,0.2); color: rgba(200,232,255,0.5); }
/* Session rows */
.ci-session-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 2px;
}
.ci-session-row:hover { background: rgba(74,240,192,0.06); }
.ci-session-row.active { background: rgba(74,240,192,0.1); border-left: 2px solid #4af0c0; }
.ci-session-info { flex: 1; min-width: 0; }
.ci-session-name { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-session-meta { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
.ci-session-actions { display: flex; gap: 2px; }
/* Artifact rows */
.ci-artifact-row {
display: flex;
align-items: center;
gap: 7px;
padding: 4px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.ci-artifact-row:last-child { border-bottom: none; }
.ci-artifact-icon { font-size: 13px; flex-shrink: 0; }
.ci-artifact-info { flex: 1; min-width: 0; }
.ci-artifact-name { font-size: 10px; color: #c8e8ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-artifact-path { font-size: 9px; color: rgba(200,232,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ci-artifact-type { flex-shrink: 0; }
/* Memory / skills rows */
.ci-mem-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
}
.ci-mem-region { font-size: 10px; color: #c8e8ff; }
/* Terminal section */
.ci-terminal-section { flex: 0 0 auto; }
.ci-terminal-status {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 10px;
color: rgba(200,232,255,0.5);
}
.ci-term-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ci-term-dot.disconnected { background: rgba(200,232,255,0.25); }
.ci-term-dot.connecting { background: #ffa028; animation: agent-pulse 0.8s infinite; }
.ci-term-dot.connected { background: #4af0c0; }
.ci-terminal-mount {
margin: 0 10px 8px;
border: 1px solid rgba(74,240,192,0.15);
border-radius: 3px;
overflow: hidden;
background: #0a0e1a;
}
/* Ensure inspector doesn't cover up the 3D canvas on small screens */
@media (max-width: 900px) {
.cockpit-inspector { width: 220px; }
}
@media (max-width: 600px) {
.cockpit-inspector { display: none; }
}

View File

@@ -0,0 +1,208 @@
/**
* Tests for Crisis Detection Module
* Issue #1543: feat: Nexus → the-door crisis bridge — detect distress in world chat
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
// Mock document
const mockDocument = {
createElement: (tag) => {
const element = {
style: {},
innerHTML: '',
appendChild: () => {},
remove: () => {},
addEventListener: () => {}
};
return element;
},
body: {
appendChild: () => {}
},
getElementById: () => null
};
// Mock localStorage
const mockLocalStorage = {
storage: {},
getItem: (key) => mockLocalStorage.storage[key] || null,
setItem: (key, value) => { mockLocalStorage.storage[key] = value; },
removeItem: (key) => { delete mockLocalStorage.storage[key]; }
};
// Load crisis-detector.js
const crisisDetectorPath = path.join(ROOT, 'js', 'crisis-detector.js');
const crisisDetectorCode = fs.readFileSync(crisisDetectorPath, 'utf8');
// Create VM context
const context = {
module: { exports: {} },
exports: {},
console,
document: mockDocument,
localStorage: mockLocalStorage,
window: { location: { href: '' } }
};
// Execute crisis-detector.js in context
const vm = require('node:vm');
vm.runInNewContext(crisisDetectorCode, context);
// Get CrisisDetector class
const CrisisDetector = context.window.CrisisDetector || context.CrisisDetector;
test('CrisisDetector class loads correctly', () => {
assert.ok(CrisisDetector, 'CrisisDetector should be defined');
assert.ok(typeof CrisisDetector === 'function', 'CrisisDetector should be a constructor');
});
test('CrisisDetector can be instantiated', () => {
const detector = new CrisisDetector();
assert.ok(detector, 'CrisisDetector instance should be created');
assert.ok(detector.crisisKeywords, 'Should have crisisKeywords');
assert.ok(detector.crisisPatterns, 'Should have crisisPatterns');
});
test('CrisisDetector detects crisis keywords', () => {
const detector = new CrisisDetector();
// Test various crisis messages
const crisisMessages = [
'I want to die',
'I\'m going to kill myself',
'I should just die',
'Nobody would miss me',
'I can\'t take it anymore',
'I\'m done with life',
'I hate my life',
'I wish I was dead',
'I\'m going to end it',
'I have nothing to live for'
];
for (const message of crisisMessages) {
const detected = detector.detectCrisis(message);
assert.ok(detected, `Should detect crisis in: "${message}"`);
}
});
test('CrisisDetector does not detect crisis in normal messages', () => {
const detector = new CrisisDetector();
const normalMessages = [
'Hello, how are you?',
'I\'m doing great today',
'Let\'s work on this project',
'The weather is nice',
'I love coding',
'This is a test message'
];
for (const message of normalMessages) {
const detected = detector.detectCrisis(message);
assert.ok(!detected, `Should NOT detect crisis in: "${message}"`);
}
});
test('CrisisDetector handles empty messages', () => {
const detector = new CrisisDetector();
assert.ok(!detector.detectCrisis(''), 'Should not detect crisis in empty string');
assert.ok(!detector.detectCrisis(null), 'Should not detect crisis in null');
assert.ok(!detector.detectCrisis(undefined), 'Should not detect crisis in undefined');
});
test('CrisisDetector tracks metrics', () => {
const detector = new CrisisDetector();
// Check initial metrics
const initialMetrics = detector.getMetrics();
assert.equal(initialMetrics.totalChecks, 0, 'Should start with 0 checks');
assert.equal(initialMetrics.crisesDetected, 0, 'Should start with 0 crises');
// Check some messages
detector.detectCrisis('Hello');
detector.detectCrisis('I want to die');
detector.detectCrisis('How are you?');
const metrics = detector.getMetrics();
assert.equal(metrics.totalChecks, 3, 'Should have 3 checks');
assert.equal(metrics.crisesDetected, 1, 'Should have 1 crisis detected');
assert.ok(metrics.lastDetection, 'Should have lastDetection');
assert.equal(metrics.lastDetection.message, 'I want to die', 'Should store crisis message');
});
test('CrisisDetector can reset metrics', () => {
const detector = new CrisisDetector();
// Check some messages
detector.detectCrisis('I want to die');
detector.detectCrisis('Hello');
// Reset metrics
detector.resetMetrics();
const metrics = detector.getMetrics();
assert.equal(metrics.totalChecks, 0, 'Should have 0 checks after reset');
assert.equal(metrics.crisesDetected, 0, 'Should have 0 crises after reset');
assert.equal(metrics.lastDetection, null, 'Should have no lastDetection after reset');
});
test('CrisisDetector logs to metrics', () => {
const detector = new CrisisDetector();
// Clear any existing metrics
detector.clearStoredMetrics();
// Detect crisis
detector.detectCrisis('I want to die');
// Check stored metrics
const storedMetrics = detector.getStoredMetrics();
assert.ok(storedMetrics.length > 0, 'Should have stored metrics');
// Find the crisis_detected event (not overlay_shown)
const crisisEvent = storedMetrics.find(event => event.type === 'crisis_detected');
assert.ok(crisisEvent, 'Should have crisis_detected event');
assert.equal(crisisEvent.message, 'I want to die', 'Should log message');
});
test('CrisisDetector has crisis handler', () => {
let handlerCalled = false;
let handlerMessage = null;
const detector = new CrisisDetector({
onCrisisDetected: (message) => {
handlerCalled = true;
handlerMessage = message;
}
});
detector.detectCrisis('I want to die');
assert.ok(handlerCalled, 'Crisis handler should be called');
assert.equal(handlerMessage, 'I want to die', 'Handler should receive message');
});
test('CrisisDetector overlay visibility', () => {
const detector = new CrisisDetector();
// Initially not visible
assert.ok(!detector.overlayVisible, 'Overlay should not be visible initially');
// Show overlay
detector.show988Overlay();
assert.ok(detector.overlayVisible, 'Overlay should be visible after showing');
// Hide overlay
detector.hide988Overlay();
assert.ok(!detector.overlayVisible, 'Overlay should not be visible after hiding');
});
console.log('All CrisisDetector tests passed!');

View File

@@ -1,95 +0,0 @@
/**
* Tests for SymbolicDebugger component (issue #871)
*/
import { SymbolicDebugger } from '../nexus/components/symbolic-debugger.js';
import { SymbolicEngine, AgentFSM, KnowledgeGraph } from '../nexus/symbolic-engine.js';
// Mock DOM for Node.js testing
if (typeof document === 'undefined') {
const mockElements = new Map();
global.document = {
createElement: (tag) => {
const el = {
tagName: tag,
style: {},
innerHTML: '',
children: [],
appendChild: function(child) { this.children.push(child); return child; },
prepend: function(child) { this.children.unshift(child); return child; },
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
setAttribute: () => {},
getAttribute: () => null,
};
return el;
},
body: {
appendChild: (el) => {
mockElements.set(el.id, el);
return el;
}
},
addEventListener: () => {},
getElementById: (id) => mockElements.get(id) || {
style: {},
innerHTML: '',
onclick: null,
addEventListener: () => {},
},
};
global.window = { SymbolicDebugger: null };
}
function assert(condition, message) {
if (!condition) {
console.error(`❌ FAILED: ${message}`);
process.exit(1);
}
console.log(`✔ PASSED: ${message}`);
}
console.log('--- Running Symbolic Debugger Tests ---');
// Test 1: Module exports
assert(typeof SymbolicDebugger === 'object', 'SymbolicDebugger exports an object');
assert(typeof SymbolicDebugger.init === 'function', 'SymbolicDebugger has init method');
assert(typeof SymbolicDebugger.show === 'function', 'SymbolicDebugger has show method');
assert(typeof SymbolicDebugger.hide === 'function', 'SymbolicDebugger has hide method');
assert(typeof SymbolicDebugger.toggle === 'function', 'SymbolicDebugger has toggle method');
assert(typeof SymbolicDebugger.update === 'function', 'SymbolicDebugger has update method');
// Test 2: Initial state
assert(SymbolicDebugger.isVisible() === false, 'Debugger starts hidden');
// Test 3: Engine integration (mock)
const mockEngine = {
facts: new Map([['energy', 75], ['stable', true]]),
rules: [{ condition: () => true, action: () => 'test' }],
reasoningLog: [
{ timestamp: Date.now(), rule: 'TestRule', outcome: 'TestOutcome' }
]
};
SymbolicDebugger.init({ engine: mockEngine });
assert(true, 'Debugger initializes with engine');
// Test 4: FSM integration
const mockFSM = { state: 'IDLE', transitions: { IDLE: [] } };
SymbolicDebugger.init({ engine: mockEngine, fsmRegistry: new Map([['Agent1', mockFSM]]) });
assert(true, 'Debugger initializes with FSM registry');
// Test 5: Knowledge Graph integration
const mockKG = {
nodes: new Map([
['A', { id: 'A', type: 'Agent' }],
['B', { id: 'B', type: 'Location' }]
]),
edges: [{ from: 'A', to: 'B', relation: 'AT' }]
};
SymbolicDebugger.init({ engine: mockEngine, knowledgeGraph: mockKG });
assert(true, 'Debugger initializes with Knowledge Graph');
console.log('--- All Symbolic Debugger Tests Passed ---');