169 lines
5.1 KiB
Python
169 lines
5.1 KiB
Python
"""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": [],
|
|
},
|
|
)
|