Co-authored-by: Perplexity Computer <perplexity@tower.local> Co-committed-by: Perplexity Computer <perplexity@tower.local>
This commit was merged in pull request #1348.
This commit is contained in:
@@ -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.
|
Extends the v1 Nexus (chat + memory sidebar + teaching panel) with:
|
||||||
No tool use; pure conversation with memory integration and a teaching panel.
|
|
||||||
|
- **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:
|
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/chat — send a message; returns HTMX partial
|
||||||
POST /nexus/teach — inject a fact into Timmy's live memory
|
POST /nexus/teach — inject a fact into Timmy's live memory
|
||||||
DELETE /nexus/history — clear the nexus conversation history
|
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 asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request, WebSocket
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
from dashboard.templating import templates
|
from dashboard.templating import templates
|
||||||
from timmy.memory_system import (
|
from timmy.memory_system import (
|
||||||
@@ -24,6 +35,9 @@ from timmy.memory_system import (
|
|||||||
search_memories,
|
search_memories,
|
||||||
store_personal_fact,
|
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
|
from timmy.session import _clean_response, chat, reset_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,28 +46,74 @@ router = APIRouter(prefix="/nexus", tags=["nexus"])
|
|||||||
|
|
||||||
_NEXUS_SESSION_ID = "nexus"
|
_NEXUS_SESSION_ID = "nexus"
|
||||||
_MAX_MESSAGE_LENGTH = 10_000
|
_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
|
# In-memory conversation log — kept in sync with the persistent store
|
||||||
# but is scoped to the Nexus so it won't pollute the main dashboard history).
|
# so templates can render without hitting the DB on every page load.
|
||||||
_nexus_log: list[dict] = []
|
_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:
|
def _ts() -> str:
|
||||||
return datetime.now(UTC).strftime("%H:%M:%S")
|
return datetime.now(UTC).strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
def _append_log(role: str, content: str) -> None:
|
def _append_log(role: str, content: str) -> None:
|
||||||
_nexus_log.append({"role": role, "content": content, "timestamp": _ts()})
|
"""Append to both in-memory log and persistent store."""
|
||||||
# Keep last 200 exchanges to bound memory usage
|
ts = _ts()
|
||||||
|
_nexus_log.append({"role": role, "content": content, "timestamp": ts})
|
||||||
|
# Bound in-memory log
|
||||||
if len(_nexus_log) > 200:
|
if len(_nexus_log) > 200:
|
||||||
del _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)
|
@router.get("", response_class=HTMLResponse)
|
||||||
async def nexus_page(request: Request):
|
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()
|
stats = get_memory_stats()
|
||||||
facts = recall_personal_facts_with_ids()[:8]
|
facts = recall_personal_facts_with_ids()[:8]
|
||||||
|
introspection = nexus_introspector.snapshot(conversation_log=_nexus_log)
|
||||||
|
pulse = sovereignty_pulse.snapshot()
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -63,13 +123,18 @@ async def nexus_page(request: Request):
|
|||||||
"messages": list(_nexus_log),
|
"messages": list(_nexus_log),
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"facts": facts,
|
"facts": facts,
|
||||||
|
"introspection": introspection.to_dict(),
|
||||||
|
"pulse": pulse.to_dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chat route ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat", response_class=HTMLResponse)
|
@router.post("/chat", response_class=HTMLResponse)
|
||||||
async def nexus_chat(request: Request, message: str = Form(...)):
|
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
|
Does not invoke tool-use approval flow — pure conversation with memory
|
||||||
context injected from Timmy's live memory store.
|
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).",
|
"error": "Message too long (max 10 000 chars).",
|
||||||
"timestamp": _ts(),
|
"timestamp": _ts(),
|
||||||
"memory_hits": [],
|
"memory_hits": [],
|
||||||
|
"introspection": nexus_introspector.snapshot().to_dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ts = _ts()
|
ts = _ts()
|
||||||
|
|
||||||
# Fetch semantically relevant memories to surface in the sidebar
|
# Fetch semantically relevant memories
|
||||||
try:
|
try:
|
||||||
memory_hits = await asyncio.to_thread(search_memories, query=message, limit=4)
|
memory_hits = await asyncio.to_thread(search_memories, query=message, limit=4)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Nexus memory search failed: %s", exc)
|
logger.warning("Nexus memory search failed: %s", exc)
|
||||||
memory_hits = []
|
memory_hits = []
|
||||||
|
|
||||||
|
# Track memory hits for analytics
|
||||||
|
nexus_introspector.record_memory_hits(len(memory_hits))
|
||||||
|
|
||||||
# Conversational response — no tool approval flow
|
# Conversational response — no tool approval flow
|
||||||
response_text: str | None = None
|
response_text: str | None = None
|
||||||
error_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:
|
if response_text:
|
||||||
_append_log("assistant", 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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/nexus_message.html",
|
"partials/nexus_message.html",
|
||||||
@@ -122,10 +194,14 @@ async def nexus_chat(request: Request, message: str = Form(...)):
|
|||||||
"error": error_text,
|
"error": error_text,
|
||||||
"timestamp": ts,
|
"timestamp": ts,
|
||||||
"memory_hits": memory_hits,
|
"memory_hits": memory_hits,
|
||||||
|
"introspection": introspection.to_dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Teach route ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.post("/teach", response_class=HTMLResponse)
|
@router.post("/teach", response_class=HTMLResponse)
|
||||||
async def nexus_teach(request: Request, fact: str = Form(...)):
|
async def nexus_teach(request: Request, fact: str = Form(...)):
|
||||||
"""Inject a fact into Timmy's live memory from the Nexus teaching panel."""
|
"""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)
|
@router.delete("/history", response_class=HTMLResponse)
|
||||||
async def nexus_clear_history(request: Request):
|
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()
|
_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)
|
reset_session(session_id=_NEXUS_SESSION_ID)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/nexus_message.html",
|
"partials/nexus_message.html",
|
||||||
@@ -162,5 +247,55 @@ async def nexus_clear_history(request: Request):
|
|||||||
"error": None,
|
"error": None,
|
||||||
"timestamp": _ts(),
|
"timestamp": _ts(),
|
||||||
"memory_hits": [],
|
"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))
|
||||||
|
|||||||
@@ -8,26 +8,40 @@
|
|||||||
<div class="container-fluid nexus-layout py-3">
|
<div class="container-fluid nexus-layout py-3">
|
||||||
|
|
||||||
<div class="nexus-header mb-3">
|
<div class="nexus-header mb-3">
|
||||||
<div class="nexus-title">// NEXUS</div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="nexus-subtitle">
|
<div>
|
||||||
Persistent conversational awareness — always present, always learning.
|
<div class="nexus-title">// NEXUS</div>
|
||||||
|
<div class="nexus-subtitle">
|
||||||
|
Persistent conversational awareness — always present, always learning.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sovereignty Pulse badge -->
|
||||||
|
<div class="nexus-pulse-badge" id="nexus-pulse-badge">
|
||||||
|
<span class="nexus-pulse-dot nexus-pulse-{{ pulse.health }}"></span>
|
||||||
|
<span class="nexus-pulse-label">SOVEREIGNTY</span>
|
||||||
|
<span class="nexus-pulse-value" id="pulse-overall">{{ pulse.overall_pct }}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nexus-grid">
|
<div class="nexus-grid-v2">
|
||||||
|
|
||||||
<!-- ── LEFT: Conversation ────────────────────────────────── -->
|
<!-- ── LEFT: Conversation ────────────────────────────────── -->
|
||||||
<div class="nexus-chat-col">
|
<div class="nexus-chat-col">
|
||||||
<div class="card mc-panel nexus-chat-panel">
|
<div class="card mc-panel nexus-chat-panel">
|
||||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||||
<span>// CONVERSATION</span>
|
<span>// CONVERSATION</span>
|
||||||
<button class="mc-btn mc-btn-sm"
|
<div class="d-flex align-items-center gap-2">
|
||||||
hx-delete="/nexus/history"
|
<span class="nexus-msg-count" id="nexus-msg-count"
|
||||||
hx-target="#nexus-chat-log"
|
title="Messages in this session">{{ messages|length }} msgs</span>
|
||||||
hx-swap="beforeend"
|
<button class="mc-btn mc-btn-sm"
|
||||||
hx-confirm="Clear nexus conversation?">
|
hx-delete="/nexus/history"
|
||||||
CLEAR
|
hx-target="#nexus-chat-log"
|
||||||
</button>
|
hx-swap="beforeend"
|
||||||
|
hx-confirm="Clear nexus conversation?">
|
||||||
|
CLEAR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body p-2" id="nexus-chat-log">
|
<div class="card-body p-2" id="nexus-chat-log">
|
||||||
@@ -67,14 +81,115 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── RIGHT: Memory sidebar ─────────────────────────────── -->
|
<!-- ── RIGHT: Awareness sidebar ──────────────────────────── -->
|
||||||
<div class="nexus-sidebar-col">
|
<div class="nexus-sidebar-col">
|
||||||
|
|
||||||
<!-- Live memory context (updated with each response) -->
|
<!-- Cognitive State Panel -->
|
||||||
|
<div class="card mc-panel nexus-cognitive-panel mb-3">
|
||||||
|
<div class="card-header mc-panel-header">
|
||||||
|
<span>// COGNITIVE STATE</span>
|
||||||
|
<span class="nexus-engagement-badge" id="cog-engagement">
|
||||||
|
{{ introspection.cognitive.engagement | upper }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="nexus-cog-grid">
|
||||||
|
<div class="nexus-cog-item">
|
||||||
|
<div class="nexus-cog-label">MOOD</div>
|
||||||
|
<div class="nexus-cog-value" id="cog-mood">{{ introspection.cognitive.mood }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-cog-item">
|
||||||
|
<div class="nexus-cog-label">FOCUS</div>
|
||||||
|
<div class="nexus-cog-value nexus-cog-focus" id="cog-focus">
|
||||||
|
{{ introspection.cognitive.focus_topic or '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-cog-item">
|
||||||
|
<div class="nexus-cog-label">DEPTH</div>
|
||||||
|
<div class="nexus-cog-value" id="cog-depth">{{ introspection.cognitive.conversation_depth }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-cog-item">
|
||||||
|
<div class="nexus-cog-label">INITIATIVE</div>
|
||||||
|
<div class="nexus-cog-value nexus-cog-focus" id="cog-initiative">
|
||||||
|
{{ introspection.cognitive.last_initiative or '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if introspection.cognitive.active_commitments %}
|
||||||
|
<div class="nexus-commitments mt-2">
|
||||||
|
<div class="nexus-cog-label">ACTIVE COMMITMENTS</div>
|
||||||
|
{% for c in introspection.cognitive.active_commitments %}
|
||||||
|
<div class="nexus-commitment-item">{{ c | e }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Thoughts Panel -->
|
||||||
|
<div class="card mc-panel nexus-thoughts-panel mb-3">
|
||||||
|
<div class="card-header mc-panel-header">
|
||||||
|
<span>// THOUGHT STREAM</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2" id="nexus-thoughts-body">
|
||||||
|
{% if introspection.recent_thoughts %}
|
||||||
|
{% for t in introspection.recent_thoughts %}
|
||||||
|
<div class="nexus-thought-item">
|
||||||
|
<div class="nexus-thought-meta">
|
||||||
|
<span class="nexus-thought-seed">{{ t.seed_type }}</span>
|
||||||
|
<span class="nexus-thought-time">{{ t.created_at[:16] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-thought-content">{{ t.content | e }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="nexus-empty-state">No thoughts yet. The thinking engine will populate this.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sovereignty Pulse Detail -->
|
||||||
|
<div class="card mc-panel nexus-sovereignty-panel mb-3">
|
||||||
|
<div class="card-header mc-panel-header">
|
||||||
|
<span>// SOVEREIGNTY PULSE</span>
|
||||||
|
<span class="nexus-health-badge nexus-health-{{ pulse.health }}" id="pulse-health">
|
||||||
|
{{ pulse.health | upper }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="nexus-pulse-meters" id="nexus-pulse-meters">
|
||||||
|
{% for layer in pulse.layers %}
|
||||||
|
<div class="nexus-pulse-layer">
|
||||||
|
<div class="nexus-pulse-layer-label">{{ layer.name | upper }}</div>
|
||||||
|
<div class="nexus-pulse-bar-track">
|
||||||
|
<div class="nexus-pulse-bar-fill" style="width: {{ layer.sovereign_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-pulse-layer-pct">{{ layer.sovereign_pct }}%</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="nexus-pulse-stats mt-2">
|
||||||
|
<div class="nexus-pulse-stat">
|
||||||
|
<span class="nexus-pulse-stat-label">Crystallizations</span>
|
||||||
|
<span class="nexus-pulse-stat-value" id="pulse-cryst">{{ pulse.crystallizations_last_hour }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-pulse-stat">
|
||||||
|
<span class="nexus-pulse-stat-label">API Independence</span>
|
||||||
|
<span class="nexus-pulse-stat-value" id="pulse-api-indep">{{ pulse.api_independence_pct }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-pulse-stat">
|
||||||
|
<span class="nexus-pulse-stat-label">Total Events</span>
|
||||||
|
<span class="nexus-pulse-stat-value" id="pulse-events">{{ pulse.total_events }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Memory Context -->
|
||||||
<div class="card mc-panel nexus-memory-panel mb-3">
|
<div class="card mc-panel nexus-memory-panel mb-3">
|
||||||
<div class="card-header mc-panel-header">
|
<div class="card-header mc-panel-header">
|
||||||
<span>// LIVE MEMORY</span>
|
<span>// LIVE MEMORY</span>
|
||||||
<span class="badge ms-2" style="background:var(--purple-dim); color:var(--purple);">
|
<span class="badge ms-2" style="background:var(--purple-dim, rgba(168,85,247,0.15)); color:var(--purple);">
|
||||||
{{ stats.total_entries }} stored
|
{{ stats.total_entries }} stored
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +200,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Teaching panel -->
|
<!-- Session Analytics -->
|
||||||
|
<div class="card mc-panel nexus-analytics-panel mb-3">
|
||||||
|
<div class="card-header mc-panel-header">// SESSION ANALYTICS</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="nexus-analytics-grid" id="nexus-analytics">
|
||||||
|
<div class="nexus-analytics-item">
|
||||||
|
<span class="nexus-analytics-label">Messages</span>
|
||||||
|
<span class="nexus-analytics-value" id="analytics-msgs">{{ introspection.analytics.total_messages }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-analytics-item">
|
||||||
|
<span class="nexus-analytics-label">Avg Response</span>
|
||||||
|
<span class="nexus-analytics-value" id="analytics-avg">{{ introspection.analytics.avg_response_length }} chars</span>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-analytics-item">
|
||||||
|
<span class="nexus-analytics-label">Memory Hits</span>
|
||||||
|
<span class="nexus-analytics-value" id="analytics-mem">{{ introspection.analytics.memory_hits_total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="nexus-analytics-item">
|
||||||
|
<span class="nexus-analytics-label">Duration</span>
|
||||||
|
<span class="nexus-analytics-value" id="analytics-dur">{{ introspection.analytics.session_duration_minutes }} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Teaching Panel -->
|
||||||
<div class="card mc-panel nexus-teach-panel">
|
<div class="card mc-panel nexus-teach-panel">
|
||||||
<div class="card-header mc-panel-header">// TEACH TIMMY</div>
|
<div class="card-header mc-panel-header">// TEACH TIMMY</div>
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
@@ -119,4 +259,128 @@
|
|||||||
</div><!-- /nexus-grid -->
|
</div><!-- /nexus-grid -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- WebSocket for live Nexus updates -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
var wsUrl = wsProto + '//' + location.host + '/nexus/ws';
|
||||||
|
var ws = null;
|
||||||
|
var reconnectDelay = 2000;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
ws.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'nexus_state') {
|
||||||
|
updateCognitive(data.introspection.cognitive);
|
||||||
|
updateThoughts(data.introspection.recent_thoughts);
|
||||||
|
updateAnalytics(data.introspection.analytics);
|
||||||
|
updatePulse(data.sovereignty_pulse);
|
||||||
|
}
|
||||||
|
} catch(err) { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
ws.onclose = function() {
|
||||||
|
setTimeout(connect, reconnectDelay);
|
||||||
|
};
|
||||||
|
ws.onerror = function() { ws.close(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCognitive(c) {
|
||||||
|
var el;
|
||||||
|
el = document.getElementById('cog-mood');
|
||||||
|
if (el) el.textContent = c.mood;
|
||||||
|
el = document.getElementById('cog-engagement');
|
||||||
|
if (el) el.textContent = c.engagement.toUpperCase();
|
||||||
|
el = document.getElementById('cog-focus');
|
||||||
|
if (el) el.textContent = c.focus_topic || '\u2014';
|
||||||
|
el = document.getElementById('cog-depth');
|
||||||
|
if (el) el.textContent = c.conversation_depth;
|
||||||
|
el = document.getElementById('cog-initiative');
|
||||||
|
if (el) el.textContent = c.last_initiative || '\u2014';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThoughts(thoughts) {
|
||||||
|
var container = document.getElementById('nexus-thoughts-body');
|
||||||
|
if (!container || !thoughts || thoughts.length === 0) return;
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < thoughts.length; i++) {
|
||||||
|
var t = thoughts[i];
|
||||||
|
html += '<div class="nexus-thought-item">'
|
||||||
|
+ '<div class="nexus-thought-meta">'
|
||||||
|
+ '<span class="nexus-thought-seed">' + escHtml(t.seed_type) + '</span>'
|
||||||
|
+ '<span class="nexus-thought-time">' + escHtml((t.created_at || '').substring(0,16)) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="nexus-thought-content">' + escHtml(t.content) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAnalytics(a) {
|
||||||
|
var el;
|
||||||
|
el = document.getElementById('analytics-msgs');
|
||||||
|
if (el) el.textContent = a.total_messages;
|
||||||
|
el = document.getElementById('analytics-avg');
|
||||||
|
if (el) el.textContent = a.avg_response_length + ' chars';
|
||||||
|
el = document.getElementById('analytics-mem');
|
||||||
|
if (el) el.textContent = a.memory_hits_total;
|
||||||
|
el = document.getElementById('analytics-dur');
|
||||||
|
if (el) el.textContent = a.session_duration_minutes + ' min';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePulse(p) {
|
||||||
|
var el;
|
||||||
|
el = document.getElementById('pulse-overall');
|
||||||
|
if (el) el.textContent = p.overall_pct + '%';
|
||||||
|
el = document.getElementById('pulse-health');
|
||||||
|
if (el) {
|
||||||
|
el.textContent = p.health.toUpperCase();
|
||||||
|
el.className = 'nexus-health-badge nexus-health-' + p.health;
|
||||||
|
}
|
||||||
|
el = document.getElementById('pulse-cryst');
|
||||||
|
if (el) el.textContent = p.crystallizations_last_hour;
|
||||||
|
el = document.getElementById('pulse-api-indep');
|
||||||
|
if (el) el.textContent = p.api_independence_pct + '%';
|
||||||
|
el = document.getElementById('pulse-events');
|
||||||
|
if (el) el.textContent = p.total_events;
|
||||||
|
|
||||||
|
// Update pulse badge dot
|
||||||
|
var badge = document.getElementById('nexus-pulse-badge');
|
||||||
|
if (badge) {
|
||||||
|
var dot = badge.querySelector('.nexus-pulse-dot');
|
||||||
|
if (dot) {
|
||||||
|
dot.className = 'nexus-pulse-dot nexus-pulse-' + p.health;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update layer bars
|
||||||
|
var meters = document.getElementById('nexus-pulse-meters');
|
||||||
|
if (meters && p.layers) {
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < p.layers.length; i++) {
|
||||||
|
var l = p.layers[i];
|
||||||
|
html += '<div class="nexus-pulse-layer">'
|
||||||
|
+ '<div class="nexus-pulse-layer-label">' + escHtml(l.name.toUpperCase()) + '</div>'
|
||||||
|
+ '<div class="nexus-pulse-bar-track">'
|
||||||
|
+ '<div class="nexus-pulse-bar-fill" style="width:' + l.sovereign_pct + '%"></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="nexus-pulse-layer-pct">' + l.sovereign_pct + '%</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
meters.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
15
src/timmy/nexus/__init__.py
Normal file
15
src/timmy/nexus/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Nexus subsystem — Timmy's sovereign conversational awareness space.
|
||||||
|
|
||||||
|
Extends the Nexus v1 chat interface with:
|
||||||
|
|
||||||
|
- **Introspection engine** — real-time cognitive state, thought-stream
|
||||||
|
integration, and session analytics surfaced directly in the Nexus.
|
||||||
|
- **Persistent sessions** — SQLite-backed conversation history that
|
||||||
|
survives process restarts.
|
||||||
|
- **Sovereignty pulse** — a live dashboard-within-dashboard showing
|
||||||
|
Timmy's sovereignty health, crystallization rate, and API independence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from timmy.nexus.introspection import NexusIntrospector # noqa: F401
|
||||||
|
from timmy.nexus.persistence import NexusStore # noqa: F401
|
||||||
|
from timmy.nexus.sovereignty_pulse import SovereigntyPulse # noqa: F401
|
||||||
236
src/timmy/nexus/introspection.py
Normal file
236
src/timmy/nexus/introspection.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Nexus Introspection Engine — cognitive self-awareness for Timmy.
|
||||||
|
|
||||||
|
Aggregates live signals from the CognitiveTracker, ThinkingEngine, and
|
||||||
|
MemorySystem into a unified introspection snapshot. The Nexus template
|
||||||
|
renders this as an always-visible cognitive state panel so the operator
|
||||||
|
can observe Timmy's inner life in real time.
|
||||||
|
|
||||||
|
Design principles:
|
||||||
|
- Read-only observer — never mutates cognitive state.
|
||||||
|
- Graceful degradation — if any upstream is unavailable, the snapshot
|
||||||
|
still returns with partial data instead of crashing.
|
||||||
|
- JSON-serializable — every method returns plain dicts ready for
|
||||||
|
WebSocket push or Jinja2 template rendering.
|
||||||
|
|
||||||
|
Refs: #1090 (Nexus Epic), architecture-v2.md §Intelligence Surface
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data models ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CognitiveSummary:
|
||||||
|
"""Distilled view of Timmy's current cognitive state."""
|
||||||
|
|
||||||
|
mood: str = "settled"
|
||||||
|
engagement: str = "idle"
|
||||||
|
focus_topic: str | None = None
|
||||||
|
conversation_depth: int = 0
|
||||||
|
active_commitments: list[str] = field(default_factory=list)
|
||||||
|
last_initiative: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ThoughtSummary:
|
||||||
|
"""Compact representation of a single thought for the Nexus viewer."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
content: str
|
||||||
|
seed_type: str
|
||||||
|
created_at: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionAnalytics:
|
||||||
|
"""Conversation-level analytics for the active Nexus session."""
|
||||||
|
|
||||||
|
total_messages: int = 0
|
||||||
|
user_messages: int = 0
|
||||||
|
assistant_messages: int = 0
|
||||||
|
avg_response_length: float = 0.0
|
||||||
|
topics_discussed: list[str] = field(default_factory=list)
|
||||||
|
session_start: str | None = None
|
||||||
|
session_duration_minutes: float = 0.0
|
||||||
|
memory_hits_total: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntrospectionSnapshot:
|
||||||
|
"""Everything the Nexus template needs to render the cognitive panel."""
|
||||||
|
|
||||||
|
cognitive: CognitiveSummary = field(default_factory=CognitiveSummary)
|
||||||
|
recent_thoughts: list[ThoughtSummary] = field(default_factory=list)
|
||||||
|
analytics: SessionAnalytics = field(default_factory=SessionAnalytics)
|
||||||
|
timestamp: str = field(
|
||||||
|
default_factory=lambda: datetime.now(UTC).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"cognitive": self.cognitive.to_dict(),
|
||||||
|
"recent_thoughts": [t.to_dict() for t in self.recent_thoughts],
|
||||||
|
"analytics": self.analytics.to_dict(),
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Introspector ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NexusIntrospector:
|
||||||
|
"""Aggregates cognitive signals into a single introspection snapshot.
|
||||||
|
|
||||||
|
Lazily pulls from:
|
||||||
|
- ``timmy.cognitive_state.cognitive_tracker``
|
||||||
|
- ``timmy.thinking.thinking_engine``
|
||||||
|
- Nexus conversation log (passed in to avoid circular import)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._session_start: datetime | None = None
|
||||||
|
self._topics: list[str] = []
|
||||||
|
self._memory_hit_count: int = 0
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def snapshot(
|
||||||
|
self,
|
||||||
|
conversation_log: list[dict] | None = None,
|
||||||
|
) -> IntrospectionSnapshot:
|
||||||
|
"""Build a complete introspection snapshot.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
conversation_log:
|
||||||
|
The in-memory ``_nexus_log`` from the routes module (list of
|
||||||
|
dicts with ``role``, ``content``, ``timestamp`` keys).
|
||||||
|
"""
|
||||||
|
return IntrospectionSnapshot(
|
||||||
|
cognitive=self._read_cognitive(),
|
||||||
|
recent_thoughts=self._read_thoughts(),
|
||||||
|
analytics=self._compute_analytics(conversation_log or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_memory_hits(self, count: int) -> None:
|
||||||
|
"""Track cumulative memory hits for session analytics."""
|
||||||
|
self._memory_hit_count += count
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset session-scoped analytics (e.g. on history clear)."""
|
||||||
|
self._session_start = None
|
||||||
|
self._topics.clear()
|
||||||
|
self._memory_hit_count = 0
|
||||||
|
|
||||||
|
# ── Cognitive state reader ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_cognitive(self) -> CognitiveSummary:
|
||||||
|
"""Pull current state from the CognitiveTracker singleton."""
|
||||||
|
try:
|
||||||
|
from timmy.cognitive_state import cognitive_tracker
|
||||||
|
|
||||||
|
state = cognitive_tracker.get_state()
|
||||||
|
return CognitiveSummary(
|
||||||
|
mood=state.mood,
|
||||||
|
engagement=state.engagement,
|
||||||
|
focus_topic=state.focus_topic,
|
||||||
|
conversation_depth=state.conversation_depth,
|
||||||
|
active_commitments=list(state.active_commitments),
|
||||||
|
last_initiative=state.last_initiative,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Introspection: cognitive state unavailable: %s", exc)
|
||||||
|
return CognitiveSummary()
|
||||||
|
|
||||||
|
# ── Thought stream reader ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_thoughts(self, limit: int = 5) -> list[ThoughtSummary]:
|
||||||
|
"""Pull recent thoughts from the ThinkingEngine."""
|
||||||
|
try:
|
||||||
|
from timmy.thinking import thinking_engine
|
||||||
|
|
||||||
|
thoughts = thinking_engine.get_recent_thoughts(limit=limit)
|
||||||
|
return [
|
||||||
|
ThoughtSummary(
|
||||||
|
id=t.id,
|
||||||
|
content=(
|
||||||
|
t.content[:200] + "…" if len(t.content) > 200 else t.content
|
||||||
|
),
|
||||||
|
seed_type=t.seed_type,
|
||||||
|
created_at=t.created_at,
|
||||||
|
parent_id=t.parent_id,
|
||||||
|
)
|
||||||
|
for t in thoughts
|
||||||
|
]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Introspection: thought stream unavailable: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ── Session analytics ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _compute_analytics(
|
||||||
|
self, conversation_log: list[dict]
|
||||||
|
) -> SessionAnalytics:
|
||||||
|
"""Derive analytics from the Nexus conversation log."""
|
||||||
|
if not conversation_log:
|
||||||
|
return SessionAnalytics()
|
||||||
|
|
||||||
|
if self._session_start is None:
|
||||||
|
self._session_start = datetime.now(UTC)
|
||||||
|
|
||||||
|
user_msgs = [m for m in conversation_log if m.get("role") == "user"]
|
||||||
|
asst_msgs = [
|
||||||
|
m for m in conversation_log if m.get("role") == "assistant"
|
||||||
|
]
|
||||||
|
|
||||||
|
avg_len = 0.0
|
||||||
|
if asst_msgs:
|
||||||
|
total_chars = sum(len(m.get("content", "")) for m in asst_msgs)
|
||||||
|
avg_len = total_chars / len(asst_msgs)
|
||||||
|
|
||||||
|
# Extract topics from user messages (simple: first 40 chars)
|
||||||
|
topics = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for m in user_msgs:
|
||||||
|
topic = m.get("content", "")[:40].strip()
|
||||||
|
if topic and topic.lower() not in seen:
|
||||||
|
topics.append(topic)
|
||||||
|
seen.add(topic.lower())
|
||||||
|
# Keep last 8 topics
|
||||||
|
topics = topics[-8:]
|
||||||
|
|
||||||
|
elapsed = (datetime.now(UTC) - self._session_start).total_seconds() / 60
|
||||||
|
|
||||||
|
return SessionAnalytics(
|
||||||
|
total_messages=len(conversation_log),
|
||||||
|
user_messages=len(user_msgs),
|
||||||
|
assistant_messages=len(asst_msgs),
|
||||||
|
avg_response_length=round(avg_len, 1),
|
||||||
|
topics_discussed=topics,
|
||||||
|
session_start=self._session_start.strftime("%H:%M:%S"),
|
||||||
|
session_duration_minutes=round(elapsed, 1),
|
||||||
|
memory_hits_total=self._memory_hit_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module singleton ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
nexus_introspector = NexusIntrospector()
|
||||||
230
src/timmy/nexus/persistence.py
Normal file
230
src/timmy/nexus/persistence.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Nexus Session Persistence — durable conversation history.
|
||||||
|
|
||||||
|
The v1 Nexus kept conversations in a Python ``list`` that vanished on
|
||||||
|
every process restart. This module provides a SQLite-backed store so
|
||||||
|
Nexus conversations survive reboots while remaining fully local.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
nexus_messages(id, role, content, timestamp, session_tag)
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
- One table, one DB file (``data/nexus.db``). Cheap, portable, sovereign.
|
||||||
|
- ``session_tag`` enables future per-operator sessions (#1090 deferred scope).
|
||||||
|
- Bounded history: ``MAX_MESSAGES`` rows per session tag. Oldest are pruned
|
||||||
|
automatically on insert.
|
||||||
|
- Thread-safe via SQLite WAL mode + module-level singleton.
|
||||||
|
|
||||||
|
Refs: #1090 (Nexus Epic — session persistence), architecture-v2.md §Data Layer
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Defaults ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DEFAULT_DB_DIR = Path("data")
|
||||||
|
DB_PATH: Path = _DEFAULT_DB_DIR / "nexus.db"
|
||||||
|
|
||||||
|
MAX_MESSAGES = 500 # per session tag
|
||||||
|
DEFAULT_SESSION_TAG = "nexus"
|
||||||
|
|
||||||
|
# ── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SCHEMA = """\
|
||||||
|
CREATE TABLE IF NOT EXISTS nexus_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
session_tag TEXT NOT NULL DEFAULT 'nexus'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nexus_session ON nexus_messages(session_tag);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nexus_ts ON nexus_messages(timestamp);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Typed dict for rows ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NexusMessage(TypedDict):
|
||||||
|
id: int
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
timestamp: str
|
||||||
|
session_tag: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Store ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class NexusStore:
|
||||||
|
"""SQLite-backed persistence for Nexus conversations.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
store = NexusStore() # uses module-level DB_PATH
|
||||||
|
store.append("user", "hi")
|
||||||
|
msgs = store.get_history() # → list[NexusMessage]
|
||||||
|
store.clear() # wipe session
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path | None = None) -> None:
|
||||||
|
self._db_path = db_path or DB_PATH
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
# ── Connection management ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = sqlite3.connect(
|
||||||
|
str(self._db_path),
|
||||||
|
check_same_thread=False,
|
||||||
|
)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._conn.executescript(_SCHEMA)
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the underlying connection (idempotent)."""
|
||||||
|
if self._conn is not None:
|
||||||
|
try:
|
||||||
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
# ── Write ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
timestamp: str | None = None,
|
||||||
|
session_tag: str = DEFAULT_SESSION_TAG,
|
||||||
|
) -> int:
|
||||||
|
"""Insert a message and return its row id.
|
||||||
|
|
||||||
|
Automatically prunes oldest messages when the session exceeds
|
||||||
|
``MAX_MESSAGES``.
|
||||||
|
"""
|
||||||
|
ts = timestamp or datetime.now(UTC).strftime("%H:%M:%S")
|
||||||
|
conn = self._get_conn()
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO nexus_messages (role, content, timestamp, session_tag) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(role, content, ts, session_tag),
|
||||||
|
)
|
||||||
|
row_id: int = cur.lastrowid # type: ignore[assignment]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Prune
|
||||||
|
self._prune(session_tag)
|
||||||
|
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
def _prune(self, session_tag: str) -> None:
|
||||||
|
"""Remove oldest rows that exceed MAX_MESSAGES for *session_tag*."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM nexus_messages WHERE session_tag = ?",
|
||||||
|
(session_tag,),
|
||||||
|
)
|
||||||
|
count = cur.fetchone()[0]
|
||||||
|
if count > MAX_MESSAGES:
|
||||||
|
excess = count - MAX_MESSAGES
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM nexus_messages WHERE id IN ("
|
||||||
|
" SELECT id FROM nexus_messages "
|
||||||
|
" WHERE session_tag = ? ORDER BY id ASC LIMIT ?"
|
||||||
|
")",
|
||||||
|
(session_tag, excess),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# ── Read ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_history(
|
||||||
|
self,
|
||||||
|
session_tag: str = DEFAULT_SESSION_TAG,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[NexusMessage]:
|
||||||
|
"""Return the most recent *limit* messages for *session_tag*.
|
||||||
|
|
||||||
|
Results are ordered oldest-first (ascending id).
|
||||||
|
"""
|
||||||
|
conn = self._get_conn()
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, role, content, timestamp, session_tag "
|
||||||
|
"FROM nexus_messages "
|
||||||
|
"WHERE session_tag = ? "
|
||||||
|
"ORDER BY id DESC LIMIT ?",
|
||||||
|
(session_tag, limit),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# Reverse to chronological order
|
||||||
|
messages: list[NexusMessage] = [
|
||||||
|
NexusMessage(
|
||||||
|
id=r["id"],
|
||||||
|
role=r["role"],
|
||||||
|
content=r["content"],
|
||||||
|
timestamp=r["timestamp"],
|
||||||
|
session_tag=r["session_tag"],
|
||||||
|
)
|
||||||
|
for r in reversed(rows)
|
||||||
|
]
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def message_count(
|
||||||
|
self, session_tag: str = DEFAULT_SESSION_TAG
|
||||||
|
) -> int:
|
||||||
|
"""Return total message count for *session_tag*."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM nexus_messages WHERE session_tag = ?",
|
||||||
|
(session_tag,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
|
||||||
|
# ── Delete ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def clear(self, session_tag: str = DEFAULT_SESSION_TAG) -> int:
|
||||||
|
"""Delete all messages for *session_tag*. Returns count deleted."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM nexus_messages WHERE session_tag = ?",
|
||||||
|
(session_tag,),
|
||||||
|
)
|
||||||
|
deleted: int = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def clear_all(self) -> int:
|
||||||
|
"""Delete every message across all session tags."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
with closing(conn.cursor()) as cur:
|
||||||
|
cur.execute("DELETE FROM nexus_messages")
|
||||||
|
deleted: int = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module singleton ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
nexus_store = NexusStore()
|
||||||
153
src/timmy/nexus/sovereignty_pulse.py
Normal file
153
src/timmy/nexus/sovereignty_pulse.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Sovereignty Pulse — real-time sovereignty health for the Nexus.
|
||||||
|
|
||||||
|
Reads from the ``SovereigntyMetricsStore`` (created in PR #1331) and
|
||||||
|
distils it into a compact "pulse" that the Nexus template can render
|
||||||
|
as a persistent health badge.
|
||||||
|
|
||||||
|
The pulse answers one question at a glance: *how sovereign is Timmy
|
||||||
|
right now?*
|
||||||
|
|
||||||
|
Signals:
|
||||||
|
- Overall sovereignty percentage (0–100).
|
||||||
|
- Per-layer breakdown (perception, decision, narration).
|
||||||
|
- Crystallization velocity — new rules learned in the last hour.
|
||||||
|
- API independence — percentage of recent inferences served locally.
|
||||||
|
- Health rating (sovereign / degraded / dependent).
|
||||||
|
|
||||||
|
All methods return plain dicts — no imports leak into the template layer.
|
||||||
|
|
||||||
|
Refs: #953 (Sovereignty Loop), #954 (metrics), #1090 (Nexus epic)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Data model ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LayerPulse:
|
||||||
|
"""Sovereignty metrics for a single AI layer."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
sovereign_pct: float = 0.0
|
||||||
|
cache_hits: int = 0
|
||||||
|
model_calls: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SovereigntyPulseSnapshot:
|
||||||
|
"""Complete sovereignty health reading for the Nexus display."""
|
||||||
|
|
||||||
|
overall_pct: float = 0.0
|
||||||
|
health: str = "unknown" # sovereign | degraded | dependent | unknown
|
||||||
|
layers: list[LayerPulse] = field(default_factory=list)
|
||||||
|
crystallizations_last_hour: int = 0
|
||||||
|
api_independence_pct: float = 0.0
|
||||||
|
total_events: int = 0
|
||||||
|
timestamp: str = field(
|
||||||
|
default_factory=lambda: datetime.now(UTC).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"overall_pct": self.overall_pct,
|
||||||
|
"health": self.health,
|
||||||
|
"layers": [layer.to_dict() for layer in self.layers],
|
||||||
|
"crystallizations_last_hour": self.crystallizations_last_hour,
|
||||||
|
"api_independence_pct": self.api_independence_pct,
|
||||||
|
"total_events": self.total_events,
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pulse reader ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_health(pct: float) -> str:
|
||||||
|
"""Map overall sovereignty percentage to a human-readable health label."""
|
||||||
|
if pct >= 80.0:
|
||||||
|
return "sovereign"
|
||||||
|
if pct >= 50.0:
|
||||||
|
return "degraded"
|
||||||
|
if pct > 0.0:
|
||||||
|
return "dependent"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class SovereigntyPulse:
|
||||||
|
"""Reads sovereignty metrics and emits pulse snapshots.
|
||||||
|
|
||||||
|
Lazily imports from ``timmy.sovereignty.metrics`` so the Nexus
|
||||||
|
module has no hard compile-time dependency on the Sovereignty Loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def snapshot(self) -> SovereigntyPulseSnapshot:
|
||||||
|
"""Build a pulse snapshot from the live metrics store."""
|
||||||
|
try:
|
||||||
|
return self._read_metrics()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("SovereigntyPulse: metrics unavailable: %s", exc)
|
||||||
|
return SovereigntyPulseSnapshot()
|
||||||
|
|
||||||
|
def _read_metrics(self) -> SovereigntyPulseSnapshot:
|
||||||
|
"""Internal reader — allowed to raise if imports fail."""
|
||||||
|
from timmy.sovereignty.metrics import get_metrics_store
|
||||||
|
|
||||||
|
store = get_metrics_store()
|
||||||
|
snap = store.get_snapshot()
|
||||||
|
|
||||||
|
# Parse per-layer stats from the snapshot
|
||||||
|
layers = []
|
||||||
|
layer_pcts: list[float] = []
|
||||||
|
for layer_name in ("perception", "decision", "narration"):
|
||||||
|
layer_data = snap.get(layer_name, {})
|
||||||
|
hits = layer_data.get("cache_hits", 0)
|
||||||
|
calls = layer_data.get("model_calls", 0)
|
||||||
|
total = hits + calls
|
||||||
|
pct = (hits / total * 100) if total > 0 else 0.0
|
||||||
|
layers.append(
|
||||||
|
LayerPulse(
|
||||||
|
name=layer_name,
|
||||||
|
sovereign_pct=round(pct, 1),
|
||||||
|
cache_hits=hits,
|
||||||
|
model_calls=calls,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
layer_pcts.append(pct)
|
||||||
|
|
||||||
|
overall = round(sum(layer_pcts) / len(layer_pcts), 1) if layer_pcts else 0.0
|
||||||
|
|
||||||
|
# Crystallization count
|
||||||
|
cryst = snap.get("crystallizations", 0)
|
||||||
|
|
||||||
|
# API independence: cache_hits / total across all layers
|
||||||
|
total_hits = sum(layer.cache_hits for layer in layers)
|
||||||
|
total_calls = sum(layer.model_calls for layer in layers)
|
||||||
|
total_all = total_hits + total_calls
|
||||||
|
api_indep = round((total_hits / total_all * 100), 1) if total_all > 0 else 0.0
|
||||||
|
|
||||||
|
total_events = snap.get("total_events", 0)
|
||||||
|
|
||||||
|
return SovereigntyPulseSnapshot(
|
||||||
|
overall_pct=overall,
|
||||||
|
health=_classify_health(overall),
|
||||||
|
layers=layers,
|
||||||
|
crystallizations_last_hour=cryst,
|
||||||
|
api_independence_pct=api_indep,
|
||||||
|
total_events=total_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module singleton ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sovereignty_pulse = SovereigntyPulse()
|
||||||
@@ -2665,25 +2665,27 @@
|
|||||||
}
|
}
|
||||||
.vs-btn-save:hover { opacity: 0.85; }
|
.vs-btn-save:hover { opacity: 0.85; }
|
||||||
|
|
||||||
/* ── Nexus ────────────────────────────────────────────────── */
|
/* ── Nexus v2 ─────────────────────────────────────────────── */
|
||||||
.nexus-layout { max-width: 1400px; margin: 0 auto; }
|
.nexus-layout { max-width: 1600px; margin: 0 auto; }
|
||||||
|
|
||||||
.nexus-header { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
|
.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-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-subtitle { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.2rem; }
|
||||||
|
|
||||||
.nexus-grid {
|
/* v2 grid: wider sidebar for awareness panels */
|
||||||
|
.nexus-grid-v2 {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 320px;
|
grid-template-columns: 1fr 360px;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1000px) {
|
||||||
.nexus-grid { grid-template-columns: 1fr; }
|
.nexus-grid-v2 { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.nexus-chat-panel { height: calc(100vh - 180px); display: flex; flex-direction: column; }
|
.nexus-chat-panel { height: calc(100vh - 180px); display: flex; flex-direction: column; }
|
||||||
.nexus-chat-panel .card-body { overflow-y: auto; flex: 1; }
|
.nexus-chat-panel .card-body { overflow-y: auto; flex: 1; }
|
||||||
|
.nexus-msg-count { font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.05em; }
|
||||||
|
|
||||||
.nexus-empty-state {
|
.nexus-empty-state {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -2693,6 +2695,177 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar scrollable on short screens */
|
||||||
|
.nexus-sidebar-col { max-height: calc(100vh - 140px); overflow-y: auto; }
|
||||||
|
|
||||||
|
/* ── Sovereignty Pulse Badge (header) ── */
|
||||||
|
.nexus-pulse-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.nexus-pulse-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.nexus-pulse-dot.nexus-pulse-sovereign { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.nexus-pulse-dot.nexus-pulse-degraded { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
||||||
|
.nexus-pulse-dot.nexus-pulse-dependent { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||||
|
.nexus-pulse-dot.nexus-pulse-unknown { background: var(--text-dim); }
|
||||||
|
.nexus-pulse-label { color: var(--text-dim); }
|
||||||
|
.nexus-pulse-value { color: var(--text-bright); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Cognitive State Panel ── */
|
||||||
|
.nexus-cognitive-panel .card-body { font-size: 0.78rem; }
|
||||||
|
.nexus-engagement-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(168,85,247,0.12);
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
.nexus-cog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.nexus-cog-item {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
.nexus-cog-label {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
.nexus-cog-value {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.nexus-cog-focus {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
.nexus-commitments { font-size: 0.72rem; }
|
||||||
|
.nexus-commitment-item {
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
border-bottom: 1px solid rgba(59,26,92,0.4);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Thought Stream Panel ── */
|
||||||
|
.nexus-thoughts-panel .card-body { max-height: 200px; overflow-y: auto; }
|
||||||
|
.nexus-thought-item {
|
||||||
|
border-left: 2px solid var(--purple);
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
background: rgba(168,85,247,0.04);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
.nexus-thought-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.nexus-thought-seed {
|
||||||
|
color: var(--purple);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.nexus-thought-time { color: var(--text-dim); font-size: 0.62rem; }
|
||||||
|
.nexus-thought-content { color: var(--text); line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ── Sovereignty Pulse Detail Panel ── */
|
||||||
|
.nexus-health-badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.nexus-health-sovereign { background: rgba(0,232,122,0.12); color: var(--green); }
|
||||||
|
.nexus-health-degraded { background: rgba(255,184,0,0.12); color: var(--amber); }
|
||||||
|
.nexus-health-dependent { background: rgba(255,68,85,0.12); color: var(--red); }
|
||||||
|
.nexus-health-unknown { background: rgba(107,74,138,0.12); color: var(--text-dim); }
|
||||||
|
|
||||||
|
.nexus-pulse-layer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.nexus-pulse-layer-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 80px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
.nexus-pulse-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(59,26,92,0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.nexus-pulse-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--purple), var(--green));
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
.nexus-pulse-layer-pct {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-pulse-stats { font-size: 0.72rem; }
|
||||||
|
.nexus-pulse-stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
border-bottom: 1px solid rgba(59,26,92,0.3);
|
||||||
|
}
|
||||||
|
.nexus-pulse-stat-label { color: var(--text-dim); }
|
||||||
|
.nexus-pulse-stat-value { color: var(--text-bright); }
|
||||||
|
|
||||||
|
/* ── Session Analytics Panel ── */
|
||||||
|
.nexus-analytics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.nexus-analytics-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.nexus-analytics-label { color: var(--text-dim); }
|
||||||
|
.nexus-analytics-value { color: var(--text-bright); }
|
||||||
|
|
||||||
/* Memory sidebar */
|
/* Memory sidebar */
|
||||||
.nexus-memory-hits { font-size: 0.78rem; }
|
.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-label { color: var(--text-dim); font-size: 0.72rem; margin-bottom: 0.4rem; letter-spacing: 0.05em; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the Nexus conversational awareness routes."""
|
"""Tests for the Nexus v2 conversational awareness routes."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -24,6 +24,41 @@ def test_nexus_page_contains_teach_form(client):
|
|||||||
assert "/nexus/teach" in response.text
|
assert "/nexus/teach" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_cognitive_panel(client):
|
||||||
|
"""Nexus v2 page must include the cognitive state panel."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "COGNITIVE STATE" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_thought_stream(client):
|
||||||
|
"""Nexus v2 page must include the thought stream panel."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "THOUGHT STREAM" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_sovereignty_pulse(client):
|
||||||
|
"""Nexus v2 page must include the sovereignty pulse panel."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "SOVEREIGNTY PULSE" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_session_analytics(client):
|
||||||
|
"""Nexus v2 page must include the session analytics panel."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "SESSION ANALYTICS" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_page_contains_websocket_script(client):
|
||||||
|
"""Nexus v2 page must include the WebSocket connection script."""
|
||||||
|
response = client.get("/nexus")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "/nexus/ws" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_nexus_chat_empty_message_returns_empty(client):
|
def test_nexus_chat_empty_message_returns_empty(client):
|
||||||
"""POST /nexus/chat with blank message returns empty response."""
|
"""POST /nexus/chat with blank message returns empty response."""
|
||||||
response = client.post("/nexus/chat", data={"message": " "})
|
response = client.post("/nexus/chat", data={"message": " "})
|
||||||
@@ -72,3 +107,17 @@ def test_nexus_clear_history(client):
|
|||||||
response = client.request("DELETE", "/nexus/history")
|
response = client.request("DELETE", "/nexus/history")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "cleared" in response.text.lower()
|
assert "cleared" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nexus_introspect_api(client):
|
||||||
|
"""GET /nexus/introspect should return JSON introspection snapshot."""
|
||||||
|
response = client.get("/nexus/introspect")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "introspection" in data
|
||||||
|
assert "sovereignty_pulse" in data
|
||||||
|
assert "cognitive" in data["introspection"]
|
||||||
|
assert "recent_thoughts" in data["introspection"]
|
||||||
|
assert "analytics" in data["introspection"]
|
||||||
|
assert "overall_pct" in data["sovereignty_pulse"]
|
||||||
|
assert "health" in data["sovereignty_pulse"]
|
||||||
|
|||||||
0
tests/timmy/nexus/__init__.py
Normal file
0
tests/timmy/nexus/__init__.py
Normal file
199
tests/timmy/nexus/test_introspection.py
Normal file
199
tests/timmy/nexus/test_introspection.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for the Nexus Introspection Engine."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from timmy.nexus.introspection import (
|
||||||
|
CognitiveSummary,
|
||||||
|
IntrospectionSnapshot,
|
||||||
|
NexusIntrospector,
|
||||||
|
SessionAnalytics,
|
||||||
|
ThoughtSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Data model tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCognitiveSummary:
|
||||||
|
def test_defaults(self):
|
||||||
|
s = CognitiveSummary()
|
||||||
|
assert s.mood == "settled"
|
||||||
|
assert s.engagement == "idle"
|
||||||
|
assert s.focus_topic is None
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
s = CognitiveSummary(mood="curious", engagement="deep", focus_topic="architecture")
|
||||||
|
d = s.to_dict()
|
||||||
|
assert d["mood"] == "curious"
|
||||||
|
assert d["engagement"] == "deep"
|
||||||
|
assert d["focus_topic"] == "architecture"
|
||||||
|
|
||||||
|
|
||||||
|
class TestThoughtSummary:
|
||||||
|
def test_to_dict(self):
|
||||||
|
t = ThoughtSummary(
|
||||||
|
id="t1", content="Hello world", seed_type="freeform", created_at="2026-01-01"
|
||||||
|
)
|
||||||
|
d = t.to_dict()
|
||||||
|
assert d["id"] == "t1"
|
||||||
|
assert d["seed_type"] == "freeform"
|
||||||
|
assert d["parent_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionAnalytics:
|
||||||
|
def test_defaults(self):
|
||||||
|
a = SessionAnalytics()
|
||||||
|
assert a.total_messages == 0
|
||||||
|
assert a.avg_response_length == 0.0
|
||||||
|
assert a.topics_discussed == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntrospectionSnapshot:
|
||||||
|
def test_to_dict_structure(self):
|
||||||
|
snap = IntrospectionSnapshot()
|
||||||
|
d = snap.to_dict()
|
||||||
|
assert "cognitive" in d
|
||||||
|
assert "recent_thoughts" in d
|
||||||
|
assert "analytics" in d
|
||||||
|
assert "timestamp" in d
|
||||||
|
|
||||||
|
def test_to_dict_with_data(self):
|
||||||
|
snap = IntrospectionSnapshot(
|
||||||
|
cognitive=CognitiveSummary(mood="energized"),
|
||||||
|
recent_thoughts=[
|
||||||
|
ThoughtSummary(id="x", content="test", seed_type="s", created_at="now"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
d = snap.to_dict()
|
||||||
|
assert d["cognitive"]["mood"] == "energized"
|
||||||
|
assert len(d["recent_thoughts"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Introspector tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusIntrospector:
|
||||||
|
def test_snapshot_empty_log(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
snap = intro.snapshot(conversation_log=[])
|
||||||
|
assert isinstance(snap, IntrospectionSnapshot)
|
||||||
|
assert snap.analytics.total_messages == 0
|
||||||
|
|
||||||
|
def test_snapshot_with_messages(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
log = [
|
||||||
|
{"role": "user", "content": "hello", "timestamp": "10:00:00"},
|
||||||
|
{"role": "assistant", "content": "Hi there!", "timestamp": "10:00:01"},
|
||||||
|
{"role": "user", "content": "architecture question", "timestamp": "10:00:02"},
|
||||||
|
]
|
||||||
|
snap = intro.snapshot(conversation_log=log)
|
||||||
|
assert snap.analytics.total_messages == 3
|
||||||
|
assert snap.analytics.user_messages == 2
|
||||||
|
assert snap.analytics.assistant_messages == 1
|
||||||
|
assert snap.analytics.avg_response_length > 0
|
||||||
|
|
||||||
|
def test_record_memory_hits(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
intro.record_memory_hits(3)
|
||||||
|
intro.record_memory_hits(2)
|
||||||
|
snap = intro.snapshot(
|
||||||
|
conversation_log=[{"role": "user", "content": "x", "timestamp": "t"}]
|
||||||
|
)
|
||||||
|
assert snap.analytics.memory_hits_total == 5
|
||||||
|
|
||||||
|
def test_reset_clears_state(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
intro.record_memory_hits(10)
|
||||||
|
intro.reset()
|
||||||
|
snap = intro.snapshot(
|
||||||
|
conversation_log=[{"role": "user", "content": "x", "timestamp": "t"}]
|
||||||
|
)
|
||||||
|
assert snap.analytics.memory_hits_total == 0
|
||||||
|
|
||||||
|
def test_topics_deduplication(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
log = [
|
||||||
|
{"role": "user", "content": "hello", "timestamp": "t"},
|
||||||
|
{"role": "user", "content": "hello", "timestamp": "t"},
|
||||||
|
{"role": "user", "content": "different topic", "timestamp": "t"},
|
||||||
|
]
|
||||||
|
snap = intro.snapshot(conversation_log=log)
|
||||||
|
assert len(snap.analytics.topics_discussed) == 2
|
||||||
|
|
||||||
|
def test_topics_capped_at_8(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
log = [{"role": "user", "content": f"topic {i}", "timestamp": "t"} for i in range(15)]
|
||||||
|
snap = intro.snapshot(conversation_log=log)
|
||||||
|
assert len(snap.analytics.topics_discussed) <= 8
|
||||||
|
|
||||||
|
def test_cognitive_read_fallback(self):
|
||||||
|
"""If cognitive read fails, snapshot still works with defaults."""
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
# Patch the module-level import inside _read_cognitive
|
||||||
|
with patch.dict("sys.modules", {"timmy.cognitive_state": None}):
|
||||||
|
snap = intro.snapshot(conversation_log=[])
|
||||||
|
# Should not raise — fallback to default
|
||||||
|
assert snap.cognitive.mood == "settled"
|
||||||
|
|
||||||
|
def test_thoughts_read_fallback(self):
|
||||||
|
"""If thought read fails, snapshot still works with empty list."""
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
with patch.dict("sys.modules", {"timmy.thinking": None}):
|
||||||
|
snap = intro.snapshot(conversation_log=[])
|
||||||
|
assert snap.recent_thoughts == []
|
||||||
|
|
||||||
|
def test_read_cognitive_from_tracker(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
mock_state = MagicMock()
|
||||||
|
mock_state.mood = "curious"
|
||||||
|
mock_state.engagement = "deep"
|
||||||
|
mock_state.focus_topic = "sovereignty"
|
||||||
|
mock_state.conversation_depth = 5
|
||||||
|
mock_state.active_commitments = ["build something"]
|
||||||
|
mock_state.last_initiative = "build something"
|
||||||
|
|
||||||
|
mock_tracker = MagicMock()
|
||||||
|
mock_tracker.get_state.return_value = mock_state
|
||||||
|
|
||||||
|
with patch("timmy.cognitive_state.cognitive_tracker", mock_tracker):
|
||||||
|
summary = intro._read_cognitive()
|
||||||
|
|
||||||
|
assert summary.mood == "curious"
|
||||||
|
assert summary.engagement == "deep"
|
||||||
|
assert summary.focus_topic == "sovereignty"
|
||||||
|
assert summary.conversation_depth == 5
|
||||||
|
|
||||||
|
def test_read_thoughts_from_engine(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
mock_thought = MagicMock()
|
||||||
|
mock_thought.id = "t1"
|
||||||
|
mock_thought.content = "Deep thought about sovereignty"
|
||||||
|
mock_thought.seed_type = "existential"
|
||||||
|
mock_thought.created_at = "2026-03-23T10:00:00"
|
||||||
|
mock_thought.parent_id = None
|
||||||
|
|
||||||
|
mock_engine = MagicMock()
|
||||||
|
mock_engine.get_recent_thoughts.return_value = [mock_thought]
|
||||||
|
|
||||||
|
with patch("timmy.thinking.thinking_engine", mock_engine):
|
||||||
|
thoughts = intro._read_thoughts(limit=5)
|
||||||
|
|
||||||
|
assert len(thoughts) == 1
|
||||||
|
assert thoughts[0].id == "t1"
|
||||||
|
assert thoughts[0].seed_type == "existential"
|
||||||
|
|
||||||
|
def test_read_thoughts_truncates_long_content(self):
|
||||||
|
intro = NexusIntrospector()
|
||||||
|
mock_thought = MagicMock()
|
||||||
|
mock_thought.id = "t2"
|
||||||
|
mock_thought.content = "x" * 300
|
||||||
|
mock_thought.seed_type = "freeform"
|
||||||
|
mock_thought.created_at = "2026-03-23"
|
||||||
|
mock_thought.parent_id = None
|
||||||
|
|
||||||
|
mock_engine = MagicMock()
|
||||||
|
mock_engine.get_recent_thoughts.return_value = [mock_thought]
|
||||||
|
|
||||||
|
with patch("timmy.thinking.thinking_engine", mock_engine):
|
||||||
|
thoughts = intro._read_thoughts(limit=5)
|
||||||
|
|
||||||
|
assert len(thoughts[0].content) <= 201 # 200 + "…"
|
||||||
144
tests/timmy/nexus/test_persistence.py
Normal file
144
tests/timmy/nexus/test_persistence.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Tests for the Nexus Session Persistence store."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from timmy.nexus.persistence import MAX_MESSAGES, NexusStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
"""Provide a NexusStore backed by a temp database."""
|
||||||
|
db = tmp_path / "test_nexus.db"
|
||||||
|
s = NexusStore(db_path=db)
|
||||||
|
yield s
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStoreBasic:
|
||||||
|
def test_append_and_retrieve(self, store):
|
||||||
|
store.append("user", "hello")
|
||||||
|
store.append("assistant", "hi there")
|
||||||
|
history = store.get_history()
|
||||||
|
assert len(history) == 2
|
||||||
|
assert history[0]["role"] == "user"
|
||||||
|
assert history[0]["content"] == "hello"
|
||||||
|
assert history[1]["role"] == "assistant"
|
||||||
|
|
||||||
|
def test_message_count(self, store):
|
||||||
|
assert store.message_count() == 0
|
||||||
|
store.append("user", "a")
|
||||||
|
store.append("user", "b")
|
||||||
|
assert store.message_count() == 2
|
||||||
|
|
||||||
|
def test_custom_timestamp(self, store):
|
||||||
|
store.append("user", "msg", timestamp="12:34:56")
|
||||||
|
history = store.get_history()
|
||||||
|
assert history[0]["timestamp"] == "12:34:56"
|
||||||
|
|
||||||
|
def test_clear_session(self, store):
|
||||||
|
store.append("user", "a")
|
||||||
|
store.append("assistant", "b")
|
||||||
|
deleted = store.clear()
|
||||||
|
assert deleted == 2
|
||||||
|
assert store.message_count() == 0
|
||||||
|
|
||||||
|
def test_clear_empty_session(self, store):
|
||||||
|
deleted = store.clear()
|
||||||
|
assert deleted == 0
|
||||||
|
|
||||||
|
def test_clear_all(self, store):
|
||||||
|
store.append("user", "a", session_tag="s1")
|
||||||
|
store.append("user", "b", session_tag="s2")
|
||||||
|
deleted = store.clear_all()
|
||||||
|
assert deleted == 2
|
||||||
|
assert store.message_count(session_tag="s1") == 0
|
||||||
|
assert store.message_count(session_tag="s2") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStoreOrdering:
|
||||||
|
def test_chronological_order(self, store):
|
||||||
|
for i in range(5):
|
||||||
|
store.append("user", f"msg-{i}")
|
||||||
|
history = store.get_history()
|
||||||
|
contents = [m["content"] for m in history]
|
||||||
|
assert contents == ["msg-0", "msg-1", "msg-2", "msg-3", "msg-4"]
|
||||||
|
|
||||||
|
def test_limit_parameter(self, store):
|
||||||
|
for i in range(10):
|
||||||
|
store.append("user", f"msg-{i}")
|
||||||
|
history = store.get_history(limit=3)
|
||||||
|
assert len(history) == 3
|
||||||
|
# Should be the 3 most recent
|
||||||
|
assert history[0]["content"] == "msg-7"
|
||||||
|
assert history[2]["content"] == "msg-9"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStoreSessionTags:
|
||||||
|
def test_session_isolation(self, store):
|
||||||
|
store.append("user", "nexus-msg", session_tag="nexus")
|
||||||
|
store.append("user", "other-msg", session_tag="other")
|
||||||
|
nexus_history = store.get_history(session_tag="nexus")
|
||||||
|
other_history = store.get_history(session_tag="other")
|
||||||
|
assert len(nexus_history) == 1
|
||||||
|
assert len(other_history) == 1
|
||||||
|
assert nexus_history[0]["content"] == "nexus-msg"
|
||||||
|
|
||||||
|
def test_clear_only_affects_target_session(self, store):
|
||||||
|
store.append("user", "a", session_tag="s1")
|
||||||
|
store.append("user", "b", session_tag="s2")
|
||||||
|
store.clear(session_tag="s1")
|
||||||
|
assert store.message_count(session_tag="s1") == 0
|
||||||
|
assert store.message_count(session_tag="s2") == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStorePruning:
|
||||||
|
def test_prune_excess_messages(self, tmp_path):
|
||||||
|
"""Inserting beyond MAX_MESSAGES should prune oldest."""
|
||||||
|
db = tmp_path / "prune_test.db"
|
||||||
|
s = NexusStore(db_path=db)
|
||||||
|
# Insert MAX_MESSAGES + 5 to trigger pruning
|
||||||
|
for i in range(MAX_MESSAGES + 5):
|
||||||
|
s.append("user", f"msg-{i}")
|
||||||
|
assert s.message_count() == MAX_MESSAGES
|
||||||
|
# Get full history — oldest remaining should be msg-5
|
||||||
|
history = s.get_history(limit=MAX_MESSAGES)
|
||||||
|
assert history[0]["content"] == "msg-5"
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStoreReopen:
|
||||||
|
def test_data_survives_close_reopen(self, tmp_path):
|
||||||
|
"""Data persists across store instances (simulates process restart)."""
|
||||||
|
db = tmp_path / "reopen.db"
|
||||||
|
|
||||||
|
s1 = NexusStore(db_path=db)
|
||||||
|
s1.append("user", "persistent message")
|
||||||
|
s1.close()
|
||||||
|
|
||||||
|
s2 = NexusStore(db_path=db)
|
||||||
|
history = s2.get_history()
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0]["content"] == "persistent message"
|
||||||
|
s2.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStoreReturnedId:
|
||||||
|
def test_append_returns_row_id(self, store):
|
||||||
|
id1 = store.append("user", "first")
|
||||||
|
id2 = store.append("user", "second")
|
||||||
|
assert isinstance(id1, int)
|
||||||
|
assert id2 > id1
|
||||||
|
|
||||||
|
|
||||||
|
class TestNexusStoreClose:
|
||||||
|
def test_close_is_idempotent(self, store):
|
||||||
|
store.close()
|
||||||
|
store.close() # Should not raise
|
||||||
|
|
||||||
|
def test_operations_after_close_reconnect(self, store):
|
||||||
|
"""After close, next operation should reconnect automatically."""
|
||||||
|
store.append("user", "before close")
|
||||||
|
store.close()
|
||||||
|
# Should auto-reconnect
|
||||||
|
store.append("user", "after close")
|
||||||
|
assert store.message_count() == 2
|
||||||
151
tests/timmy/nexus/test_sovereignty_pulse.py
Normal file
151
tests/timmy/nexus/test_sovereignty_pulse.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Tests for the Sovereignty Pulse module."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from timmy.nexus.sovereignty_pulse import (
|
||||||
|
LayerPulse,
|
||||||
|
SovereigntyPulse,
|
||||||
|
SovereigntyPulseSnapshot,
|
||||||
|
_classify_health,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyHealth:
|
||||||
|
def test_sovereign(self):
|
||||||
|
assert _classify_health(95.0) == "sovereign"
|
||||||
|
assert _classify_health(80.0) == "sovereign"
|
||||||
|
|
||||||
|
def test_degraded(self):
|
||||||
|
assert _classify_health(79.9) == "degraded"
|
||||||
|
assert _classify_health(50.0) == "degraded"
|
||||||
|
|
||||||
|
def test_dependent(self):
|
||||||
|
assert _classify_health(49.9) == "dependent"
|
||||||
|
assert _classify_health(0.1) == "dependent"
|
||||||
|
|
||||||
|
def test_unknown(self):
|
||||||
|
assert _classify_health(0.0) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayerPulse:
|
||||||
|
def test_to_dict(self):
|
||||||
|
lp = LayerPulse(name="perception", sovereign_pct=75.0, cache_hits=15, model_calls=5)
|
||||||
|
d = lp.to_dict()
|
||||||
|
assert d["name"] == "perception"
|
||||||
|
assert d["sovereign_pct"] == 75.0
|
||||||
|
assert d["cache_hits"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
class TestSovereigntyPulseSnapshot:
|
||||||
|
def test_defaults(self):
|
||||||
|
snap = SovereigntyPulseSnapshot()
|
||||||
|
assert snap.overall_pct == 0.0
|
||||||
|
assert snap.health == "unknown"
|
||||||
|
assert snap.layers == []
|
||||||
|
|
||||||
|
def test_to_dict_structure(self):
|
||||||
|
snap = SovereigntyPulseSnapshot(
|
||||||
|
overall_pct=85.0,
|
||||||
|
health="sovereign",
|
||||||
|
layers=[LayerPulse(name="perception", sovereign_pct=90.0)],
|
||||||
|
crystallizations_last_hour=3,
|
||||||
|
api_independence_pct=88.0,
|
||||||
|
total_events=42,
|
||||||
|
)
|
||||||
|
d = snap.to_dict()
|
||||||
|
assert d["overall_pct"] == 85.0
|
||||||
|
assert d["health"] == "sovereign"
|
||||||
|
assert len(d["layers"]) == 1
|
||||||
|
assert d["layers"][0]["name"] == "perception"
|
||||||
|
assert d["crystallizations_last_hour"] == 3
|
||||||
|
assert d["api_independence_pct"] == 88.0
|
||||||
|
assert d["total_events"] == 42
|
||||||
|
assert "timestamp" in d
|
||||||
|
|
||||||
|
|
||||||
|
class TestSovereigntyPulse:
|
||||||
|
def test_snapshot_graceful_degradation(self):
|
||||||
|
"""When metrics are unavailable, should return default snapshot."""
|
||||||
|
pulse = SovereigntyPulse()
|
||||||
|
with patch.object(
|
||||||
|
pulse,
|
||||||
|
"_read_metrics",
|
||||||
|
side_effect=ImportError("no metrics"),
|
||||||
|
):
|
||||||
|
snap = pulse.snapshot()
|
||||||
|
assert isinstance(snap, SovereigntyPulseSnapshot)
|
||||||
|
assert snap.health == "unknown"
|
||||||
|
|
||||||
|
def test_snapshot_with_metrics(self):
|
||||||
|
"""When metrics are available, should read and compute correctly."""
|
||||||
|
pulse = SovereigntyPulse()
|
||||||
|
mock_snapshot = {
|
||||||
|
"perception": {"cache_hits": 8, "model_calls": 2},
|
||||||
|
"decision": {"cache_hits": 6, "model_calls": 4},
|
||||||
|
"narration": {"cache_hits": 10, "model_calls": 0},
|
||||||
|
"crystallizations": 7,
|
||||||
|
"total_events": 100,
|
||||||
|
}
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_snapshot.return_value = mock_snapshot
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"timmy.sovereignty.metrics.get_metrics_store", return_value=mock_store
|
||||||
|
):
|
||||||
|
snap = pulse.snapshot()
|
||||||
|
|
||||||
|
# Perception: 8/10 = 80%, Decision: 6/10 = 60%, Narration: 10/10 = 100%
|
||||||
|
# Overall: (80 + 60 + 100) / 3 = 80.0
|
||||||
|
assert len(snap.layers) == 3
|
||||||
|
assert snap.layers[0].name == "perception"
|
||||||
|
assert snap.layers[0].sovereign_pct == 80.0
|
||||||
|
assert snap.layers[1].name == "decision"
|
||||||
|
assert snap.layers[1].sovereign_pct == 60.0
|
||||||
|
assert snap.layers[2].name == "narration"
|
||||||
|
assert snap.layers[2].sovereign_pct == 100.0
|
||||||
|
assert snap.overall_pct == 80.0
|
||||||
|
assert snap.health == "sovereign"
|
||||||
|
assert snap.crystallizations_last_hour == 7
|
||||||
|
assert snap.total_events == 100
|
||||||
|
|
||||||
|
def test_api_independence_calculation(self):
|
||||||
|
pulse = SovereigntyPulse()
|
||||||
|
mock_snapshot = {
|
||||||
|
"perception": {"cache_hits": 5, "model_calls": 5},
|
||||||
|
"decision": {"cache_hits": 5, "model_calls": 5},
|
||||||
|
"narration": {"cache_hits": 5, "model_calls": 5},
|
||||||
|
"crystallizations": 0,
|
||||||
|
"total_events": 0,
|
||||||
|
}
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_snapshot.return_value = mock_snapshot
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"timmy.sovereignty.metrics.get_metrics_store", return_value=mock_store
|
||||||
|
):
|
||||||
|
snap = pulse.snapshot()
|
||||||
|
|
||||||
|
# Total hits: 15, Total calls: 15, Total: 30
|
||||||
|
# Independence: 15/30 = 50%
|
||||||
|
assert snap.api_independence_pct == 50.0
|
||||||
|
|
||||||
|
def test_zero_events_no_division_error(self):
|
||||||
|
pulse = SovereigntyPulse()
|
||||||
|
mock_snapshot = {
|
||||||
|
"perception": {"cache_hits": 0, "model_calls": 0},
|
||||||
|
"decision": {"cache_hits": 0, "model_calls": 0},
|
||||||
|
"narration": {"cache_hits": 0, "model_calls": 0},
|
||||||
|
"crystallizations": 0,
|
||||||
|
"total_events": 0,
|
||||||
|
}
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_snapshot.return_value = mock_snapshot
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"timmy.sovereignty.metrics.get_metrics_store", return_value=mock_store
|
||||||
|
):
|
||||||
|
snap = pulse.snapshot()
|
||||||
|
|
||||||
|
assert snap.overall_pct == 0.0
|
||||||
|
assert snap.api_independence_pct == 0.0
|
||||||
|
assert snap.health == "unknown"
|
||||||
Reference in New Issue
Block a user