From 43455e9c835aee5cff8e6b4cfafc3db67a293312 Mon Sep 17 00:00:00 2001 From: "Timmy Swarm (mimo-v2-pro)" Date: Fri, 10 Apr 2026 20:19:20 -0400 Subject: [PATCH] fix: [PANELS] Add heartbeat / morning briefing panel tied to Hermes state (closes #698) --- .gitignore | 3 ++ app.js | 124 +++++++++++++++++++++++++++++++++++++++++++++ index.html | 6 +++ server.py | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++- style.css | 132 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 408 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 79e3e5bf..26b0bd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ mempalace/__pycache__/ # Prevent agents from writing to wrong path (see issue #1145) public/nexus/ + +__pycache__/ +*.pyc diff --git a/app.js b/app.js index 21c5c222..2018abea 100644 --- a/app.js +++ b/app.js @@ -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 = '
Briefing offline
No connection to Nexus gateway
'; + return; + } + + if (pulseDot) pulseDot.classList.remove('offline'); + + let html = ''; + + // ── Core Heartbeat ── + html += '
'; + html += ''; + 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 += `
+ ${isAlive ? '● ALIVE' : '○ STALE'} + cycle ${hb.cycle || '?'} · ${hb.model || 'unknown'} · ${ageLabel} +
`; + html += `
status: ${hb.status || '?'}
`; + } else { + html += '
○ NO DATA
'; + } + html += '
'; + + // ── Cron Heartbeats ── + const cron = data.cron_heartbeat; + if (cron && cron.jobs && cron.jobs.length > 0) { + html += '
'; + html += ''; + html += `
+ ● ${cron.healthy_count} healthy + ${cron.stale_count > 0 ? `⚠ ${cron.stale_count} stale` : ''} +
`; + for (const job of cron.jobs.slice(0, 5)) { + const cls = job.healthy ? 'healthy' : 'stale'; + html += `
+ ${esc(job.job)} + ${esc(job.message)} +
`; + } + if (cron.jobs.length > 5) { + html += `
+${cron.jobs.length - 5} more jobs
`; + } + html += '
'; + } + + // ── Morning Report ── + const report = data.morning_report; + if (report) { + html += '
'; + html += ''; + + // 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 += `
+
${totalClosed}Closed
+
${totalMerged}Merged
+
${(report.blockers || []).length}Blockers
+
`; + + // Highlights (up to 3) + if (report.highlights && report.highlights.length > 0) { + for (const h of report.highlights.slice(0, 3)) { + html += `
+ ${esc(h)}
`; + } + } + + // Blockers + if (report.blockers && report.blockers.length > 0) { + for (const b of report.blockers) { + html += `
⚠ ${esc(b)}
`; + } + } + + html += `
Report: ${esc(report.generated_at || '?')}
`; + html += '
'; + } + + // ── Timestamp ── + html += `
Briefing updated: ${new Date().toLocaleTimeString('en-US', { hour12: false })}
`; + + 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 = [ diff --git a/index.html b/index.html index 54ada489..b741d986 100644 --- a/index.html +++ b/index.html @@ -96,6 +96,12 @@
SOVEREIGN HEALTH
+
+
+ HEARTBEAT BRIEFING +
+
+
ADAPTIVE CALIBRATOR
diff --git a/server.py b/server.py index fe1e9d76..46e51881 100644 --- a/server.py +++ b/server.py @@ -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 diff --git a/style.css b/style.css index 2c3f70eb..3948677e 100644 --- a/style.css +++ b/style.css @@ -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