diff --git a/docs/nexus-spec.md b/docs/nexus-spec.md new file mode 100644 index 00000000..5fcf27d3 --- /dev/null +++ b/docs/nexus-spec.md @@ -0,0 +1,105 @@ +# Nexus — Scope & Acceptance Criteria + +**Issue:** #1208 +**Date:** 2026-03-23 +**Status:** Initial implementation complete; teaching/RL harness deferred + +--- + +## Summary + +The **Nexus** is a persistent conversational space where Timmy lives with full +access to his live memory. Unlike the main dashboard chat (which uses tools and +has a transient feel), the Nexus is: + +- **Conversational only** — no tool approval flow; pure dialogue +- **Memory-aware** — semantically relevant memories surface alongside each exchange +- **Teachable** — the operator can inject facts directly into Timmy's live memory +- **Persistent** — the session survives page refreshes; history accumulates over time +- **Local** — always backed by Ollama; no cloud inference required + +This is the foundation for future LoRA fine-tuning, RL training harnesses, and +eventually real-time self-improvement loops. + +--- + +## Scope (v1 — this PR) + +| Area | Included | Deferred | +|------|----------|----------| +| Conversational UI | ✅ Chat panel with HTMX streaming | Streaming tokens | +| Live memory sidebar | ✅ Semantic search on each turn | Auto-refresh on teach | +| Teaching panel | ✅ Inject personal facts | Bulk import, LoRA trigger | +| Session isolation | ✅ Dedicated `nexus` session ID | Per-operator sessions | +| Nav integration | ✅ NEXUS link in INTEL dropdown | Mobile nav | +| CSS/styling | ✅ Two-column responsive layout | Dark/light theme toggle | +| Tests | ✅ 9 unit tests, all green | E2E with real Ollama | +| LoRA / RL harness | ❌ deferred to future issue | | +| Auto-falsework | ❌ deferred | | +| Bannerlord interface | ❌ separate track | | + +--- + +## Acceptance Criteria + +### AC-1: Nexus page loads +- **Given** the dashboard is running +- **When** I navigate to `/nexus` +- **Then** I see a two-panel layout: conversation on the left, memory sidebar on the right +- **And** the page title reads "// NEXUS" +- **And** the page is accessible from the nav (INTEL → NEXUS) + +### AC-2: Conversation-only chat +- **Given** I am on the Nexus page +- **When** I type a message and submit +- **Then** Timmy responds using the `nexus` session (isolated from dashboard history) +- **And** no tool-approval cards appear — responses are pure text +- **And** my message and Timmy's reply are appended to the chat log + +### AC-3: Memory context surfaces automatically +- **Given** I send a message +- **When** the response arrives +- **Then** the "LIVE MEMORY CONTEXT" panel shows up to 4 semantically relevant memories +- **And** each memory entry shows its type and content + +### AC-4: Teaching panel stores facts +- **Given** I type a fact into the "TEACH TIMMY" input and submit +- **When** the request completes +- **Then** I see a green confirmation "✓ Taught: " +- **And** the fact appears in the "KNOWN FACTS" list +- **And** the fact is stored in Timmy's live memory (`store_personal_fact`) + +### AC-5: Empty / invalid input is rejected gracefully +- **Given** I submit a blank message or fact +- **Then** no request is made and the log is unchanged +- **Given** I submit a message over 10 000 characters +- **Then** an inline error is shown without crashing the server + +### AC-6: Conversation can be cleared +- **Given** the Nexus has conversation history +- **When** I click CLEAR and confirm +- **Then** the chat log shows only a "cleared" confirmation +- **And** the Agno session for `nexus` is reset + +### AC-7: Graceful degradation when Ollama is down +- **Given** Ollama is unavailable +- **When** I send a message +- **Then** an error message is shown inline (not a 500 page) +- **And** the app continues to function + +### AC-8: No regression on existing tests +- **Given** the nexus route is registered +- **When** `tox -e unit` runs +- **Then** all 343+ existing tests remain green + +--- + +## Future Work (separate issues) + +1. **LoRA trigger** — button in the teaching panel to queue a fine-tuning run + using the current Nexus conversation as training data +2. **RL harness** — reward signal collection during conversation for RLHF +3. **Auto-falsework pipeline** — scaffold harness generation from conversation +4. **Bannerlord interface** — Nexus as the live-memory bridge for in-game Timmy +5. **Streaming responses** — token-by-token display via WebSocket +6. **Per-operator sessions** — isolate Nexus history by logged-in user diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 02cd2093..8dddeb39 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -42,6 +42,7 @@ from dashboard.routes.hermes import router as hermes_router from dashboard.routes.loop_qa import router as loop_qa_router from dashboard.routes.memory import router as memory_router from dashboard.routes.mobile import router as mobile_router +from dashboard.routes.nexus import router as nexus_router from dashboard.routes.models import api_router as models_api_router from dashboard.routes.models import router as models_router from dashboard.routes.quests import router as quests_router @@ -652,6 +653,7 @@ app.include_router(tools_router) app.include_router(spark_router) app.include_router(discord_router) app.include_router(memory_router) +app.include_router(nexus_router) app.include_router(grok_router) app.include_router(models_router) app.include_router(models_api_router) diff --git a/src/dashboard/routes/nexus.py b/src/dashboard/routes/nexus.py new file mode 100644 index 00000000..061d1485 --- /dev/null +++ b/src/dashboard/routes/nexus.py @@ -0,0 +1,168 @@ +"""Nexus — 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. + +Routes: + GET /nexus — render nexus page with live memory sidebar + 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 +""" + +import asyncio +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse + +from dashboard.templating import templates +from timmy.memory_system import ( + get_memory_stats, + recall_personal_facts_with_ids, + search_memories, + store_personal_fact, +) +from timmy.session import _clean_response, chat, reset_session + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/nexus", tags=["nexus"]) + +_NEXUS_SESSION_ID = "nexus" +_MAX_MESSAGE_LENGTH = 10_000 + +# 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). +_nexus_log: list[dict] = [] + + +def _ts() -> str: + return datetime.now(timezone.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 + if len(_nexus_log) > 200: + del _nexus_log[:-200] + + +@router.get("", response_class=HTMLResponse) +async def nexus_page(request: Request): + """Render the Nexus page with live memory context.""" + stats = get_memory_stats() + facts = recall_personal_facts_with_ids()[:8] + + return templates.TemplateResponse( + request, + "nexus.html", + { + "page_title": "Nexus", + "messages": list(_nexus_log), + "stats": stats, + "facts": facts, + }, + ) + + +@router.post("/chat", response_class=HTMLResponse) +async def nexus_chat(request: Request, message: str = Form(...)): + """Conversational-only chat routed through the Nexus session. + + Does not invoke tool-use approval flow — pure conversation with memory + context injected from Timmy's live memory store. + """ + message = message.strip() + if not message: + return HTMLResponse("") + if len(message) > _MAX_MESSAGE_LENGTH: + return templates.TemplateResponse( + request, + "partials/nexus_message.html", + { + "user_message": message[:80] + "…", + "response": None, + "error": "Message too long (max 10 000 chars).", + "timestamp": _ts(), + "memory_hits": [], + }, + ) + + ts = _ts() + + # Fetch semantically relevant memories to surface in the sidebar + 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 = [] + + # Conversational response — no tool approval flow + response_text: str | None = None + error_text: str | None = None + try: + raw = await chat(message, session_id=_NEXUS_SESSION_ID) + response_text = _clean_response(raw) + except Exception as exc: + logger.error("Nexus chat error: %s", exc) + error_text = "Timmy is unavailable right now. Check that Ollama is running." + + _append_log("user", message) + if response_text: + _append_log("assistant", response_text) + + return templates.TemplateResponse( + request, + "partials/nexus_message.html", + { + "user_message": message, + "response": response_text, + "error": error_text, + "timestamp": ts, + "memory_hits": memory_hits, + }, + ) + + +@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.""" + fact = fact.strip() + if not fact: + return HTMLResponse("") + + try: + await asyncio.to_thread(store_personal_fact, fact) + facts = await asyncio.to_thread(recall_personal_facts_with_ids) + facts = facts[:8] + except Exception as exc: + logger.error("Nexus teach error: %s", exc) + facts = [] + + return templates.TemplateResponse( + request, + "partials/nexus_facts.html", + {"facts": facts, "taught": fact}, + ) + + +@router.delete("/history", response_class=HTMLResponse) +async def nexus_clear_history(request: Request): + """Clear the Nexus conversation history.""" + _nexus_log.clear() + reset_session(session_id=_NEXUS_SESSION_ID) + return templates.TemplateResponse( + request, + "partials/nexus_message.html", + { + "user_message": None, + "response": "Nexus conversation cleared.", + "error": None, + "timestamp": _ts(), + "memory_hits": [], + }, + ) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index b92cb4b4..0456d976 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -67,6 +67,7 @@
+ NEXUS SPARK MEMORY MARKET diff --git a/src/dashboard/templates/nexus.html b/src/dashboard/templates/nexus.html new file mode 100644 index 00000000..1020e1f5 --- /dev/null +++ b/src/dashboard/templates/nexus.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block title %}Nexus{% endblock %} + +{% block extra_styles %}{% endblock %} + +{% block content %} +
+ +
+
// NEXUS
+
+ Persistent conversational awareness — always present, always learning. +
+
+ +
+ + +
+
+
+ // CONVERSATION + +
+ +
+ {% for msg in messages %} +
+
+ {{ 'YOU' if msg.role == 'user' else 'TIMMY' }} // {{ msg.timestamp }} +
+
+ {{ msg.content | e }} +
+
+ {% else %} +
+ Nexus is ready. Start a conversation — memories will surface in real time. +
+ {% endfor %} +
+ + +
+
+ + +
+ + +
+
+ // LIVE MEMORY + + {{ stats.total_entries }} stored + +
+
+
+
Relevant memories appear here as you chat.
+
+
+
+ + +
+
// TEACH TIMMY
+
+
+
+ + +
+
+
+ +
// KNOWN FACTS
+
    + {% for fact in facts %} +
  • {{ fact.content | e }}
  • + {% else %} +
  • No personal facts stored yet.
  • + {% endfor %} +
+
+
+ +
+
+ +
+{% endblock %} diff --git a/src/dashboard/templates/partials/nexus_facts.html b/src/dashboard/templates/partials/nexus_facts.html new file mode 100644 index 00000000..5dff7b11 --- /dev/null +++ b/src/dashboard/templates/partials/nexus_facts.html @@ -0,0 +1,12 @@ +{% if taught %} +
+ ✓ Taught: {{ taught | e }} +
+{% endif %} +
    + {% for fact in facts %} +
  • {{ fact.content | e }}
  • + {% else %} +
  • No facts stored yet.
  • + {% endfor %} +
diff --git a/src/dashboard/templates/partials/nexus_message.html b/src/dashboard/templates/partials/nexus_message.html new file mode 100644 index 00000000..9055df2b --- /dev/null +++ b/src/dashboard/templates/partials/nexus_message.html @@ -0,0 +1,36 @@ +{% if user_message %} +
+
YOU // {{ timestamp }}
+
{{ user_message | e }}
+
+{% endif %} +{% if response %} +
+
TIMMY // {{ timestamp }}
+
{{ response | e }}
+
+ +{% elif error %} +
+
SYSTEM // {{ timestamp }}
+
{{ error | e }}
+
+{% endif %} +{% if memory_hits %} +
+
// LIVE MEMORY CONTEXT
+ {% for hit in memory_hits %} +
+ {{ hit.memory_type }} + {{ hit.content | e }} +
+ {% endfor %} +
+{% endif %} diff --git a/static/css/mission-control.css b/static/css/mission-control.css index 1f29261d..fc333da0 100644 --- a/static/css/mission-control.css +++ b/static/css/mission-control.css @@ -2664,3 +2664,53 @@ color: var(--bg-deep); } .vs-btn-save:hover { opacity: 0.85; } + +/* ── Nexus ────────────────────────────────────────────────── */ +.nexus-layout { max-width: 1400px; margin: 0 auto; } + +.nexus-header { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; } +.nexus-title { font-size: 1.4rem; font-weight: 700; color: var(--purple); letter-spacing: 0.1em; } +.nexus-subtitle { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.2rem; } + +.nexus-grid { + display: grid; + grid-template-columns: 1fr 320px; + gap: 1rem; + align-items: start; +} +@media (max-width: 900px) { + .nexus-grid { grid-template-columns: 1fr; } +} + +.nexus-chat-panel { height: calc(100vh - 180px); display: flex; flex-direction: column; } +.nexus-chat-panel .card-body { overflow-y: auto; flex: 1; } + +.nexus-empty-state { + color: var(--text-dim); + font-size: 0.85rem; + font-style: italic; + padding: 1rem 0; + text-align: center; +} + +/* Memory sidebar */ +.nexus-memory-hits { font-size: 0.78rem; } +.nexus-memory-label { color: var(--text-dim); font-size: 0.72rem; margin-bottom: 0.4rem; letter-spacing: 0.05em; } +.nexus-memory-hit { display: flex; gap: 0.4rem; margin-bottom: 0.35rem; align-items: flex-start; } +.nexus-memory-type { color: var(--purple); font-size: 0.68rem; white-space: nowrap; padding-top: 0.1rem; min-width: 60px; } +.nexus-memory-content { color: var(--text); line-height: 1.4; } + +/* Teaching panel */ +.nexus-facts-header { font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.08em; margin-bottom: 0.4rem; } +.nexus-facts-list { list-style: none; padding: 0; margin: 0; font-size: 0.8rem; } +.nexus-fact-item { color: var(--text); border-bottom: 1px solid var(--border); padding: 0.3rem 0; } +.nexus-fact-empty { color: var(--text-dim); font-style: italic; } +.nexus-taught-confirm { + font-size: 0.8rem; + color: var(--green); + background: rgba(0,255,136,0.06); + border: 1px solid var(--green); + border-radius: 4px; + padding: 0.3rem 0.6rem; + margin-bottom: 0.5rem; +} diff --git a/tests/dashboard/test_nexus.py b/tests/dashboard/test_nexus.py new file mode 100644 index 00000000..70faefa1 --- /dev/null +++ b/tests/dashboard/test_nexus.py @@ -0,0 +1,72 @@ +"""Tests for the Nexus conversational awareness routes.""" + +from unittest.mock import patch + + +def test_nexus_page_returns_200(client): + """GET /nexus should render without error.""" + response = client.get("/nexus") + assert response.status_code == 200 + assert "NEXUS" in response.text + + +def test_nexus_page_contains_chat_form(client): + """Nexus page must include the conversational chat form.""" + response = client.get("/nexus") + assert response.status_code == 200 + assert "/nexus/chat" in response.text + + +def test_nexus_page_contains_teach_form(client): + """Nexus page must include the teaching panel form.""" + response = client.get("/nexus") + assert response.status_code == 200 + assert "/nexus/teach" in response.text + + +def test_nexus_chat_empty_message_returns_empty(client): + """POST /nexus/chat with blank message returns empty response.""" + response = client.post("/nexus/chat", data={"message": " "}) + assert response.status_code == 200 + assert response.text == "" + + +def test_nexus_chat_too_long_returns_error(client): + """POST /nexus/chat with overlong message returns error partial.""" + long_msg = "x" * 10_001 + response = client.post("/nexus/chat", data={"message": long_msg}) + assert response.status_code == 200 + assert "too long" in response.text.lower() + + +def test_nexus_chat_posts_message(client): + """POST /nexus/chat calls the session chat function and returns a partial.""" + with patch("dashboard.routes.nexus.chat", return_value="Hello from Timmy"): + response = client.post("/nexus/chat", data={"message": "hello"}) + assert response.status_code == 200 + assert "hello" in response.text.lower() or "timmy" in response.text.lower() + + +def test_nexus_teach_stores_fact(client): + """POST /nexus/teach should persist a fact and return confirmation.""" + with patch("dashboard.routes.nexus.store_personal_fact") as mock_store, \ + patch("dashboard.routes.nexus.recall_personal_facts_with_ids", return_value=[]): + mock_store.return_value = None + response = client.post("/nexus/teach", data={"fact": "Timmy loves Python"}) + assert response.status_code == 200 + assert "Timmy loves Python" in response.text + + +def test_nexus_teach_empty_fact_returns_empty(client): + """POST /nexus/teach with blank fact returns empty response.""" + response = client.post("/nexus/teach", data={"fact": " "}) + assert response.status_code == 200 + assert response.text == "" + + +def test_nexus_clear_history(client): + """DELETE /nexus/history should clear the conversation log.""" + with patch("dashboard.routes.nexus.reset_session"): + response = client.request("DELETE", "/nexus/history") + assert response.status_code == 200 + assert "cleared" in response.text.lower()