""" Conversation Store SQLite-backed persistence for Claude chat sessions. """ import json import sqlite3 from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional # Database path relative to backend directory DB_PATH = Path(__file__).parent.parent.parent / "sessions.db" class ConversationStore: """SQLite-backed conversation storage for Claude sessions""" def __init__(self, db_path: Path = DB_PATH): self.db_path = db_path self._init_db() def _get_conn(self) -> sqlite3.Connection: """Get database connection with row factory""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn def _init_db(self): """Initialize database schema""" conn = self._get_conn() try: conn.execute(""" CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, mode TEXT NOT NULL DEFAULT 'user', study_id TEXT, created_at TEXT NOT NULL, last_active TEXT NOT NULL ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, tool_calls TEXT, timestamp TEXT NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(session_id, timestamp) """) conn.commit() finally: conn.close() def create_session( self, session_id: str, mode: str = "user", study_id: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new session record. Args: session_id: Unique session identifier mode: "user" or "power" study_id: Optional study context Returns: Session record dict """ conn = self._get_conn() try: now = datetime.now().isoformat() conn.execute( """INSERT INTO sessions (session_id, mode, study_id, created_at, last_active) VALUES (?, ?, ?, ?, ?)""", (session_id, mode, study_id, now, now), ) conn.commit() return { "session_id": session_id, "mode": mode, "study_id": study_id, "created_at": now, "last_active": now, } finally: conn.close() def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: """Get session by ID""" conn = self._get_conn() try: cursor = conn.execute( "SELECT * FROM sessions WHERE session_id = ?", (session_id,), ) row = cursor.fetchone() if row: return dict(row) return None finally: conn.close() def update_session( self, session_id: str, mode: Optional[str] = None, study_id: Optional[str] = None, ): """Update session properties""" conn = self._get_conn() try: updates = ["last_active = ?"] values: List[Any] = [datetime.now().isoformat()] if mode is not None: updates.append("mode = ?") values.append(mode) if study_id is not None: updates.append("study_id = ?") values.append(study_id) values.append(session_id) conn.execute( f"UPDATE sessions SET {', '.join(updates)} WHERE session_id = ?", values, ) conn.commit() finally: conn.close() def touch_session(self, session_id: str): """Update last_active timestamp""" conn = self._get_conn() try: conn.execute( "UPDATE sessions SET last_active = ? WHERE session_id = ?", (datetime.now().isoformat(), session_id), ) conn.commit() finally: conn.close() def add_message( self, session_id: str, role: str, content: str, tool_calls: Optional[List[Dict]] = None, ) -> int: """ Add a message to a session. Args: session_id: Session identifier role: "user" or "assistant" content: Message text content tool_calls: Optional list of tool call records Returns: Message ID """ conn = self._get_conn() try: cursor = conn.execute( """INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)""", ( session_id, role, content, json.dumps(tool_calls) if tool_calls else None, datetime.now().isoformat(), ), ) # Update session last_active conn.execute( "UPDATE sessions SET last_active = ? WHERE session_id = ?", (datetime.now().isoformat(), session_id), ) conn.commit() return cursor.lastrowid or 0 finally: conn.close() def get_history( self, session_id: str, limit: int = 50, offset: int = 0, ) -> List[Dict[str, Any]]: """ Get conversation history for a session. Args: session_id: Session identifier limit: Maximum messages to return offset: Number of messages to skip (from most recent) Returns: List of message dicts in chronological order """ conn = self._get_conn() try: cursor = conn.execute( """ SELECT role, content, tool_calls, timestamp FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ? OFFSET ? """, (session_id, limit, offset), ) rows = cursor.fetchall() messages = [] for row in reversed(rows): msg: Dict[str, Any] = { "role": row["role"], "content": row["content"], "timestamp": row["timestamp"], } if row["tool_calls"]: msg["tool_calls"] = json.loads(row["tool_calls"]) messages.append(msg) return messages finally: conn.close() def get_message_count(self, session_id: str) -> int: """Get total message count for a session""" conn = self._get_conn() try: cursor = conn.execute( "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,), ) return cursor.fetchone()[0] finally: conn.close() def delete_session(self, session_id: str): """Delete a session and all its messages""" conn = self._get_conn() try: conn.execute( "DELETE FROM messages WHERE session_id = ?", (session_id,), ) conn.execute( "DELETE FROM sessions WHERE session_id = ?", (session_id,), ) conn.commit() finally: conn.close() def get_stale_sessions(self, max_age_hours: int = 24) -> List[str]: """Get session IDs that haven't been active for max_age_hours""" conn = self._get_conn() try: cutoff = datetime.now() # Calculate cutoff time from datetime import timedelta cutoff = (cutoff - timedelta(hours=max_age_hours)).isoformat() cursor = conn.execute( "SELECT session_id FROM sessions WHERE last_active < ?", (cutoff,), ) return [row["session_id"] for row in cursor.fetchall()] finally: conn.close() def cleanup_stale_sessions(self, max_age_hours: int = 24) -> int: """Delete stale sessions and return count deleted""" stale = self.get_stale_sessions(max_age_hours) for session_id in stale: self.delete_session(session_id) return len(stale)