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 += '
Nexus Core
';
+ 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 += '
Cron Heartbeats
';
+ 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 += '
Latest Report
';
+
+ // 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 @@
+
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