"""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()