Co-authored-by: Perplexity Computer <perplexity@tower.local> Co-committed-by: Perplexity Computer <perplexity@tower.local>
231 lines
8.4 KiB
Python
231 lines
8.4 KiB
Python
"""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()
|