diff --git a/src/dashboard/routes/nexus.py b/src/dashboard/routes/nexus.py index 9a76caa7..8cd403d6 100644 --- a/src/dashboard/routes/nexus.py +++ b/src/dashboard/routes/nexus.py @@ -1,21 +1,32 @@ -"""Nexus — Timmy's persistent conversational awareness space. +"""Nexus v2 — Timmy's persistent conversational awareness space. -A conversational-only interface where Timmy maintains live memory context. -No tool use; pure conversation with memory integration and a teaching panel. +Extends the v1 Nexus (chat + memory sidebar + teaching panel) with: + +- **Persistent conversations** — SQLite-backed history survives restarts. +- **Introspection panel** — live cognitive state, recent thoughts, session + analytics rendered alongside every conversation turn. +- **Sovereignty pulse** — real-time sovereignty health badge in the sidebar. +- **WebSocket** — pushes introspection + sovereignty snapshots so the + Nexus page stays alive without polling. Routes: - GET /nexus — render nexus page with live memory sidebar + GET /nexus — render nexus page with full awareness panels POST /nexus/chat — send a message; returns HTMX partial POST /nexus/teach — inject a fact into Timmy's live memory DELETE /nexus/history — clear the nexus conversation history + GET /nexus/introspect — JSON introspection snapshot (API) + WS /nexus/ws — live introspection + sovereignty push + +Refs: #1090 (Nexus Epic), #953 (Sovereignty Loop) """ import asyncio +import json import logging from datetime import UTC, datetime -from fastapi import APIRouter, Form, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Form, Request, WebSocket +from fastapi.responses import HTMLResponse, JSONResponse from dashboard.templating import templates from timmy.memory_system import ( @@ -24,6 +35,9 @@ from timmy.memory_system import ( search_memories, store_personal_fact, ) +from timmy.nexus.introspection import nexus_introspector +from timmy.nexus.persistence import nexus_store +from timmy.nexus.sovereignty_pulse import sovereignty_pulse from timmy.session import _clean_response, chat, reset_session logger = logging.getLogger(__name__) @@ -32,28 +46,74 @@ router = APIRouter(prefix="/nexus", tags=["nexus"]) _NEXUS_SESSION_ID = "nexus" _MAX_MESSAGE_LENGTH = 10_000 +_WS_PUSH_INTERVAL = 5 # seconds between WebSocket pushes -# In-memory conversation log for the Nexus session (mirrors chat store pattern -# but is scoped to the Nexus so it won't pollute the main dashboard history). +# In-memory conversation log — kept in sync with the persistent store +# so templates can render without hitting the DB on every page load. _nexus_log: list[dict] = [] +# ── Initialisation ─────────────────────────────────────────────────────────── +# On module load, hydrate the in-memory log from the persistent store. +# This runs once at import time (process startup). +_HYDRATED = False + + +def _hydrate_log() -> None: + """Load persisted history into the in-memory log (idempotent).""" + global _HYDRATED + if _HYDRATED: + return + try: + rows = nexus_store.get_history(limit=200) + _nexus_log.clear() + for row in rows: + _nexus_log.append( + { + "role": row["role"], + "content": row["content"], + "timestamp": row["timestamp"], + } + ) + _HYDRATED = True + logger.info("Nexus: hydrated %d messages from persistent store", len(_nexus_log)) + except Exception as exc: + logger.warning("Nexus: failed to hydrate from store: %s", exc) + _HYDRATED = True # Don't retry repeatedly + + +# ── Helpers ────────────────────────────────────────────────────────────────── + def _ts() -> str: return datetime.now(UTC).strftime("%H:%M:%S") def _append_log(role: str, content: str) -> None: - _nexus_log.append({"role": role, "content": content, "timestamp": _ts()}) - # Keep last 200 exchanges to bound memory usage + """Append to both in-memory log and persistent store.""" + ts = _ts() + _nexus_log.append({"role": role, "content": content, "timestamp": ts}) + # Bound in-memory log if len(_nexus_log) > 200: del _nexus_log[:-200] + # Persist + try: + nexus_store.append(role, content, timestamp=ts) + except Exception as exc: + logger.warning("Nexus: persist failed: %s", exc) + + +# ── Page route ─────────────────────────────────────────────────────────────── @router.get("", response_class=HTMLResponse) async def nexus_page(request: Request): - """Render the Nexus page with live memory context.""" + """Render the Nexus page with full awareness panels.""" + _hydrate_log() + stats = get_memory_stats() facts = recall_personal_facts_with_ids()[:8] + introspection = nexus_introspector.snapshot(conversation_log=_nexus_log) + pulse = sovereignty_pulse.snapshot() return templates.TemplateResponse( request, @@ -63,13 +123,18 @@ async def nexus_page(request: Request): "messages": list(_nexus_log), "stats": stats, "facts": facts, + "introspection": introspection.to_dict(), + "pulse": pulse.to_dict(), }, ) +# ── Chat route ─────────────────────────────────────────────────────────────── + + @router.post("/chat", response_class=HTMLResponse) async def nexus_chat(request: Request, message: str = Form(...)): - """Conversational-only chat routed through the Nexus session. + """Conversational-only chat with persistence and introspection. Does not invoke tool-use approval flow — pure conversation with memory context injected from Timmy's live memory store. @@ -87,18 +152,22 @@ async def nexus_chat(request: Request, message: str = Form(...)): "error": "Message too long (max 10 000 chars).", "timestamp": _ts(), "memory_hits": [], + "introspection": nexus_introspector.snapshot().to_dict(), }, ) ts = _ts() - # Fetch semantically relevant memories to surface in the sidebar + # Fetch semantically relevant memories try: memory_hits = await asyncio.to_thread(search_memories, query=message, limit=4) except Exception as exc: logger.warning("Nexus memory search failed: %s", exc) memory_hits = [] + # Track memory hits for analytics + nexus_introspector.record_memory_hits(len(memory_hits)) + # Conversational response — no tool approval flow response_text: str | None = None error_text: str | None = None @@ -113,6 +182,9 @@ async def nexus_chat(request: Request, message: str = Form(...)): if response_text: _append_log("assistant", response_text) + # Build fresh introspection snapshot after the exchange + introspection = nexus_introspector.snapshot(conversation_log=_nexus_log) + return templates.TemplateResponse( request, "partials/nexus_message.html", @@ -122,10 +194,14 @@ async def nexus_chat(request: Request, message: str = Form(...)): "error": error_text, "timestamp": ts, "memory_hits": memory_hits, + "introspection": introspection.to_dict(), }, ) +# ── Teach route ────────────────────────────────────────────────────────────── + + @router.post("/teach", response_class=HTMLResponse) async def nexus_teach(request: Request, fact: str = Form(...)): """Inject a fact into Timmy's live memory from the Nexus teaching panel.""" @@ -148,11 +224,20 @@ async def nexus_teach(request: Request, fact: str = Form(...)): ) +# ── Clear history ──────────────────────────────────────────────────────────── + + @router.delete("/history", response_class=HTMLResponse) async def nexus_clear_history(request: Request): - """Clear the Nexus conversation history.""" + """Clear the Nexus conversation history (both in-memory and persistent).""" _nexus_log.clear() + try: + nexus_store.clear() + except Exception as exc: + logger.warning("Nexus: persistent clear failed: %s", exc) + nexus_introspector.reset() reset_session(session_id=_NEXUS_SESSION_ID) + return templates.TemplateResponse( request, "partials/nexus_message.html", @@ -162,5 +247,55 @@ async def nexus_clear_history(request: Request): "error": None, "timestamp": _ts(), "memory_hits": [], + "introspection": nexus_introspector.snapshot().to_dict(), }, ) + + +# ── Introspection API ──────────────────────────────────────────────────────── + + +@router.get("/introspect", response_class=JSONResponse) +async def nexus_introspect(): + """Return a JSON introspection snapshot (for API consumers).""" + snap = nexus_introspector.snapshot(conversation_log=_nexus_log) + pulse = sovereignty_pulse.snapshot() + return { + "introspection": snap.to_dict(), + "sovereignty_pulse": pulse.to_dict(), + } + + +# ── WebSocket — live Nexus push ────────────────────────────────────────────── + + +@router.websocket("/ws") +async def nexus_ws(websocket: WebSocket) -> None: + """Push introspection + sovereignty pulse snapshots to the Nexus page. + + The frontend connects on page load and receives JSON updates every + ``_WS_PUSH_INTERVAL`` seconds, keeping the cognitive state panel, + thought stream, and sovereignty badge fresh without HTMX polling. + """ + await websocket.accept() + logger.info("Nexus WS connected") + try: + # Immediate first push + await _push_snapshot(websocket) + while True: + await asyncio.sleep(_WS_PUSH_INTERVAL) + await _push_snapshot(websocket) + except Exception: + logger.debug("Nexus WS disconnected") + + +async def _push_snapshot(ws: WebSocket) -> None: + """Send the combined introspection + pulse payload.""" + snap = nexus_introspector.snapshot(conversation_log=_nexus_log) + pulse = sovereignty_pulse.snapshot() + payload = { + "type": "nexus_state", + "introspection": snap.to_dict(), + "sovereignty_pulse": pulse.to_dict(), + } + await ws.send_text(json.dumps(payload)) diff --git a/src/dashboard/templates/nexus.html b/src/dashboard/templates/nexus.html index 1020e1f5..5c6b9f90 100644 --- a/src/dashboard/templates/nexus.html +++ b/src/dashboard/templates/nexus.html @@ -8,26 +8,40 @@