Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy Swarm (mimo-v2-pro)
43455e9c83 fix: [PANELS] Add heartbeat / morning briefing panel tied to Hermes state (closes #698) 2026-04-10 20:19:36 -04:00
5 changed files with 408 additions and 1 deletions

3
.gitignore vendored
View File

@@ -7,3 +7,6 @@ mempalace/__pycache__/
# Prevent agents from writing to wrong path (see issue #1145)
public/nexus/
__pycache__/
*.pyc

124
app.js
View File

@@ -713,6 +713,8 @@ async function init() {
connectHermes();
fetchGiteaData();
setInterval(fetchGiteaData, 30000); // Refresh every 30s
updateHeartbeatBriefing(); // Initial briefing load
setInterval(updateHeartbeatBriefing, 60000); // Refresh briefing every 60s
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
@@ -1140,6 +1142,7 @@ async function fetchGiteaData() {
const worldState = JSON.parse(atob(content.content));
updateNexusCommand(worldState);
updateSovereignHealth();
updateHeartbeatBriefing();
}
} catch (e) {
console.error('Failed to fetch Gitea data:', e);
@@ -1235,6 +1238,127 @@ function updateNexusCommand(state) {
terminal.updatePanelText(lines);
}
// ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════════
async function updateHeartbeatBriefing() {
const container = document.getElementById('heartbeat-briefing-content');
const pulseDot = document.querySelector('.hb-pulse-dot');
if (!container) return;
let data = null;
try {
// Derive briefing endpoint from current location or fallback to localhost
const briefingUrl = window.location.protocol === 'file:'
? 'http://localhost:8766/api/briefing'
: `${window.location.protocol}//${window.location.hostname}:8766/api/briefing`;
const res = await fetch(briefingUrl);
if (res.ok) data = await res.json();
} catch (e) {
// Server not reachable — show honest offline state
}
if (!data) {
if (pulseDot) pulseDot.classList.add('offline');
container.innerHTML = '<div class="hb-empty">Briefing offline<br>No connection to Nexus gateway</div>';
return;
}
if (pulseDot) pulseDot.classList.remove('offline');
let html = '';
// ── Core Heartbeat ──
html += '<div class="briefing-section">';
html += '<div class="briefing-section-label">Nexus Core</div>';
const hb = data.core_heartbeat;
if (hb) {
const age = hb.age_secs != null ? hb.age_secs : '?';
const ageLabel = typeof age === 'number'
? (age < 60 ? `${age.toFixed(0)}s ago` : `${(age / 60).toFixed(1)}m ago`)
: age;
const isAlive = typeof age === 'number' && age < 120;
html += `<div class="hb-core-row">
<span class="hb-core-status ${isAlive ? 'alive' : 'dead'}">${isAlive ? '● ALIVE' : '○ STALE'}</span>
<span class="hb-core-meta">cycle ${hb.cycle || '?'} · ${hb.model || 'unknown'} · ${ageLabel}</span>
</div>`;
html += `<div class="hb-core-meta">status: ${hb.status || '?'}</div>`;
} else {
html += '<div class="hb-core-row"><span class="hb-core-status dead">○ NO DATA</span></div>';
}
html += '</div>';
// ── Cron Heartbeats ──
const cron = data.cron_heartbeat;
if (cron && cron.jobs && cron.jobs.length > 0) {
html += '<div class="briefing-section">';
html += '<div class="briefing-section-label">Cron Heartbeats</div>';
html += `<div class="hb-cron-row">
<span class="hb-cron-healthy">● ${cron.healthy_count} healthy</span>
${cron.stale_count > 0 ? `<span class="hb-cron-stale">⚠ ${cron.stale_count} stale</span>` : ''}
</div>`;
for (const job of cron.jobs.slice(0, 5)) {
const cls = job.healthy ? 'healthy' : 'stale';
html += `<div class="hb-cron-job">
<span class="hb-cron-job-name">${esc(job.job)}</span>
<span class="hb-cron-job-status ${cls}">${esc(job.message)}</span>
</div>`;
}
if (cron.jobs.length > 5) {
html += `<div class="hb-core-meta">+${cron.jobs.length - 5} more jobs</div>`;
}
html += '</div>';
}
// ── Morning Report ──
const report = data.morning_report;
if (report) {
html += '<div class="briefing-section">';
html += '<div class="briefing-section-label">Latest Report</div>';
// Aggregate stats
let totalClosed = 0, totalMerged = 0;
if (report.repos) {
for (const r of Object.values(report.repos)) {
totalClosed += r.closed_issues || 0;
totalMerged += r.merged_prs || 0;
}
}
html += `<div class="hb-stats-row">
<div class="hb-stat"><span class="hb-stat-value" style="color:#4af0c0">${totalClosed}</span><span class="hb-stat-label">Closed</span></div>
<div class="hb-stat"><span class="hb-stat-value" style="color:#7b5cff">${totalMerged}</span><span class="hb-stat-label">Merged</span></div>
<div class="hb-stat"><span class="hb-stat-value" style="color:#ffd700">${(report.blockers || []).length}</span><span class="hb-stat-label">Blockers</span></div>
</div>`;
// Highlights (up to 3)
if (report.highlights && report.highlights.length > 0) {
for (const h of report.highlights.slice(0, 3)) {
html += `<div class="hb-core-meta">+ ${esc(h)}</div>`;
}
}
// Blockers
if (report.blockers && report.blockers.length > 0) {
for (const b of report.blockers) {
html += `<div class="hb-blocker">⚠ ${esc(b)}</div>`;
}
}
html += `<div class="hb-timestamp">Report: ${esc(report.generated_at || '?')}</div>`;
html += '</div>';
}
// ── Timestamp ──
html += `<div class="hb-timestamp">Briefing updated: ${new Date().toLocaleTimeString('en-US', { hour12: false })}</div>`;
container.innerHTML = html;
}
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
// ═══ AGENT PRESENCE SYSTEM ═══
function createAgentPresences() {
const agentData = [

View File

@@ -96,6 +96,12 @@
<div class="panel-header">SOVEREIGN HEALTH</div>
<div id="sovereign-health-content" class="panel-content"></div>
</div>
<div class="hud-panel hud-panel-briefing" id="heartbeat-briefing-log">
<div class="panel-header">
<span class="hb-pulse-dot"></span> HEARTBEAT BRIEFING
</div>
<div id="heartbeat-briefing-content" class="panel-content"></div>
</div>
<div class="hud-panel" id="calibrator-log">
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
<div id="calibrator-log-content" class="panel-content"></div>

144
server.py
View File

@@ -3,17 +3,156 @@
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface.
Serves HTTP alongside WebSocket:
GET /api/briefing — heartbeat + morning report data for the HUD briefing panel
"""
import asyncio
import json
import logging
import os
import signal
import sys
from typing import Set
from datetime import datetime, timezone
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from threading import Thread
from typing import Any, Dict, Set
# Branch protected file - see POLICY.md
import websockets
# ── HTTP Briefing Endpoint ─────────────────────────────────────────────
HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
REPORTS_DIR = Path.home() / ".local" / "timmy" / "reports"
CRON_HEARTBEAT_DIR_PRIMARY = Path("/var/run/bezalel/heartbeats")
CRON_HEARTBEAT_DIR_FALLBACK = Path.home() / ".bezalel" / "heartbeats"
def _read_json_file(path: Path) -> Any:
"""Read and parse a JSON file. Returns None on failure."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def _resolve_cron_dir() -> Path:
"""Return the first writable cron heartbeat directory."""
for d in [CRON_HEARTBEAT_DIR_PRIMARY, CRON_HEARTBEAT_DIR_FALLBACK]:
if d.exists() and os.access(str(d), os.R_OK):
return d
return CRON_HEARTBEAT_DIR_FALLBACK
def _read_cron_heartbeats() -> list:
"""Read all .last files from the cron heartbeat directory."""
hb_dir = _resolve_cron_dir()
if not hb_dir.exists():
return []
now = datetime.now(timezone.utc).timestamp()
jobs = []
for f in sorted(hb_dir.glob("*.last")):
data = _read_json_file(f)
if data is None:
jobs.append({"job": f.stem, "healthy": False, "message": "corrupt"})
continue
ts = float(data.get("timestamp", 0))
interval = int(data.get("interval", 3600))
age = now - ts
is_stale = age > (2 * interval)
jobs.append({
"job": f.stem,
"healthy": not is_stale,
"age_secs": round(age, 1),
"interval": interval,
"last_seen": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None,
"message": f"{'STALE' if is_stale else 'OK'} ({age:.0f}s / {interval}s)" if ts else "no timestamp",
})
return jobs
def _latest_morning_report() -> Dict[str, Any] | None:
"""Find the most recent morning report file."""
if not REPORTS_DIR.exists():
return None
reports = sorted(REPORTS_DIR.glob("morning-*.json"), reverse=True)
if not reports:
return None
return _read_json_file(reports[0])
def _build_briefing() -> Dict[str, Any]:
"""Assemble the full briefing payload from real files."""
now = datetime.now(timezone.utc)
# Core heartbeat
core_hb = _read_json_file(HEARTBEAT_PATH)
if core_hb:
beat_ts = float(core_hb.get("timestamp", 0))
core_hb["age_secs"] = round(now.timestamp() - beat_ts, 1) if beat_ts else None
# Cron heartbeats
cron_jobs = _read_cron_heartbeats()
healthy_count = sum(1 for j in cron_jobs if j.get("healthy"))
stale_count = sum(1 for j in cron_jobs if not j.get("healthy"))
# Morning report
report = _latest_morning_report()
return {
"generated_at": now.isoformat(),
"core_heartbeat": core_hb,
"cron_heartbeat": {
"jobs": cron_jobs,
"healthy_count": healthy_count,
"stale_count": stale_count,
},
"morning_report": report,
}
class BriefingHandler(SimpleHTTPRequestHandler):
"""Minimal HTTP handler that only serves /api/briefing."""
def do_GET(self):
if self.path == "/api/briefing":
try:
data = _build_briefing()
body = json.dumps(data).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
except Exception as e:
self.send_error(500, str(e))
elif self.path == "/api/health":
body = json.dumps({"status": "ok"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
else:
self.send_error(404)
def log_message(self, fmt, *args):
pass # Suppress HTTP access logs — WS gateway logs are enough
def start_http_server(port: int = 8766):
"""Run the HTTP server in a daemon thread."""
server = HTTPServer(("0.0.0.0", port), BriefingHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
logger = logging.getLogger("nexus-gateway")
logger.info(f"Briefing HTTP server started on http://0.0.0.0:{port}")
return server
# Configuration
PORT = 8765
HOST = "0.0.0.0" # Allow external connections if needed
@@ -80,6 +219,9 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main():
"""Main server loop with graceful shutdown."""
# Start HTTP briefing endpoint alongside WS
http_server = start_http_server(port=8766)
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
# Set up signal handlers for graceful shutdown

132
style.css
View File

@@ -1224,6 +1224,138 @@ canvas#nexus-canvas {
.pse-status { color: #4af0c0; font-weight: 600; }
/* ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════ */
.hud-panel-briefing {
width: 320px;
max-height: 420px;
border-left-color: #7b5cff;
}
.hud-panel-briefing .panel-header {
display: flex;
align-items: center;
gap: 6px;
color: #7b5cff;
}
.hud-panel-briefing .panel-content {
max-height: 360px;
overflow-y: auto;
}
/* Pulse dot */
.hb-pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4af0c0;
box-shadow: 0 0 6px #4af0c0;
animation: hb-dot-pulse 2s ease-in-out infinite;
flex-shrink: 0;
}
.hb-pulse-dot.offline {
background: #ff4466;
box-shadow: 0 0 6px #ff4466;
animation: none;
}
@keyframes hb-dot-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* Briefing sections */
.briefing-section {
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(123, 92, 255, 0.12);
}
.briefing-section:last-child { border-bottom: none; margin-bottom: 0; }
.briefing-section-label {
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8a9ab8;
margin-bottom: 4px;
}
/* Core heartbeat row */
.hb-core-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 4px;
}
.hb-core-status {
font-weight: 700;
font-size: 11px;
}
.hb-core-status.alive { color: #4af0c0; }
.hb-core-status.dead { color: #ff4466; }
.hb-core-meta {
font-size: 10px;
color: #8a9ab8;
}
/* Cron jobs */
.hb-cron-row {
display: flex;
gap: 8px;
font-size: 10px;
margin-bottom: 2px;
}
.hb-cron-healthy { color: #4af0c0; }
.hb-cron-stale { color: #ff4466; font-weight: 700; }
.hb-cron-job {
display: flex;
justify-content: space-between;
font-size: 10px;
padding: 1px 0;
}
.hb-cron-job-name { color: #e0f0ff; }
.hb-cron-job-status.healthy { color: #4af0c0; }
.hb-cron-job-status.stale { color: #ff4466; font-weight: 700; }
/* Morning report stats */
.hb-stats-row {
display: flex;
gap: 12px;
font-size: 10px;
}
.hb-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.hb-stat-value {
font-family: 'Orbitron', sans-serif;
font-weight: 700;
font-size: 14px;
}
.hb-stat-label {
font-size: 9px;
color: #8a9ab8;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Blockers */
.hb-blocker {
font-size: 10px;
color: #ff4466;
padding: 1px 0;
}
/* Narrative */
.hb-narrative {
font-size: 10px;
color: #8a9ab8;
line-height: 1.5;
font-style: italic;
}
/* Empty / offline state */
.hb-empty {
font-size: 10px;
color: #8a9ab8;
text-align: center;
padding: 12px 0;
}
/* Timestamp */
.hb-timestamp {
font-size: 9px;
color: rgba(138, 154, 184, 0.6);
margin-top: 4px;
}
/* ═══════════════════════════════════════════
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL