"""piSynapse Database Layer SQLite with aiosqlite: conversations, sessions, and long-term memory. Single persistent connection with WAL mode for Pi-friendly I/O. """ import asyncio import csv import gzip import hashlib import hmac import json import logging import math import os import re import secrets import time import aiosqlite from config import DB_PATH logger = logging.getLogger("piSynapse") # DB files hold conversations, memories and audit params (incl. e-mail # bodies). Restrict the process umask so any file SQLite creates # (assistant.db / -wal / -shm / -journal) is born 0o600, never world-readable. os.umask(0o077) _db: aiosqlite.Connection | None = None _db_lock = asyncio.Lock() # Backfill task control (prevents unbounded embedding tasks on legacy memories) _BACKFILL_SEMAPHORE = asyncio.Semaphore(2) _BACKFILL_BATCH_SIZE = 10 _backfill_task: asyncio.Task | None = None _LOCKED_RETRIES = 3 # Serializes grouped multi-statement writes on the single shared connection. # aiosqlite uses legacy isolation (implicit BEGIN), so an explicit # BEGIN IMMEDIATE would risk "cannot start a transaction within a # transaction"; since every writer runs on the same event loop, an # asyncio.Lock gives the same no-interleaving guarantee with zero # SQLite-semantics risk. Slow work (embeddings) must stay OUTSIDE the lock. # The lock is never re-entered: db writers never call each other. # One lock per event loop: a module-level lock stays bound to the first loop # that acquires it, which breaks callers that (like the test-suite) drive the # module from several asyncio.run loops over the process lifetime. _write_locks: dict = {} def _write_lock(): """Return the write-serialization lock for the running loop.""" try: loop = asyncio.get_running_loop() except RuntimeError: loop = None lock = _write_locks.get(loop) if lock is None: lock = asyncio.Lock() # Prune locks of dead loops so the registry cannot grow unbounded. for known in [k for k in _write_locks if k is not None and k.is_closed()]: _write_locks.pop(known, None) _write_locks[loop] = lock return lock async def _write_with_retry(db: aiosqlite.Connection, sql: str, params: tuple = ()) -> aiosqlite.Cursor: """Execute a write, retrying briefly on 'database is locked' / 'busy'.""" import sqlite3 for attempt in range(_LOCKED_RETRIES + 1): try: cur = await db.execute(sql, params) await db.commit() return cur except sqlite3.OperationalError as e: if "locked" not in str(e).lower() and "busy" not in str(e).lower(): raise if attempt >= _LOCKED_RETRIES: raise logger.warning(f"SQLite busy, retrying ({attempt + 1}/{_LOCKED_RETRIES})...") await asyncio.sleep(0.25 * (attempt + 1)) async def _commit_with_retry(db: aiosqlite.Connection) -> None: """Commit a multi-statement write, retrying briefly on locked/busy. Complementary to _write_with_retry for hand-rolled transaction bodies (save_message, clear_history, ...) whose statements must stay grouped in a single transaction. busy_timeout already buffers most contention; this is a secondary safety net on the critical chat completion path. """ import sqlite3 for attempt in range(_LOCKED_RETRIES + 1): try: await db.commit() return except sqlite3.OperationalError as e: if "locked" not in str(e).lower() and "busy" not in str(e).lower(): raise if attempt >= _LOCKED_RETRIES: raise logger.warning(f"SQLite busy on commit, retrying ({attempt + 1}/{_LOCKED_RETRIES})...") await asyncio.sleep(0.25 * (attempt + 1)) async def _secure_db_files() -> None: """Force owner-only permissions on the DB and its SQLite sidecars. The DB is created on first run (not by install.py), so this runs on every startup as a guarantee for fresh and pre-existing installs. Covers every *.db* file in the DB directory (not just the configured path) so stray world-readable copies cannot linger. """ paths = [DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm", DB_PATH + "-journal"] db_dir = os.path.dirname(os.path.abspath(DB_PATH)) or "." try: for name in os.listdir(db_dir): if name.endswith(".db") or ".db-" in name: paths.append(os.path.join(db_dir, name)) except OSError as e: logger.warning(f"Could not list DB directory {db_dir}: {e}") for path in dict.fromkeys(os.path.abspath(p) for p in paths): try: if os.path.exists(path): os.chmod(path, 0o600) except OSError as e: logger.warning(f"Could not secure permissions on {path}: {e}") async def get_db() -> aiosqlite.Connection: global _db if _db is not None: try: await _db.execute("SELECT 1") return _db except Exception: logger.warning("SQLite connection lost, reconnecting...") _db = None async with _db_lock: if _db is not None: return _db conn = await aiosqlite.connect(DB_PATH) await conn.execute("PRAGMA journal_mode=WAL") await conn.execute("PRAGMA synchronous=NORMAL") await conn.execute("PRAGMA cache_size=10000") await conn.execute("PRAGMA temp_store=MEMORY") await conn.execute("PRAGMA foreign_keys=ON") await conn.execute("PRAGMA busy_timeout=10000") await conn.execute("PRAGMA journal_size_limit=67108864") await _secure_db_files() _db = conn return _db async def close_db(): global _db # Stop any in-flight embedding backfill (fire-and-forget: it runs on its # own connection, so this is housekeeping, not a correctness requirement). if _backfill_task is not None and not _backfill_task.done(): _backfill_task.cancel() if _db is not None: await _db.close() _db = None # -- Schema -- # Ordered schema migrations. `user_version` tracks how many are applied. # # IMPORTANT — keep this list in sync with the CREATE TABLE definitions in # init_db() below. The CREATE TABLEs describe the full CURRENT schema (they # already include every column listed here) and are what brand-new databases # get. MIGRATIONS only matters for pre-existing databases whose tables were # created before a column existed. When adding a column: # 1. add it to the matching CREATE TABLE statement (new DBs), AND # 2. append a new entry at the END of MIGRATIONS (old DBs). # Never reorder, edit or remove an existing entry: user_version numbers are # baked into live databases and renumbering would re-run/skip migrations. MIGRATIONS: list[tuple[str, str, str]] = [ ("conversations", "images", "TEXT"), ("sessions", "name", "TEXT"), ("sessions", "summarized_until", "INTEGER DEFAULT 0"), ("memories", "embedding", "BLOB"), ("conversations", "reasoning", "TEXT"), ("conversations", "embedding", "BLOB"), ("tool_audit_log", "expected_tool", "TEXT"), ("tool_audit_log", "corrected_at", "DATETIME"), ("tool_audit_log", "verification_status", "TEXT"), ("tool_audit_log", "expected_group", "TEXT"), ("tool_audit_log", "confirmed_at", "DATETIME"), ("tool_audit_log", "conversation_id", "INTEGER"), ("conversations", "client_key", "TEXT"), ("conversations", "user_id", "TEXT DEFAULT 'default'"), ("sessions", "user_id", "TEXT DEFAULT 'default'"), ("email_session_map", "user_id", "TEXT DEFAULT 'default'"), ("notes_session_map", "user_id", "TEXT DEFAULT 'default'"), ("tasks_session_map", "user_id", "TEXT DEFAULT 'default'"), ("calendar_session_map", "user_id", "TEXT DEFAULT 'default'"), ("tool_audit_log", "user_id", "TEXT"), ("users", "password_hash", "TEXT"), ("users", "is_approved", "INTEGER DEFAULT 0"), ("sync_items", "origin", "TEXT NOT NULL DEFAULT ''"), ("tool_audit_log", "stats_json", "TEXT"), ] # One-time data backfills, keyed by the migration index that requires them. # Unlike the every-boot repairs in init_db, these run exactly once (inside # _apply_migrations) so later admin edits are never overwritten. _DATA_BACKFILLS: dict[int, list[str]] = { # Grandfather pre-approval rows: everyone on board keeps counting. 21: ["UPDATE users SET is_approved = 1"], } async def _get_schema_version(db: aiosqlite.Connection) -> int: cur = await db.execute("PRAGMA user_version") row = await cur.fetchone() return int(row[0]) if row else 0 _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") _DDL_UNSAFE_RE = re.compile(r";|--|/\*|\*/") def _safe_ident(name: str) -> str: """Quote a schema identifier; rejects anything but plain names.""" if not _IDENT_RE.fullmatch(name or ""): raise ValueError(f"Unsafe schema identifier: {name!r}") return f'"{name}"' def _safe_ddl_definition(definition: str) -> str: """Allow column definitions built from types/defaults only. Blocks statement stacking (;) and comment breakout (--, /* */), the realistic injection vectors for DDL assembled from parts. (The ``PRAGMA user_version = {i + 1}`` below needs no guard: ``i`` is a range() int by construction.) """ if not definition or _DDL_UNSAFE_RE.search(definition): raise ValueError(f"Unsafe column definition: {definition!r}") return definition async def _apply_migrations(db: aiosqlite.Connection): version = await _get_schema_version(db) for i in range(version, len(MIGRATIONS)): table, column, definition = MIGRATIONS[i] try: await _write_with_retry( db, f"ALTER TABLE {_safe_ident(table)} ADD COLUMN {_safe_ident(column)} " f"{_safe_ddl_definition(definition)}", ) except Exception as e: if "duplicate column" not in str(e).lower(): logger.warning(f"Migration {table}.{column} failed: {e}") break for _stmt in _DATA_BACKFILLS.get(i, []): await db.execute(_stmt) await db.execute(f"PRAGMA user_version = {i + 1}") await _commit_with_retry(db) async def init_db(): db = await get_db() await db.execute(""" CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, images TEXT, reasoning TEXT, embedding BLOB, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, client_key TEXT, user_id TEXT DEFAULT 'default' ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS message_feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL UNIQUE, value TEXT NOT NULL CHECK (value IN ('up','down')), note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_active DATETIME DEFAULT CURRENT_TIMESTAMP, name TEXT, summary TEXT, summarized_until INTEGER DEFAULT 0, user_id TEXT DEFAULT 'default' ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', key_hash TEXT NOT NULL UNIQUE, is_admin INTEGER NOT NULL DEFAULT 0, password_hash TEXT, is_approved INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS user_api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', key_hash TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_user_api_keys_user ON user_api_keys(user_id)") await db.execute(""" CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, device TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)") await db.execute(""" CREATE TABLE IF NOT EXISTS user_settings ( user_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL DEFAULT '', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, key) ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL DEFAULT 'default', content TEXT NOT NULL, category TEXT, importance INTEGER DEFAULT 5, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP, access_count INTEGER DEFAULT 0, embedding BLOB ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS tool_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, tool_name TEXT NOT NULL, params TEXT, success INTEGER NOT NULL, duration_ms REAL, error TEXT, is_summary INTEGER NOT NULL DEFAULT 0, day TEXT, total_calls INTEGER, success_count INTEGER, error_count INTEGER, tool_breakdown TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expected_tool TEXT, corrected_at DATETIME, expected_group TEXT, verification_status TEXT, confirmed_at DATETIME, conversation_id INTEGER, user_id TEXT, stats_json TEXT ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS intent_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT NOT NULL, chosen_group TEXT, best_sim REAL, margin REAL, source TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS email_session_map ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, seq INTEGER NOT NULL, message_id TEXT NOT NULL, subject TEXT DEFAULT '', sender TEXT DEFAULT '', preview TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_id TEXT DEFAULT 'default', UNIQUE(session_id, seq) ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS feedback_votes ( audit_id INTEGER NOT NULL, user_id TEXT NOT NULL, signature TEXT NOT NULL DEFAULT '', proposed_group TEXT NOT NULL DEFAULT '', signal TEXT NOT NULL DEFAULT 'confirm', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(audit_id, user_id) ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS pattern_approvals ( signature TEXT PRIMARY KEY, proposed_group TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'approved', decided_by TEXT NOT NULL DEFAULT '', decided_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS user_credentials ( user_id TEXT NOT NULL, provider TEXT NOT NULL, enc_blob TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, provider) ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id, timestamp)") await db.execute("CREATE INDEX IF NOT EXISTS idx_memories_user ON memories(user_id, importance DESC)") await db.execute("CREATE INDEX IF NOT EXISTS idx_tool_audit_created ON tool_audit_log(created_at)") await db.execute("CREATE INDEX IF NOT EXISTS idx_tool_audit_rollup ON tool_audit_log(is_summary, created_at)") await db.execute("CREATE INDEX IF NOT EXISTS idx_intent_audit_created ON intent_audit_log(created_at)") await db.execute("CREATE INDEX IF NOT EXISTS idx_intent_audit_source ON intent_audit_log(source, created_at)") await db.execute("CREATE INDEX IF NOT EXISTS idx_email_session_map_session ON email_session_map(session_id, seq)") await db.execute("CREATE INDEX IF NOT EXISTS idx_feedback_votes_sig ON feedback_votes(signature, proposed_group)") # Legacy repairs (dedupe + backfill + unique gate) run after # _apply_migrations below: they reference migrated columns that may not # exist yet on old DBs. await db.execute("CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at)") await db.execute("CREATE INDEX IF NOT EXISTS idx_tool_audit_conv ON tool_audit_log(conversation_id)") await db.execute( "CREATE INDEX IF NOT EXISTS idx_tool_audit_day " "ON tool_audit_log(substr(created_at, 1, 10))" ) await db.execute(""" CREATE TABLE IF NOT EXISTS notes_session_map ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, seq INTEGER NOT NULL, note_id INTEGER NOT NULL, title TEXT DEFAULT '', category TEXT DEFAULT '', preview TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_id TEXT DEFAULT 'default', UNIQUE(session_id, seq) ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_notes_session_map_session ON notes_session_map(session_id, seq)") await db.execute(""" CREATE TABLE IF NOT EXISTS tasks_session_map ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, seq INTEGER NOT NULL, uid TEXT NOT NULL, summary TEXT DEFAULT '', due TEXT DEFAULT '', priority INTEGER DEFAULT 0, completed INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_id TEXT DEFAULT 'default', UNIQUE(session_id, seq) ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_tasks_session_map_session ON tasks_session_map(session_id, seq)") await db.execute(""" CREATE TABLE IF NOT EXISTS calendar_session_map ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, seq INTEGER NOT NULL, uid TEXT NOT NULL, summary TEXT DEFAULT '', start_time TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_id TEXT DEFAULT 'default', UNIQUE(session_id, seq) ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_calendar_session_map_session ON calendar_session_map(session_id, seq)") # ── Two-way device sync store ────────────────────────────────────────── # Generic last-write-wins entity table: phones push local items (tasks, # phone-side notes/memory) and pull other devices' changes. `deleted` is a # tombstone so removals propagate. `uuid` is the client-generated stable id. await db.execute(""" CREATE TABLE IF NOT EXISTS sync_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT 'default', entity_type TEXT NOT NULL, data TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, origin TEXT NOT NULL DEFAULT '', UNIQUE(user_id, uuid) ) """) await db.execute("CREATE INDEX IF NOT EXISTS idx_sync_items_user_type ON sync_items(user_id, entity_type, updated_at)") # FTS5 full-text index for session message search # unicode61 tokenizer: proper Turkish/diacritik support # content= + content_rowid= : external-content table (conversations) await db.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS conversations_fts USING fts5(content, session_id, content='conversations', content_rowid='id', tokenize='unicode61 remove_diacritics 2') """) # Migrate old ascii tokenizer → unicode61 (one-time, only if needed). # CREATE IF NOT EXISTS won't recreate an existing table with the old # tokenizer, so we must detect and rebuild the schema explicitly. try: async with db.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='conversations_fts'") as cur: row = await cur.fetchone() fts_sql = row[0] if row else "" if fts_sql and "unicode61" not in fts_sql: logger.info("FTS5 tokenizer migration: ascii → unicode61") await db.execute("DROP TABLE IF EXISTS conversations_fts") await db.execute(""" CREATE VIRTUAL TABLE conversations_fts USING fts5(content, session_id, content='conversations', content_rowid='id', tokenize='unicode61 remove_diacritics 2') """) await db.execute(""" INSERT INTO conversations_fts (rowid, content, session_id) SELECT id, content, session_id FROM conversations """) else: # Light drift check: rebuild only if row counts diverge # (e.g. crash mid-write). Avoids O(N) rebuild on every startup. async with db.execute("SELECT COUNT(*) FROM conversations") as cur: conv_count = (await cur.fetchone())[0] async with db.execute("SELECT COUNT(*) FROM conversations_fts") as cur: fts_count = (await cur.fetchone())[0] if conv_count != fts_count: logger.info(f"FTS5 drift detected (conversations={conv_count}, fts={fts_count}) — rebuilding") await db.execute("INSERT INTO conversations_fts(conversations_fts) VALUES('rebuild')") except Exception as e: logger.warning(f"FTS5 migration/drift check failed (non-fatal): {e}") await _apply_migrations(db) # Legacy repairs run here (after migrations): they reference migrated # columns that may not exist yet on old DBs. All idempotent; scans are # cheap at realistic sizes (a version gate was considered and rejected: # it risks silently skipping repairs after partial runs, saving <1s). # Audit ownership first (it joins live owner values), then NULL-norm. await db.execute( "UPDATE tool_audit_log SET user_id = " "(SELECT c.user_id FROM conversations c WHERE c.id = tool_audit_log.conversation_id) " "WHERE conversation_id IS NOT NULL AND user_id IS NULL " "AND (SELECT c.user_id FROM conversations c WHERE c.id = tool_audit_log.conversation_id) IS NOT NULL" ) for _owner_tbl, _owner_col in (("conversations", "user_id"), ("sessions", "user_id"), ("memories", "user_id"), ("tool_audit_log", "user_id")): await db.execute(f"UPDATE {_owner_tbl} SET {_owner_col} = 'default' WHERE {_owner_col} IS NULL") # Canonicalize stored sync timestamps (idempotent; see helper doc) so # LWW and since-comparisons are valid on legacy rows too. await _normalize_stored_sync_timestamps(db) doomed_cur = await db.execute( "SELECT id FROM conversations WHERE client_key IS NOT NULL AND id NOT IN " "(SELECT MIN(id) FROM conversations WHERE client_key IS NOT NULL " "GROUP BY user_id, client_key)" ) doomed = [r[0] for r in await doomed_cur.fetchall()] if doomed: logger.warning( "Removing %d duplicate import rows before enforcing " "UNIQUE(user_id, client_key)", len(doomed), ) ph = ",".join("?" * len(doomed)) try: await db.execute(f"DELETE FROM conversations_fts WHERE rowid IN ({ph})", doomed) except Exception as fts_e: # External-content FTS5 quirk: when NONE of the target rowids are # indexed (past best-effort FTS misses), the DELETE raises # "malformed" instead of deleting nothing. That same absence means # there is nothing to orphan, so dropping the FTS leg is safe — # and init_db must never crash on legacy data (startup SPOF). logger.warning(f"FTS cleanup skipped during dedupe (non-fatal): {fts_e}") await db.execute(f"DELETE FROM conversations WHERE id IN ({ph})", doomed) await db.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_client_key " "ON conversations(user_id, client_key) WHERE client_key IS NOT NULL" ) for _map_tbl in ("email_session_map", "notes_session_map", "tasks_session_map", "calendar_session_map"): await db.execute( f"UPDATE {_map_tbl} SET user_id = " f"(SELECT s.user_id FROM sessions s WHERE s.id = {_map_tbl}.session_id) " f"WHERE EXISTS (SELECT 1 FROM sessions s WHERE s.id = {_map_tbl}.session_id " f"AND s.user_id != 'default')" ) await cleanup_expired_data() await _commit_with_retry(db) # Sidecar -wal/-shm now exist after the first write; lock them down too. await _secure_db_files() async def _vacuum_if_fragmented(db: aiosqlite.Connection) -> None: """Compact the DB file only when it is mostly free pages. The tool-audit rollup deletes thousands of detail rows every day, leaving free pages behind; an unconditional daily VACUUM would add pointless I/O and write-lock contention on every cleanup run. Triggering only above a 20% free-page ratio (and only for a meaningfully large DB) keeps the file compact in practice with negligible overhead. Never raises. """ try: async with db.execute("PRAGMA freelist_count") as cur: free = (await cur.fetchone())[0] async with db.execute("PRAGMA page_count") as cur: total = (await cur.fetchone())[0] if total > 256 and free / total > 0.2: await db.execute("VACUUM") logger.info(f"VACUUM: reclaimed {free} free pages (file was {total} pages)") except Exception as e: logger.warning(f"VACUUM skipped: {e}") async def _retention_prune_conversations(db: aiosqlite.Connection) -> int: """Delete conversations older than retention + purge their orphans. Runs inside the caller's write lock. Returns the removed row count. """ from config import CONVERSATION_RETENTION_DAYS cur = await _write_with_retry( db, "DELETE FROM conversations WHERE timestamp < datetime('now', ?)", (f"-{CONVERSATION_RETENTION_DAYS} days",), ) removed = cur.rowcount if cur.rowcount else 0 # Purge matching FTS5 rows await _write_with_retry( db, "DELETE FROM conversations_fts WHERE rowid NOT IN (SELECT id FROM conversations)", ) await _write_with_retry( db, "DELETE FROM sessions WHERE id NOT IN (SELECT DISTINCT session_id FROM conversations)" ) # Orphan feedback/audits of pruned messages (same pattern as FTS). await _write_with_retry( db, "DELETE FROM message_feedback WHERE message_id NOT IN (SELECT id FROM conversations)", ) await _write_with_retry( db, "DELETE FROM tool_audit_log WHERE conversation_id IS NOT NULL " "AND conversation_id NOT IN (SELECT id FROM conversations)", ) # Surviving sessions may point past the pruned tail. await _repair_summary_boundaries(db) await _commit_with_retry(db) return removed async def db_quick_check() -> str | None: """Run PRAGMA quick_check; return None when healthy, else a detail string.""" db = await get_db() cur = await db.execute("PRAGMA quick_check") rows = await cur.fetchall() if len(rows) == 1 and str(rows[0][0]).lower() == "ok": return None return "; ".join(str(r[0]) for r in rows[:5]) AUTO_BACKUP_KEEP = 7 AUTO_BACKUP_PREFIX = "piSynapse-auto-" async def auto_backup_db() -> str | None: """Online backup over a dedicated read-only connection, pruning generations. Uses the sqlite3 online-backup API (chunked + yielding) instead of VACUUM INTO, which refuses to run while the shared connection has open statements. Owner-only 0600, atomic tmp+replace, keeps the newest AUTO_BACKUP_KEEP generations. Returns the backup path or None (never raises). Runs at startup and from the daily loop. """ tmp = None try: import datetime as _dt import sqlite3 as _sqlite3 db_path = os.path.abspath(DB_PATH) backup_dir = os.path.join(os.path.dirname(db_path) or ".", "backups") os.makedirs(backup_dir, mode=0o700, exist_ok=True) stamp = _dt.datetime.now().strftime("%Y%m%d") dest = os.path.join(backup_dir, f"{AUTO_BACKUP_PREFIX}{stamp}.db") tmp = dest + ".tmp" def _copy() -> None: src = _sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=30) try: dst = _sqlite3.connect(tmp, timeout=30) try: src.backup(dst, pages=100, sleep=0.05) finally: dst.close() finally: src.close() await asyncio.to_thread(_copy) os.chmod(tmp, 0o600) os.replace(tmp, dest) gens = sorted( f for f in os.listdir(backup_dir) if f.startswith(AUTO_BACKUP_PREFIX) and f.endswith(".db") ) for old in gens[:-AUTO_BACKUP_KEEP]: try: os.remove(os.path.join(backup_dir, old)) except OSError as e: logger.warning(f"Could not prune old backup {old}: {e}") return dest except Exception as e: if tmp: try: os.remove(tmp) except OSError: pass logger.warning(f"Auto backup failed (non-fatal): {e}") return None _AUDIT_EXPORT_SUFFIXES = (".csv", ".ndjson.gz", ".manifest.json") async def _prune_audit_exports(retention_days: int) -> int: """Delete per-day audit export files older than the window. Returns count. Prunes every artefact for a day together (csv + ndjson.gz + manifest) so a day is never left half-archived. `retention_days <= 0` keeps everything (the default: the archive is the only full-detail copy after the rollup, so deleting it silently loses evidence). """ import datetime as _dt if retention_days <= 0: return 0 outdir = audit_export_dir() cutoff = _dt.date.today() - _dt.timedelta(days=retention_days) removed = 0 try: names = os.listdir(outdir) except OSError: return 0 for name in names: m = re.fullmatch(r"tool-audit-(\d{4})-(\d{2})-(\d{2})(\.csv|\.ndjson\.gz|\.manifest\.json)", name) if not m: continue try: day = _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))) except ValueError: continue if day < cutoff: try: os.remove(os.path.join(outdir, name)) removed += 1 except OSError as e: logger.warning(f"Could not prune audit export {name}: {e}") return removed async def cleanup_expired_data() -> tuple[int, int]: """Delete data older than the configured retention (0 = keep forever). Runs on startup and then periodically (see ``periodic_cleanup_loop``). Never raises — a failure (DB locked, etc.) is logged and retried on the next cycle so it cannot crash the service. Returns (deleted_conversation_rows, deleted_memory_rows). """ from config import CONVERSATION_RETENTION_DAYS, MEMORY_RETENTION_DAYS try: db = await get_db() removed_conv = removed_mem = 0 if CONVERSATION_RETENTION_DAYS > 0: async with _write_lock(): removed_conv = await _retention_prune_conversations(db) # Archive pruning follows its own policy (default 0 = keep forever): # the exported files are the only full-detail copy after a rollup. from config import AUDIT_ARCHIVE_RETENTION_DAYS removed_csv = await _prune_audit_exports(AUDIT_ARCHIVE_RETENTION_DAYS) if removed_csv: logger.info(f"Retention cleanup: {removed_csv} audit export file(s) pruned") if MEMORY_RETENTION_DAYS > 0: cur = await _write_with_retry( db, "DELETE FROM memories WHERE created_at < datetime('now', ?)", (f"-{MEMORY_RETENTION_DAYS} days",), ) removed_mem = cur.rowcount if cur.rowcount else 0 if removed_conv or removed_mem: logger.info(f"Retention cleanup: {removed_conv} conversations, {removed_mem} memories deleted") # Free-page compaction (opportunistic, see helper) after the deletes. await _vacuum_if_fragmented(db) return removed_conv, removed_mem except Exception as e: logger.warning(f"Retention cleanup failed, will retry next cycle: {e}") return 0, 0 # -- Tool audit log -- # Params keys whose values are user content or secrets; their values are # replaced with [REDACTED] in the audit row so raw email/note/memory text and # credentials never hit the DB (the log keeps tool_name/success/duration/error # for accountability). _AUDIT_REDACT_KEYS = { "password", "passwd", "pass", "token", "api_key", "apikey", "api-key", "secret", "auth", "authorization", "credential", "credentials", "body", "content", "text", "message", "prompt", # E-mail fields: recipient addresses, subject and sender are PII and # must not land in the audit log in plaintext. "to", "cc", "bcc", "from", "sender", "recipient", "recipients", "subject", "reply_to", "replyto", "from_address", "to_address", } _AUDIT_PARAMS_MAX_CHARS = 2048 def _redact_params(value): """Recursively redact sensitive keys and return a log-safe structure.""" if isinstance(value, dict): return { k: ("[REDACTED]" if _is_redact_key(k) else _redact_params(v)) for k, v in value.items() } if isinstance(value, (list, tuple)): return [_redact_params(v) for v in value] return value def _is_redact_key(key: str) -> bool: norm = str(key).lower().replace(" ", "_").replace("-", "_") return norm in _AUDIT_REDACT_KEYS def _audit_params_json(params) -> str | None: """Serialise params with sensitive fields redacted and a size cap.""" if not params: return None safe = _redact_params(params) try: out = json.dumps(safe, ensure_ascii=False) except (TypeError, ValueError): out = json.dumps(_redact_params(str(params)), ensure_ascii=False) if len(out) > _AUDIT_PARAMS_MAX_CHARS: out = out[:_AUDIT_PARAMS_MAX_CHARS] + " ...(truncated)" return out async def log_tool_call( tool_name: str, params: dict | None, success: bool, duration_ms: float | None = None, error: str | None = None, verification_status: str | None = None, user_id: str | None = None, ) -> int | None: """Append a row to the tool audit log. ``verification_status`` records the outcome of ID-based backend verification (one of "verified", "verified_by_fallback", "unverified", "verification_failed", or None when verification is not applicable). Returns the new row's id (the audit_id the frontend uses to attach a correction to this exact tool call), or None on failure. Deliberately swallows every failure (DB down, locked, schema issue) and only logs a warning — this runs inside the tool-call loop's verification hook and must never break or stall an actual tool execution. """ try: db = await get_db() cur = await _write_with_retry( db, "INSERT INTO tool_audit_log (tool_name, params, success, duration_ms, error, verification_status, user_id) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (tool_name, _audit_params_json(params), 1 if success else 0, duration_ms, error, verification_status, user_id or "default"), ) return cur.lastrowid except Exception as e: logger.warning(f"Tool audit log write failed for '{tool_name}': {e}") return None async def log_intent_audit(message: str, chosen_group: str | None, best_sim: float | None, margin: float | None, source: str) -> None: """Fire-and-forget intent ambiguity record. Never raises.""" try: db = await get_db() async with _write_lock(): await db.execute( "INSERT INTO intent_audit_log (message, chosen_group, best_sim, margin, source) " "VALUES (?, ?, ?, ?, ?)", (message[:500], chosen_group, best_sim, margin, source), ) await _commit_with_retry(db) except Exception as e: logger.warning(f"Intent audit write failed (source={source}): {e}") async def set_tool_correction(audit_id: int, expected_tool: str | None, expected_group: str | None = None, user_id: str | None = None) -> bool: """Set a correction on a tool audit log entry. Updates expected_tool and/or expected_group and sets corrected_at to current timestamp. expected_tool is a precise positive signal (exact tool name); expected_group is a coarse signal (domain) used when the user only picks a group. Either may be NULL. Sets confirmed_at to NULL: confirmation and correction are mutually exclusive feedback states, so a row always holds at most one of them. Returns True if a row was updated, False if not found. Ownership: pass the caller's user_id to scope the write to their own rows (multi-user invisibility). None disables scoping — offline scripts/tests only; HTTP endpoints must always pass current_user. """ try: db = await get_db() sql = ("UPDATE tool_audit_log SET expected_tool = ?, expected_group = ?, " "corrected_at = CURRENT_TIMESTAMP, confirmed_at = NULL WHERE id = ?") args: tuple = (expected_tool, expected_group, audit_id) if user_id is not None: sql += " AND user_id = ?" args = (*args, user_id) async with _write_lock(): cur = await db.execute(sql, args) await _commit_with_retry(db) return cur.rowcount > 0 except Exception as e: logger.warning(f"Tool correction update failed for audit_id={audit_id}: {e}") return False async def get_audit_tool_name(audit_id: int, user_id: str | None = None) -> str | None: """Return the tool_name of an audit log row, or None if not found. Pass user_id to scope the read to the caller's own rows (None = unscoped, offline use only). """ try: db = await get_db() sql = "SELECT tool_name FROM tool_audit_log WHERE id = ?" args: tuple = (audit_id,) if user_id is not None: sql += " AND user_id = ?" args = (*args, user_id) cur = await db.execute(sql, args) row = await cur.fetchone() return row[0] if row else None except Exception as e: logger.warning(f"fetch audit tool_name failed for audit_id={audit_id}: {e}") return None async def set_tool_confirmation(audit_id: int, user_id: str | None = None) -> bool: """Record a positive (confirmation) signal on a tool audit log entry. Sets confirmed_at to the current timestamp. Confirmation is the opposite of a correction: any previously stored expected_tool/expected_group and their corrected_at are cleared so a row never holds two opposing signals. Returns True if a row was updated, False if not found. Ownership: pass the caller's user_id (None = unscoped, offline use only). """ try: db = await get_db() sql = ("UPDATE tool_audit_log SET confirmed_at = CURRENT_TIMESTAMP, " "expected_tool = NULL, expected_group = NULL, corrected_at = NULL " "WHERE id = ?") args: tuple = (audit_id,) if user_id is not None: sql += " AND user_id = ?" args = (*args, user_id) async with _write_lock(): cur = await db.execute(sql, args) await _commit_with_retry(db) return cur.rowcount > 0 except Exception as e: logger.warning(f"Tool confirmation update failed for audit_id={audit_id}: {e}") return False # ── Collective-learning quorum (Phase 3) ────────────────────────────────────── # Votes mirror feedback writes for quorum accounting. Raw audit rows stay # per-user and invisible; only anonymized (signature, group) tallies below # ever influence shared learning. Nothing here raises. FEEDBACK_DAILY_CAP = 50 QUORUM_MIN_USERS = 2 REPUTATION_FLOOR = 0.5 async def _user_text_for_audit(db, audit_id: int, user_id: str) -> str | None: """Resolve the user's trigger sentence behind an audit row (owner-scoped).""" cur = await db.execute( "SELECT conversation_id FROM tool_audit_log WHERE id = ? AND user_id = ?", (audit_id, user_id), ) row = await cur.fetchone() if not row or not row[0]: return None cur = await db.execute( "SELECT session_id FROM conversations WHERE id = ? AND user_id = ?", (row[0], user_id), ) srow = await cur.fetchone() if not srow: return None cur = await db.execute( "SELECT content FROM conversations WHERE session_id = ? AND id < ? " "AND role = 'user' AND user_id = ? ORDER BY id DESC LIMIT 1", (srow[0], row[0], user_id), ) urow = await cur.fetchone() return urow[0] if urow and urow[0] else None async def record_feedback_vote(audit_id: int, user_id: str, signal: str, group: str | None) -> bool: """Mirror a scoped feedback write into quorum accounting. Never raises. Re-votes overwrite (matches feedback overwrite semantics). Skips quietly when the trigger text is unresolvable — the feedback itself already applied. """ try: from textnorm import normalize_signature db = await get_db() text = await _user_text_for_audit(db, audit_id, user_id) if not text: return False sig = normalize_signature(text) if not sig: return False async with _write_lock(): await db.execute( """INSERT INTO feedback_votes (audit_id, user_id, signature, proposed_group, signal) VALUES (?, ?, ?, ?, ?) ON CONFLICT(audit_id, user_id) DO UPDATE SET signature = excluded.signature, proposed_group = excluded.proposed_group, signal = excluded.signal, created_at = CURRENT_TIMESTAMP""", (audit_id, user_id, sig, group or "", signal), ) await _commit_with_retry(db) return True except Exception as e: logger.warning(f"Feedback vote record failed for audit_id={audit_id}: {e}") return False async def get_pattern_support(signature: str, group: str) -> dict: """Distinct-user support/contradiction counts for a (signature, group). Only approved users count (admins always do) — open registration must not let sockpuppets manufacture quorum. Deleted users drop out via JOIN. """ try: db = await get_db() cur = await db.execute( "SELECT v.proposed_group, COUNT(DISTINCT v.user_id) FROM feedback_votes v " "JOIN users u ON u.id = v.user_id " "WHERE v.signature = ? AND (u.is_approved = 1 OR u.is_admin = 1) " "GROUP BY v.proposed_group", (signature,), ) supports = contradicts = 0 for grow, n in await cur.fetchall(): if grow == group: supports += n else: contradicts += n return {"supports": supports, "contradicts": contradicts} except Exception: return {"supports": 0, "contradicts": 0} async def user_reputation(user_id: str) -> float: """Agreement rate with decided patterns (weight, never a ban). A pattern is decided when ≥2 distinct approved users voted and one group holds a strict majority of distinct users. Users with no decided votes score 1.0 (neutral). Never raises. """ try: db = await get_db() cur = await db.execute( "SELECT v.signature, v.proposed_group, v.user_id FROM feedback_votes v " "JOIN users u ON u.id = v.user_id " "WHERE v.signature != '' AND (u.is_approved = 1 OR u.is_admin = 1)" ) rows = await cur.fetchall() by_sig: dict[str, list[tuple[str, str]]] = {} for s, g, u in rows: by_sig.setdefault(s, []).append((g, u)) decided: dict[str, str] = {} for s, votes in by_sig.items(): per_group: dict[str, set[str]] = {} for g, u in votes: per_group.setdefault(g, set()).add(u) total = len({u for _, u in votes}) if total < 2: continue top = max(len(us) for us in per_group.values()) winners = [g for g, us in per_group.items() if len(us) == top] if len(winners) == 1 and top > total / 2: decided[s] = winners[0] mine = [(s, g) for s, g, u in rows if u == user_id and s in decided] if not mine: return 1.0 return sum(1 for s, g in mine if decided[s] == g) / len(mine) except Exception: return 1.0 # ── Per-user encrypted credentials (mail/nextcloud) ────────────────────────── async def save_credential(user_id: str, provider: str, payload: dict) -> bool: """Validate + encrypt + upsert a credential. Never raises, never logs secrets.""" try: from credstore import PROVIDER_FIELDS, VALID_PROVIDERS, encrypt_payload if provider not in VALID_PROVIDERS: return False need = PROVIDER_FIELDS[provider] if any(not str(payload.get(k) or "").strip() for k in need): return False clean = {k: str(payload[k]).strip()[:500] for k in need} blob = encrypt_payload(clean) db = await get_db() async with _write_lock(): await db.execute( """INSERT INTO user_credentials (user_id, provider, enc_blob) VALUES (?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET enc_blob = excluded.enc_blob, updated_at = CURRENT_TIMESTAMP""", (user_id, provider, blob), ) await _commit_with_retry(db) return True except Exception: return False async def get_credential(user_id: str | None, provider: str) -> dict | None: """Decrypt a credential, or None (missing/unreadable). Never raises.""" if not user_id: return None try: from credstore import decrypt_payload db = await get_db() cur = await db.execute( "SELECT enc_blob FROM user_credentials WHERE user_id = ? AND provider = ?", (user_id, provider), ) row = await cur.fetchone() if not row: return None return decrypt_payload(row[0]) except Exception: return None async def list_credentials(user_id: str) -> list[dict]: """Providers saved by a user with their public account label (no secrets).""" try: from credstore import PROVIDER_FIELDS, decrypt_payload db = await get_db() cur = await db.execute( "SELECT provider, enc_blob, updated_at FROM user_credentials WHERE user_id = ? ORDER BY provider", (user_id,), ) out = [] for provider, blob, updated in await cur.fetchall(): account = "" data = decrypt_payload(blob) if data: fields = PROVIDER_FIELDS.get(provider, ()) account = str(data.get(fields[0], "")) if fields else "" out.append({"provider": provider, "account": account, "updated_at": updated}) return out except Exception: return [] async def delete_credential(user_id: str, provider: str) -> bool: """Remove a credential. Never raises.""" try: db = await get_db() async with _write_lock(): cur = await db.execute( "DELETE FROM user_credentials WHERE user_id = ? AND provider = ?", (user_id, provider), ) await _commit_with_retry(db) return cur.rowcount > 0 except Exception: return False async def count_user_votes_today(user_id: str) -> int: """Feedback votes cast by a user today (UTC) — flood-control counter.""" try: db = await get_db() cur = await db.execute( "SELECT COUNT(*) FROM feedback_votes WHERE user_id = ? AND date(created_at) = date('now')", (user_id,), ) row = await cur.fetchone() return int(row[0]) if row else 0 except Exception: return 0 async def record_pattern_decision(signature: str, group: str, status: str, decided_by: str) -> None: """Store an admin decision on a routing pattern (approved/rejected). Approved patterns skip the contradiction freeze; rejected ones are never auto-added. Never raises. """ if status not in ("approved", "rejected") or not signature: return try: db = await get_db() async with _write_lock(): await db.execute( """INSERT INTO pattern_approvals (signature, proposed_group, status, decided_by) VALUES (?, ?, ?, ?) ON CONFLICT(signature) DO UPDATE SET proposed_group = excluded.proposed_group, status = excluded.status, decided_by = excluded.decided_by, decided_at = CURRENT_TIMESTAMP""", (signature, group or "", status, decided_by), ) await _commit_with_retry(db) except Exception as e: logger.warning(f"Pattern decision record failed: {e}") async def get_pattern_decision(signature: str) -> dict | None: """Return the admin decision for a signature, or None.""" try: db = await get_db() cur = await db.execute( "SELECT proposed_group, status, decided_by, decided_at FROM pattern_approvals WHERE signature = ?", (signature,), ) row = await cur.fetchone() if not row: return None return {"group": row[0], "status": row[1], "decided_by": row[2], "decided_at": row[3]} except Exception: return None async def get_review_queue() -> list[dict]: """Frozen candidates for the admin queue: reputable contradiction, no quorum, undecided. Each item carries supports/contradicts counts plus contradictor ids with reputations so the admin decides informed. Never raises. """ try: db = await get_db() cur = await db.execute( "SELECT DISTINCT v.signature FROM feedback_votes v " "JOIN users u ON u.id = v.user_id " "WHERE v.signature != '' AND (u.is_approved = 1 OR u.is_admin = 1)" ) sigs = [r[0] for r in await cur.fetchall()] queue = [] for s in sigs: if await get_pattern_decision(s) is not None: continue cur = await db.execute( "SELECT v.proposed_group, v.user_id FROM feedback_votes v " "JOIN users u ON u.id = v.user_id " "WHERE v.signature = ? AND (u.is_approved = 1 OR u.is_admin = 1)", (s,), ) per_group: dict[str, set[str]] = {} for g, u in await cur.fetchall(): per_group.setdefault(g, set()).add(u) if len(per_group) < 2: continue total_supports = 0 groups = [] for g, us in per_group.items(): sub = await get_pattern_support(s, g) total_supports = max(total_supports, sub["supports"]) groups.append({"group": g, "supports": sub["supports"], "voters": sorted(us)}) if total_supports >= QUORUM_MIN_USERS: continue for g in groups: g["reputations"] = {u: await user_reputation(u) for u in g["voters"]} queue.append({"signature": s, "groups": groups}) return queue except Exception as e: logger.warning(f"Review queue build failed: {e}") return [] async def upsert_message_feedback(message_id: int, value: str, note: str | None = None, user_id: str | None = None) -> bool: """Store a user's 👍/👎 verdict for an assistant message that had no tool call (or whose round failed before any audit existed). One row per message: pressing the other thumb overwrites the stored verdict; a note can be attached to a 👎 to capture *why* it was wrong (model dropped the intent, asked instead of acting, hallucinated, …). Never raises. Ownership: pass the caller's user_id to accept only their own messages (None = role check only, offline use only). """ if value not in ("up", "down"): return False note = (note or "").strip() or None try: db = await get_db() sql = "SELECT role FROM conversations WHERE id = ?" args: tuple = (message_id,) if user_id is not None: sql += " AND user_id = ?" args = (*args, user_id) async with db.execute(sql, args) as cur: row = await cur.fetchone() if not row or row[0] != "assistant": return False async with _write_lock(): await db.execute( """INSERT INTO message_feedback (message_id, value, note, created_at, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(message_id) DO UPDATE SET value = excluded.value, note = excluded.note, updated_at = CURRENT_TIMESTAMP""", (message_id, value, note), ) await _commit_with_retry(db) return True except Exception as e: logger.warning(f"Message feedback upsert failed for message_id={message_id}: {e}") return False async def link_audits_to_message(conversation_id: int, audit_ids: list[int]): """Bind audit rows to the assistant message that produced them. Called after the assistant message is written, using the audit_ids the stream collected from its SSE tool events. `IS NULL` guard keeps a row owned by an earlier message (e.g. a previous regeneration) untouched. Never raises; returns the number of rows linked. """ if not audit_ids or conversation_id is None: return 0 try: db = await get_db() ph = ",".join("?" * len(audit_ids)) async with _write_lock(): cur = await db.execute( f"UPDATE tool_audit_log SET conversation_id = ? WHERE id IN ({ph}) AND conversation_id IS NULL", (conversation_id, *audit_ids), ) await _commit_with_retry(db) return cur.rowcount except Exception as e: logger.warning(f"Audit->message link failed for conversation_id={conversation_id}: {e}") return 0 async def purge_intent_audit(days: int = 30) -> int: """Delete intent audit rows older than `days`. Never raises; returns deleted count.""" try: db = await get_db() async with _write_lock(): cur = await db.execute( "DELETE FROM intent_audit_log WHERE created_at < datetime('now', ?)", (f"-{days} days",), ) await _commit_with_retry(db) return cur.rowcount except Exception as e: logger.warning(f"Intent audit purge failed: {e}") return 0 # -- Audit export before retention rollup -- # The full detail-row shape: every audit column plus the conversation the row # was linked to (thumbs/fine-tuning context survives the 14-day purge). AUDIT_EXPORT_COLUMNS = [ "id", "tool_name", "params", "success", "duration_ms", "error", "is_summary", "day", "total_calls", "success_count", "error_count", "tool_breakdown", "created_at", "expected_tool", "corrected_at", "verification_status", "expected_group", "confirmed_at", "conversation_id", "session_id", ] def audit_export_dir() -> str: """Directory for pre-rollup audit exports. `AUDIT_EXPORT_DIR` env wins; otherwise ``/audit_exports`` — in production that is ``audit_exports/`` next to ``assistant.db``, and in tests it lands inside the tmp fixture DB's own directory (no repo litter). """ env = os.getenv("AUDIT_EXPORT_DIR") if env: return env return os.path.join(os.path.dirname(DB_PATH), "audit_exports") # Spreadsheet formula-injection guard (OWASP): a cell Excel/Sheets would # EXECUTE starts with one of these. '-' is deliberately NOT guarded — negative # numbers and dash-led messages must stay machine-readable, and our params # are JSON objects (they start with '{'), so the practical attack surface is # the free-text error column. _CSV_INJECTION_CHARS = ("=", "+", "@", "\t", "\r") def _csv_safe_cell(value) -> str: """Stringify one CSV cell, neutralising spreadsheet formula injection.""" s = "" if value is None else str(value) if s and s[0] in _CSV_INJECTION_CHARS: return "'" + s return s def _write_audit_csv(day: str, rows: list[tuple]) -> int: """Write `rows` to ``tool-audit-.csv`` atomically (tmp + os.replace). Runs in a worker thread; overwrites the day's file (idempotent — a crash after the write but before the rollup commit just re-writes the same file). Raises on failure so the caller's rollback keeps the rows for the next try. Rows arrive ORDER BY id (see export_tool_audit_day): same rows in, same bytes out — a re-export is sha256-identical. """ outdir = audit_export_dir() os.makedirs(outdir, exist_ok=True) final = os.path.join(outdir, f"tool-audit-{day}.csv") tmp = final + ".tmp" with open(tmp, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(AUDIT_EXPORT_COLUMNS) for r in rows: w.writerow([_csv_safe_cell(v) for v in r]) os.replace(tmp, final) return len(rows) def _write_audit_ndjson(day: str, rows: list[tuple]) -> int: """Write `rows` to ``tool-audit-.ndjson.gz`` (atomic, deterministic). One JSON object per line; key order fixed by AUDIT_EXPORT_COLUMNS (dicts preserve insertion order — no sort_keys). gzip with mtime=0 and an empty stored filename: the default gzip header embeds the clock, which would make byte-identical rows produce different files. Deterministic bytes are the point — the manifest's sha256 is only comparable across runs if two exports of the same rows hash the same. """ outdir = audit_export_dir() os.makedirs(outdir, exist_ok=True) final = os.path.join(outdir, f"tool-audit-{day}.ndjson.gz") tmp = final + ".tmp" with open(tmp, "wb") as raw: with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz: for r in rows: obj = {c: v for c, v in zip(AUDIT_EXPORT_COLUMNS, r)} gz.write(json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n") os.replace(tmp, final) return len(rows) def _file_sha256(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def _write_audit_manifest(day: str, files: dict) -> None: """Write ``tool-audit-.manifest.json`` atomically (integrity record). `files` maps filename -> {"sha256", "bytes"} of the archived artefacts. Written LAST of the three: a day whose manifest exists is a day whose archive completed — a crash before it leaves no manifest, which is the detectable gap (industry pattern: manifest binds the export). `generated_at` is envelope metadata, deliberately outside the determinism guarantee that applies to the data files. """ import datetime as _dt outdir = audit_export_dir() os.makedirs(outdir, exist_ok=True) final = os.path.join(outdir, f"tool-audit-{day}.manifest.json") tmp = final + ".tmp" manifest = { "format": "pisynapse-audit-export", "version": 1, "day": day, "files": files, "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"), } with open(tmp, "w", encoding="utf-8") as f: json.dump(manifest, f, ensure_ascii=False, indent=2, sort_keys=True) f.write("\n") os.replace(tmp, final) def _write_audit_exports(day: str, rows: list[tuple]) -> int: """Archive one day: CSV + NDJSON.gz + manifest (worker thread). Returns n. Any raise propagates so the rollup transaction rolls back — details are never deleted without a complete archive on disk. """ _write_audit_csv(day, rows) _write_audit_ndjson(day, rows) outdir = audit_export_dir() files = {} for name in (f"tool-audit-{day}.csv", f"tool-audit-{day}.ndjson.gz"): p = os.path.join(outdir, name) files[name] = {"sha256": _file_sha256(p), "bytes": os.path.getsize(p)} _write_audit_manifest(day, files) return len(rows) async def export_tool_audit_day(day: str, cutoff_sql: str) -> int: """Fetch and archive every detail row of `day`; returns row count. Called INSIDE the rollup transaction: on success the caller proceeds to summarize + delete; on exception it rolls back, so nothing is discarded until its archive exists. ORDER BY t.id fixes the row order — exports are deterministic (byte-identical for identical rows, sha256-checkable). """ cols = ", ".join( "c.session_id" if c == "session_id" else "t." + c for c in AUDIT_EXPORT_COLUMNS ) db = await get_db() cur = await db.execute( f"""SELECT {cols} FROM tool_audit_log t LEFT JOIN conversations c ON c.id = t.conversation_id WHERE t.is_summary = 0 AND t.created_at < datetime('now', ?) AND substr(t.created_at, 1, 10) = ? ORDER BY t.id""", (cutoff_sql, day), ) rows = await cur.fetchall() if not rows: return 0 return await asyncio.to_thread(_write_audit_exports, day, rows) async def verify_audit_export(day: str) -> dict: """Re-hash one day's archived files against its manifest. Never raises. Returns ``{"day", "exists", "ok", "files": {name: {sha256_ok, bytes_ok}}}``. ``exists=False`` means no manifest — either the day was never archived or it predates the manifest format (legacy CSV-only days). ``ok`` is True only when every file listed in the manifest is present and matches. """ out: dict = {"day": day, "exists": False, "ok": False, "files": {}} try: outdir = audit_export_dir() manifest_path = os.path.join(outdir, f"tool-audit-{day}.manifest.json") if not os.path.isfile(manifest_path): return out with open(manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) out["exists"] = True all_ok = True for name, meta in (manifest.get("files") or {}).items(): p = os.path.join(outdir, name) entry = {"sha256_ok": False, "bytes_ok": False} if os.path.isfile(p): entry["bytes_ok"] = os.path.getsize(p) == meta.get("bytes") entry["sha256_ok"] = _file_sha256(p) == meta.get("sha256") all_ok = all_ok and entry["sha256_ok"] and entry["bytes_ok"] out["files"][name] = entry out["ok"] = all_ok and bool(out["files"]) except Exception as e: logger.warning(f"verify_audit_export({day}) failed: {e}") return out def _build_day_stats(rows: list[tuple]) -> dict: """Aggregate detail rows into the per-day stats dict stored in `stats_json`. `rows` are (tool_name, user_id, success, duration_ms, verification_status) tuples — the same projection used by the rollup INSERT and by the dashboard's live-day aggregation, so both paths always agree. NULL durations are skipped; NULL user_ids count as "unknown"; NULL verification_status means "not applicable" and is omitted (only ID-verified tools record a status). p95 is nearest-rank (ceil(0.95*n)-1). Deterministic and pure — no I/O, no time. """ tools: dict[str, int] = {} users: dict[str, dict[str, int]] = {} durations: list[float] = [] verify: dict[str, int] = {} success_total = 0 for tool_name, user_id, success, duration_ms, vstatus in rows: tools[tool_name] = tools.get(tool_name, 0) + 1 u = user_id or "unknown" bucket = users.setdefault(u, {"total": 0, "success": 0, "error": 0}) bucket["total"] += 1 if success: success_total += 1 bucket["success"] += 1 else: bucket["error"] += 1 if duration_ms is not None: durations.append(float(duration_ms)) if vstatus: verify[vstatus] = verify.get(vstatus, 0) + 1 dur: dict = {"n": len(durations)} if durations: ordered = sorted(durations) dur["min"] = round(ordered[0], 1) dur["max"] = round(ordered[-1], 1) dur["avg"] = round(sum(ordered) / len(ordered), 1) dur["p95"] = round(ordered[max(0, math.ceil(0.95 * len(ordered)) - 1)], 1) return { "tools": tools, "users": users, "dur_ms": dur, "verify": verify, "success": success_total, } async def rollup_tool_audit(days: int | None = None) -> int: """Compress detail rows older than `days` into one daily summary row per day. Retention policy: detail rows survive for `days` (default: config ``AUDIT_RETENTION_DAYS``, 14), then are exported to ``audit_exports/`` as per-day CSV files (``tool-audit-YYYY-MM-DD.csv``, one row per audited call, incl. the linked conversation), aggregated into a per-day summary (total/success/error counts + per-tool breakdown + `stats_json`: per-user counts, duration min/avg/max/p95, verification breakdown) and the detail rows are removed. Each day is processed inside its own ``BEGIN IMMEDIATE`` transaction so the export completion, the summary INSERT and the detail DELETE are all-or-nothing: a failed export rolls the day back and nothing is discarded until a copy exists on disk. Days that already have a summary row are skipped, so a crash between runs cannot produce duplicate summaries (or duplicate exports — the day's CSV is overwritten in place). Idempotent and never raises. Returns the number of days rolled up. """ import sqlite3 from config import AUDIT_RETENTION_DAYS if days is None: days = max(1, AUDIT_RETENTION_DAYS) try: db = await get_db() cutoff = f"-{days} days" cur = await db.execute( """SELECT substr(created_at, 1, 10) AS day FROM tool_audit_log WHERE is_summary = 0 AND created_at < datetime('now', ?) AND substr(created_at, 1, 10) NOT IN (SELECT day FROM tool_audit_log WHERE is_summary = 1) GROUP BY substr(created_at, 1, 10)""", (cutoff,), ) days_found = [row[0] for row in await cur.fetchall()] days_summarized = 0 for day in days_found: # Each day is atomic: summary INSERT + detail DELETE in one txn. # The process-wide write lock is held for the whole transaction: # every other write path takes it, and without it another # writer's statements would JOIN this open transaction on the # shared connection — and roll back WITH it when a busy retry # discards the day (or BEGIN IMMEDIATE would collide with the # other path's implicit transaction and the generic rollback # would kill ITS partial write). async with _write_lock(): for attempt in range(_LOCKED_RETRIES + 1): try: await db.execute("BEGIN IMMEDIATE") cur2 = await db.execute( """SELECT tool_name, user_id, success, duration_ms, verification_status FROM tool_audit_log WHERE is_summary = 0 AND created_at < datetime('now', ?) AND substr(created_at, 1, 10) = ?""", (cutoff, day), ) day_rows = await cur2.fetchall() stats = _build_day_stats(day_rows) total = len(day_rows) success_count = stats["success"] # Archive first, delete second: a failed export raises and # rolls the day back, so detail rows are never discarded # without a CSV copy existing on disk. await export_tool_audit_day(day, cutoff) await db.execute( """INSERT INTO tool_audit_log (is_summary, day, total_calls, success_count, error_count, tool_breakdown, tool_name, success, created_at, expected_tool, corrected_at, stats_json) VALUES (1, ?, ?, ?, ?, ?, 'rollup', 1, ?, NULL, NULL, ?)""", (day, total, success_count, total - success_count, json.dumps(stats["tools"]), day, json.dumps(stats, separators=(",", ":"))), ) await db.execute( """DELETE FROM tool_audit_log WHERE is_summary = 0 AND created_at < datetime('now', ?) AND substr(created_at, 1, 10) = ?""", (cutoff, day), ) await _commit_with_retry(db) days_summarized += 1 break except sqlite3.OperationalError as e: try: await db.rollback() except Exception: pass if "locked" not in str(e).lower() and "busy" not in str(e).lower(): raise if attempt >= _LOCKED_RETRIES: raise logger.warning(f"SQLite busy during rollup of {day}, retrying ({attempt + 1}/{_LOCKED_RETRIES})...") await asyncio.sleep(0.25 * (attempt + 1)) except Exception: try: await db.rollback() except Exception: pass raise if days_summarized: logger.info(f"Tool audit rollup: {days_summarized} day(s) summarized") return days_summarized except Exception as e: logger.warning(f"Tool audit rollup failed: {e}") return 0 ROLLUP_INTERVAL_SECONDS = 24 * 3600 # retention sweep runs once a day _AUDIT_ALERT_MIN_CALLS = 10 # below this a "100% error rate" is noise, not signal async def get_audit_overview(days: int | None = None) -> dict: """Dashboard payload: per-day tool-audit stats for the last `days` days. Merges rolled-up summary rows (is_summary=1, older than retention) with a live aggregation of still-detailed rows, so the trend is continuous across the retention boundary — a gap there would make the dashboard lie exactly when the rollup runs. Newest day first. Each entry carries ``source: "summary" | "detail"`` so the UI can label compressed days. Never raises; returns ``{"days": [], ...}`` on failure. """ from config import AUDIT_RETENTION_DAYS if days is None: days = max(1, AUDIT_RETENTION_DAYS) out: dict = {"days": [], "retention_days": days, "window_days": days} try: db = await get_db() cur = await db.execute( """SELECT day, total_calls, success_count, error_count, tool_breakdown, stats_json FROM tool_audit_log WHERE is_summary = 1 ORDER BY day DESC LIMIT ?""", (days,), ) merged: dict[str, dict] = {} for day, total, succ, err, breakdown, stats_json in await cur.fetchall(): try: stats = json.loads(stats_json) if stats_json else {} except (ValueError, TypeError): stats = {} try: tools = json.loads(breakdown) if breakdown else stats.get("tools", {}) except (ValueError, TypeError): tools = stats.get("tools", {}) total = total or 0 merged[day] = { "day": day, "total": total, "success": succ or 0, "errors": err or 0, "error_rate": round((err or 0) / total, 3) if total else 0.0, "tools": tools, "users": stats.get("users", {}), "dur_ms": stats.get("dur_ms", {}), "verify": stats.get("verify", {}), "source": "summary", } # Live days (within the window, not yet rolled up): aggregate details # with the SAME _build_day_stats the rollup uses, per day. cur = await db.execute( """SELECT substr(created_at, 1, 10) AS day, tool_name, user_id, success, duration_ms, verification_status FROM tool_audit_log WHERE is_summary = 0 AND created_at >= datetime('now', ?) ORDER BY created_at""", (f"-{days} days",), ) by_day: dict[str, list] = {} for day, tool_name, user_id, success, dur, vstatus in await cur.fetchall(): by_day.setdefault(day, []).append((tool_name, user_id, success, dur, vstatus)) for day, rows in by_day.items(): if day in merged: continue # already summarized (crash-residue); summary wins stats = _build_day_stats(rows) total = len(rows) succ = stats["success"] merged[day] = { "day": day, "total": total, "success": succ, "errors": total - succ, "error_rate": round((total - succ) / total, 3) if total else 0.0, "tools": stats["tools"], "users": stats["users"], "dur_ms": stats["dur_ms"], "verify": stats["verify"], "source": "detail", } out["days"] = sorted(merged.values(), key=lambda d: d["day"], reverse=True) except Exception as e: logger.warning(f"get_audit_overview failed: {e}") return out async def audit_error_alert(window_hours: int = 24) -> dict: """Rolling error-rate check across detail rows AND summary rows. Summaries contribute their success_count/error_count: right after a sweep a details-only window would read empty and the alarm would miss a catastrophic day that just got rolled up. Whole summaries inside the window count fully (a deliberate coarseness — an alarm, not an accounting). Threshold: config ``AUDIT_ERROR_RATE_ALERT`` (0 = off); ignored below ``_AUDIT_ALERT_MIN_CALLS`` total calls. Never raises. """ from config import AUDIT_ERROR_RATE_ALERT out = { "window_hours": window_hours, "total": 0, "success": 0, "errors": 0, "error_rate": 0.0, "threshold": AUDIT_ERROR_RATE_ALERT, "min_calls": _AUDIT_ALERT_MIN_CALLS, "breached": False, } try: db = await get_db() cur = await db.execute( """SELECT COUNT(*), COALESCE(SUM(success), 0) FROM tool_audit_log WHERE is_summary = 0 AND created_at >= datetime('now', ?)""", (f"-{window_hours} hours",), ) det_total, det_success = await cur.fetchone() cur = await db.execute( """SELECT COALESCE(SUM(total_calls), 0), COALESCE(SUM(success_count), 0) FROM tool_audit_log WHERE is_summary = 1 AND day >= date('now', ?)""", (f"-{window_hours} hours",), ) sum_total, sum_success = await cur.fetchone() total = (det_total or 0) + (sum_total or 0) success = (det_success or 0) + (sum_success or 0) errors = max(0, total - success) out.update({ "total": total, "success": success, "errors": errors, "error_rate": round(errors / total, 3) if total else 0.0, }) out["breached"] = bool( AUDIT_ERROR_RATE_ALERT > 0 and total >= _AUDIT_ALERT_MIN_CALLS and out["error_rate"] > AUDIT_ERROR_RATE_ALERT ) except Exception as e: logger.warning(f"audit_error_alert failed: {e}") return out def _error_cluster_key(error: str) -> str: """Normalize one error message into a cluster key (digits/ids stripped). "timeout after 12s" and "timeout after 40s" must count as ONE recurring failure, not two — numbers (durations, ids, ports) are the noise between copies of the same message. Capped at 80 chars: a cluster key is a label, not the evidence (the CSV archive keeps the full text). """ return re.sub(r"\d+", "#", (error or "").strip().lower())[:80] async def audit_failure_digest(days: int = 7) -> dict: """Clustered summary of the last `days` days of tool failures + signals. Reads DETAIL rows only (summaries keep counts but not error text — fine, since the default 7d window sits inside the 14d detail retention). Surfaces what the weekly flywheel digest needs: which tools fail, which normalized error recurs, and how many corrections/confirmations/verification failures the users produced. Never raises. """ out: dict = { "window_days": days, "total_calls": 0, "errors": 0, "corrections": 0, "confirmations": 0, "verify_failed": 0, "top_tools": [], "top_errors": [], } try: db = await get_db() cur = await db.execute( """SELECT tool_name, success, error, expected_group IS NOT NULL, confirmed_at IS NOT NULL, verification_status = 'verification_failed' FROM tool_audit_log WHERE is_summary = 0 AND created_at >= datetime('now', ?)""", (f"-{days} days",), ) tool_stats: dict[str, dict] = {} err_clusters: dict[str, int] = {} total = errors = corrections = confirmations = vfail = 0 for tool_name, success, error, corrected, confirmed, v_failed in await cur.fetchall(): total += 1 ts = tool_stats.setdefault(tool_name, {"errors": 0, "total": 0}) ts["total"] += 1 if not success: errors += 1 ts["errors"] += 1 key = _error_cluster_key(error or "unknown") err_clusters[key] = err_clusters.get(key, 0) + 1 corrections += 1 if corrected else 0 confirmations += 1 if confirmed else 0 vfail += 1 if v_failed else 0 out.update({ "total_calls": total, "errors": errors, "corrections": corrections, "confirmations": confirmations, "verify_failed": vfail, }) out["top_tools"] = sorted( ({"tool": t, **s, "error_rate": round(s["errors"] / s["total"], 3)} for t, s in tool_stats.items() if s["errors"]), key=lambda d: (-d["errors"], d["tool"]), )[:10] out["top_errors"] = sorted( ({"error": k, "count": n} for k, n in err_clusters.items()), key=lambda d: (-d["count"], d["error"]), )[:10] except Exception as e: logger.warning(f"audit_failure_digest failed: {e}") return out async def periodic_rollup_loop(interval: float = ROLLUP_INTERVAL_SECONDS, sleep=asyncio.sleep): """Run rollup_tool_audit() once per `interval` until cancelled. Mirrors the "never raise" principle of the verification hook: any failure (DB lock, disk issue, ...) is logged and retried on the next cycle instead of crashing the service. Cancellation propagates so the app can shut down cleanly. `sleep` is injectable for tests (avoids waiting a real 24 hours). """ while True: await sleep(interval) try: await rollup_tool_audit() await purge_intent_audit() except asyncio.CancelledError: raise except Exception as e: logger.warning(f"Periodic rollup failed, will retry next cycle: {e}") # Health check rides the same cadence; never raises. A breach is a # loud log line — the dashboard renders the same numbers live. try: alert = await audit_error_alert() if alert["breached"]: logger.warning( f"Tool error-rate alert: {alert['errors']}/{alert['total']} " f"calls failed in the last {alert['window_hours']}h " f"(rate {alert['error_rate']:.0%} > threshold {alert['threshold']:.0%})" ) except asyncio.CancelledError: raise except Exception as e: logger.warning(f"Periodic audit alert check failed: {e}") CLEANUP_INTERVAL_SECONDS = 24 * 3600 # retention cleanup runs once a day async def periodic_cleanup_loop(interval: float = CLEANUP_INTERVAL_SECONDS, sleep=asyncio.sleep): """Run cleanup_expired_data() once per `interval` until cancelled. Mirrors the "never raise" principle of the retention sweep — cleanup failures are logged and retried on the next cycle instead of crashing the service. Cancellation propagates so the app can shut down cleanly. `sleep` is injectable for tests (avoids waiting a real 24 hours). """ while True: await sleep(interval) try: await cleanup_expired_data() except asyncio.CancelledError: raise except Exception as e: logger.warning(f"Periodic retention cleanup failed, will retry next cycle: {e}") # Daily online backup rides the same cadence (best-effort). try: await auto_backup_db() except asyncio.CancelledError: raise except Exception as e: logger.warning(f"Periodic auto backup failed, will retry next cycle: {e}") # Keep the WAL file bounded: checkpoint whatever readers no longer need. # Best-effort; a busy checkpoint simply retries next cycle. try: db = await get_db() await db.execute("PRAGMA wal_checkpoint(PASSIVE)") except asyncio.CancelledError: raise except Exception as e: logger.debug(f"WAL checkpoint skipped: {e}") # -- Conversations -- async def save_message(session_id: str, role: str, content: str, images: list[str] | None = None, reasoning: str | None = None, user_id: str = "default"): import json images_json = json.dumps(images) if images else None db = await get_db() # Retry dedup: same user message as the last row → skip to avoid # duplicate context when the frontend re-sends after regenerate. if role == "user": async with db.execute( "SELECT role, content FROM conversations " "WHERE session_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1", (session_id, user_id) ) as cur: last = await cur.fetchone() if last and last[0] == "user" and last[1] == content: return # duplicate — do not insert # Generate embedding for semantic search (best-effort, never blocks chat on failure) embedding_blob = None if content and content.strip(): try: from embedding import embed_async embedding_blob = await embed_async(content) except Exception as e: logger.warning(f"Embedding failed for save_message (non-fatal): {e}") async with _write_lock(): # Ownership guard: a session row owned by another user must not # accept writes. Every read path filters by user_id, so the row # would be an unreadable orphan that still pollutes FTS, storage # and any future session-scoped maintenance. Fail-closed with the # same no-op contract as the duplicate guard above: return None. async with db.execute( "SELECT 1 FROM sessions WHERE id = ? AND user_id <> ?", (session_id, user_id), ) as cur: if await cur.fetchone() is not None: return None cur = await db.execute( "INSERT INTO conversations (session_id, role, content, images, reasoning, embedding, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)", (session_id, role, content, images_json, reasoning, embedding_blob, user_id), ) # Keep FTS5 index in sync (cursor.lastrowid: SELECT last_insert_rowid() # on the shared connection can return another task's row under concurrency). rowid = cur.lastrowid try: await db.execute( "INSERT INTO conversations_fts (rowid, content, session_id) VALUES (?, ?, ?)", (rowid, content, session_id), ) except Exception as fts_e: # FTS5 is best-effort; a failure must not eat the message row. logger.warning(f"save_message FTS insert failed (non-fatal): {fts_e}") await db.execute( """INSERT INTO sessions (id, user_id) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET last_active = CURRENT_TIMESTAMP WHERE sessions.user_id = excluded.user_id""", (session_id, user_id), ) if role == "user": existing = await db.execute("SELECT name FROM sessions WHERE id = ? AND user_id = ?", (session_id, user_id)) row = await existing.fetchone() if not row or not row[0]: from title import generate_instant_title name = generate_instant_title(content) await db.execute( "UPDATE sessions SET name = ? WHERE id = ? AND user_id = ?", (name, session_id, user_id), ) await _commit_with_retry(db) return rowid async def delete_last_assistant(session_id: str, user_id: str = "default") -> bool: """Delete the last assistant message in a session. Returns True if a row was removed, False if no assistant message existed. Used by the regenerate flow: the old response is purged before the user message is re-sent so the model never sees the stale reply in context. """ db = await get_db() async with db.execute( "SELECT id FROM conversations " "WHERE session_id = ? AND user_id = ? AND role = 'assistant' ORDER BY id DESC LIMIT 1", (session_id, user_id), ) as cur: row = await cur.fetchone() if not row: return False async with _write_lock(): await db.execute("DELETE FROM conversations WHERE id = ?", (row[0],)) await db.execute("DELETE FROM conversations_fts WHERE rowid = ?", (row[0],)) await db.execute("DELETE FROM message_feedback WHERE message_id = ?", (row[0],)) await db.execute("DELETE FROM tool_audit_log WHERE conversation_id = ?", (row[0],)) await _repair_summary_boundaries(db) await _commit_with_retry(db) return True async def delete_branch(session_id: str, anchor_id: int, user_id: str = "default") -> list[int]: """Truncate a conversation from an anchor message onward (regenerate branch). Deletes the anchored message and everything saved after it (messages are appended strictly in id order), so a regenerated reply never sits on a stale tail — both in the model's context window and in the UI's history. Returns the list of deleted message ids (FTS rows are removed in lockstep). Unlike delete_last_assistant this is position-agnostic: the frontend picks ANY assistant message to regenerate and the whole downstream tail goes away, exactly like ChatGPT/Claude's branch semantics for old responses. """ db = await get_db() async with db.execute( "SELECT id FROM conversations WHERE session_id = ? AND user_id = ? AND id >= ? ORDER BY id", (session_id, user_id, anchor_id), ) as cur: ids = [r[0] for r in await cur.fetchall()] async with _write_lock(): if ids: ph = ",".join("?" * len(ids)) await db.execute( f"DELETE FROM conversations WHERE session_id = ? AND id IN ({ph})", (session_id, *ids), ) await db.execute(f"DELETE FROM conversations_fts WHERE rowid IN ({ph})", ids) await db.execute(f"DELETE FROM message_feedback WHERE message_id IN ({ph})", ids) await db.execute( f"DELETE FROM tool_audit_log WHERE conversation_id IN ({ph})", ids ) # Keep the session alive + surfaced even though no rows were inserted. await db.execute( "INSERT INTO sessions (id, user_id) VALUES (?, ?) " "ON CONFLICT(id) DO UPDATE SET last_active = CURRENT_TIMESTAMP " "WHERE sessions.user_id = excluded.user_id", (session_id, user_id), ) await _repair_summary_boundaries(db) await _commit_with_retry(db) return ids async def get_history(session_id: str, limit: int = 20, include_reasoning: bool = False, include_audits: bool = False, user_id: str = "default") -> list[dict]: import json db = await get_db() async with db.execute( """SELECT id, role, content, images, timestamp, reasoning FROM conversations WHERE session_id = ? AND user_id = ? ORDER BY id DESC LIMIT ?""", (session_id, user_id, limit), ) as cur: rows = await cur.fetchall() # Per-assistant-message audit list for the C-7 feedback UI. Only used by # /chat/history; the LLM context path keeps include_audits=False so audit # data never pollutes model input. audits_by_msg: dict[int, list[dict]] = {} fb_by_msg: dict[int, tuple] = {} if include_audits and rows: ids = [r[0] for r in rows] ph = ",".join("?" * len(ids)) async with db.execute( f"""SELECT m.id, a.id, a.tool_name, a.confirmed_at, a.corrected_at, a.expected_group FROM conversations m JOIN tool_audit_log a ON a.conversation_id = m.id WHERE m.id IN ({ph}) ORDER BY m.id, a.id""", tuple(ids), ) as cur: for mid, aid, tool, c_at, cor_at, grp in await cur.fetchall(): audits_by_msg.setdefault(mid, []).append( {"audit_id": aid, "tool_name": tool, "confirmed_at": c_at, "corrected_at": cor_at, "expected_group": grp} ) # Message-level verdict (👍/👎) for messages without tool audits — # same /chat/history-only gate so context never sees it. async with db.execute( f"SELECT message_id, value, note FROM message_feedback WHERE message_id IN ({ph})", tuple(ids), ) as cur: for mid, val, note in await cur.fetchall(): fb_by_msg[mid] = (val, note) result = [] for r in reversed(rows): item = {"id": r[0], "role": r[1], "content": r[2], "timestamp": r[4]} if r[3]: try: item["images"] = json.loads(r[3]) except Exception: pass if include_reasoning and r[5]: item["reasoning"] = r[5] if include_audits and r[0] in audits_by_msg: item["audits"] = audits_by_msg[r[0]] if include_audits and r[0] in fb_by_msg: fbval, fbnote = fb_by_msg[r[0]] item["feedback"] = fbval if fbnote: item["feedback_note"] = fbnote result.append(item) return result async def get_all_history(user_id: str = "default", limit: int | None = None, offset: int = 0) -> dict[str, list[dict]]: """Return every conversation grouped by session_id, oldest first. Used by the single-request Android chat sync pull so a phone can restore its sidebar in one HTTP round-trip instead of one call per session (which trips the 30 rpm rate limiter). ``limit``/``offset`` paginate that pull; the default (None) keeps the historical unbounded contract for the sync caller. """ import json db = await get_db() history: dict[str, list[dict]] = {} sql = ( """SELECT session_id, id, role, content, images, timestamp, reasoning FROM conversations WHERE user_id = ? ORDER BY session_id, id ASC""" ) params: tuple = (user_id,) if limit is not None: sql += " LIMIT ? OFFSET ?" params = (user_id, limit, offset) async with db.execute(sql, params) as cur: rows = await cur.fetchall() for sid, mid, role, content, images, ts, reasoning in rows: item = {"id": mid, "role": role, "content": content, "timestamp": ts} if images: try: item["images"] = json.loads(images) except Exception: pass if reasoning: item["reasoning"] = reasoning history.setdefault(sid, []).append(item) return history async def clear_history(session_id: str, user_id: str = "default"): db = await get_db() async with _write_lock(): # Feedback/audit cascade first: their subqueries need the rows present. await db.execute( "DELETE FROM message_feedback WHERE message_id IN " "(SELECT id FROM conversations WHERE session_id = ? AND user_id = ?)", (session_id, user_id), ) await db.execute( "DELETE FROM tool_audit_log WHERE conversation_id IN " "(SELECT id FROM conversations WHERE session_id = ? AND user_id = ?)", (session_id, user_id), ) # FTS first: the rowid subquery needs the conversation rows still present. await db.execute( "DELETE FROM conversations_fts WHERE rowid IN " "(SELECT id FROM conversations WHERE session_id = ? AND user_id = ?)", (session_id, user_id), ) await db.execute("DELETE FROM conversations WHERE session_id = ? AND user_id = ?", (session_id, user_id)) await db.execute("DELETE FROM sessions WHERE id = ? AND user_id = ?", (session_id, user_id)) await db.execute("DELETE FROM email_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id)) await db.execute("DELETE FROM notes_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id)) await db.execute("DELETE FROM tasks_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id)) await db.execute("DELETE FROM calendar_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id)) await _repair_summary_boundaries(db) await _commit_with_retry(db) async def import_messages(session_id: str, messages: list[dict], client_key: str, user_id: str = "default") -> int: """Idempotently import a batch of chat messages from a paired device. Each message must carry a stable `client_key` (uuid + index) so re-syncs never duplicate rows. Session metadata is created on demand and the conversation title is regenerated from the first user message if missing. """ db = await get_db() inserted = 0 async with _write_lock(): for i, m in enumerate(messages): role = (m.get("role") or "").strip() content = (m.get("content") or "").strip() if role not in ("user", "assistant") or not content: continue key = f"{client_key}:{i}" async with db.execute( "SELECT 1 FROM conversations WHERE client_key = ? AND user_id = ?", (key, user_id) ) as cur: if await cur.fetchone(): continue try: cur = await db.execute( "INSERT INTO conversations (session_id, role, content, client_key, user_id) VALUES (?, ?, ?, ?, ?)", (session_id, role, content, key, user_id), ) rowid = cur.lastrowid try: await db.execute( "INSERT INTO conversations_fts (rowid, content, session_id) VALUES (?, ?, ?)", (rowid, content, session_id), ) except Exception as fts_e: # FTS5 is best-effort; a failure must not eat the message row. logger.warning(f"import FTS insert failed (non-fatal): {fts_e}") inserted += 1 except Exception as e: # A lost TOCTOU race lands here as a UNIQUE violation: the # winning task already inserted this key, so skipping is the # correct idempotent outcome (debug, not warning). if "UNIQUE constraint failed" in str(e): logger.debug(f"import deduped by constraint (session={session_id}): {e}") else: logger.warning(f"import insert failed (session={session_id}): {e}") continue if inserted: await db.execute( "INSERT INTO sessions (id, user_id) VALUES (?, ?) " "ON CONFLICT(id) DO UPDATE SET last_active = CURRENT_TIMESTAMP " "WHERE sessions.user_id = excluded.user_id", (session_id, user_id), ) # Title from first user message if the server has none. async with db.execute("SELECT name FROM sessions WHERE id = ? AND user_id = ?", (session_id, user_id)) as cur: row = await cur.fetchone() if not row or not row[0]: title_src = next((m.get("content", "").strip() for m in messages if m.get("role") == "user"), "") if title_src: from title import generate_instant_title await db.execute( "UPDATE sessions SET name = ? WHERE id = ? AND user_id = ?", (generate_instant_title(title_src), session_id, user_id), ) await _commit_with_retry(db) return inserted async def search_sessions(query: str, limit: int = 20, user_id: str = "default") -> list[dict]: """Hybrid search: FTS5 keyword + semantic embedding. FTS5 gives exact keyword matches (ranked by BM25), semantic gives meaning matches (e.g. 'hava nasıl' ↔ 'weather outside') via cosine similarity. Results are merged, deduplicated, and capped at `limit`. Falls back to LIKE if FTS5 query fails. """ import re as _re db = await get_db() # Sanitize for FTS5: strip special match characters safe_q = _re.sub(r'[^\w\s]', '', query).strip() if not safe_q: return [] # FTS: try AND first (precise), fallback to OR if no hits terms = safe_q.split() fts_query_and = " AND ".join(terms) fts_query_or = " OR ".join(terms) rows: list = [] try: async with db.execute(""" SELECT f.session_id, s.name, snippet(conversations_fts, 0, '', '', '…', 12) AS snippet FROM conversations_fts f JOIN sessions s ON s.id = f.session_id AND s.user_id = ? WHERE conversations_fts MATCH ? ORDER BY rank LIMIT ? """, (user_id, fts_query_and, limit)) as cur: rows = await cur.fetchall() # If AND gave no results and query has multiple terms, try OR for recall if not rows and len(terms) > 1: async with db.execute(""" SELECT f.session_id, s.name, snippet(conversations_fts, 0, '', '', '…', 12) AS snippet FROM conversations_fts f JOIN sessions s ON s.id = f.session_id AND s.user_id = ? WHERE conversations_fts MATCH ? ORDER BY rank LIMIT ? """, (user_id, fts_query_or, limit)) as cur: rows = await cur.fetchall() except Exception: # Fallback: LIKE on raw table (safe_q, escape %/_) safe_like = safe_q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") async with db.execute(""" SELECT DISTINCT c.session_id, s.name, c.content FROM conversations c LEFT JOIN sessions s ON s.id = c.session_id AND s.user_id = ? WHERE c.content LIKE ? ESCAPE '\\' AND c.user_id = ? ORDER BY c.timestamp DESC LIMIT ? """, (user_id, f"%{safe_like}%", user_id, limit)) as cur: rows = await cur.fetchall() seen = set() results = [] for r in rows: if r[0] not in seen: seen.add(r[0]) results.append({"session_id": r[0], "name": r[1] or "", "snippet": r[2] or ""}) # Semantic supplement: only for multi-word queries and when FTS is sparse. # Single-word exact matches are better served by FTS (semantic gives many # false positives for short queries: "omlet" vs "teşekkürler" 0.637). # Threshold 0.50 for higher precision (memories uses 0.35, but search needs stricter). if len(results) < 5 and len(terms) > 1 and len(results) < limit: try: from embedding import cosine_similarity, embed_async query_emb = await embed_async(safe_q) async with db.execute(""" SELECT c.session_id, s.name, c.content, c.embedding FROM conversations c LEFT JOIN sessions s ON s.id = c.session_id AND s.user_id = c.user_id WHERE c.embedding IS NOT NULL AND c.user_id = ? ORDER BY c.timestamp DESC LIMIT 200 """, (user_id,)) as cur: cand_rows = await cur.fetchall() scored: list[tuple[float, str, str, str]] = [] for sess_id, name, content, blob in cand_rows: if not blob or sess_id in seen: continue try: sim = cosine_similarity(query_emb, blob) except Exception: continue if sim >= 0.50: scored.append((sim, sess_id, name or "", content)) scored.sort(key=lambda x: x[0], reverse=True) for sim, sess_id, name, content in scored[: limit - len(results)]: if sess_id not in seen: snippet = content[:80].replace("\n", " ").strip() + "…" results.append({"session_id": sess_id, "name": name, "snippet": snippet}) seen.add(sess_id) except Exception as e: logger.warning(f"Semantic search supplement failed (non-fatal): {e}") return results # -- Email list-number → message-ID mapping -- # # The model sees email lists as "1., 2., ..." and is told never to show raw # IDs. This table persists the mapping from that list number to the real IMAP # message ID per session, so a follow-up like "read email 3" keeps resolving # correctly even after a restart or when a session is resumed later. Rows are # replaced on every list_emails / search_emails call. async def save_email_map(session_id: str, emails: list[dict], user_id: str = "default"): """Replace the stored email listing for a session with a new one.""" if not session_id or not emails: return user_id = user_id or "default" db = await get_db() async with _write_lock(): await db.execute( "DELETE FROM email_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id), ) for seq, m in enumerate(emails, 1): await db.execute( "INSERT INTO email_session_map (session_id, seq, message_id, subject, sender, preview, user_id) " "VALUES (?, ?, ?, ?, ?, ?, ?)", ( session_id, seq, str(m.get("id", "")), m.get("subject", "")[:200], m.get("from", "")[:200], (m.get("body", "") or "")[:200], user_id, ), ) await _commit_with_retry(db) async def get_email_map(session_id: str, user_id: str = "default") -> list[dict]: """Return the persisted email listing for a session (list-number order).""" if not session_id: return [] user_id = user_id or "default" db = await get_db() async with db.execute( """SELECT message_id, subject, sender, preview FROM email_session_map WHERE session_id = ? AND user_id = ? ORDER BY seq ASC""", (session_id, user_id), ) as cur: rows = await cur.fetchall() return [ {"id": r[0], "subject": r[1], "from": r[2], "preview": r[3]} for r in rows ] async def clear_email_map(session_id: str): """Drop the stored email listing for a session (used on history clear).""" if not session_id: return db = await get_db() await _write_with_retry(db, "DELETE FROM email_session_map WHERE session_id = ?", (session_id,)) # -- Notes Session Map -- # Same pattern as email_session_map: the model sees numbered note lists # ("1., 2., ...") and this table maps list positions to real Nextcloud note # IDs per session, so "read note 3" resolves correctly. async def save_notes_map(session_id: str, notes: list[dict], user_id: str = "default"): """Replace the stored note listing for a session with a new one.""" if not session_id or not notes: return user_id = user_id or "default" db = await get_db() async with _write_lock(): await db.execute( "DELETE FROM notes_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id), ) for seq, n in enumerate(notes, 1): await db.execute( "INSERT INTO notes_session_map (session_id, seq, note_id, title, category, preview, user_id) " "VALUES (?, ?, ?, ?, ?, ?, ?)", ( session_id, seq, int(n.get("id", 0)), n.get("title", "")[:200], n.get("category", "")[:100], (n.get("content", "") or "")[:200], user_id, ), ) await _commit_with_retry(db) async def get_notes_map(session_id: str, user_id: str = "default") -> list[dict]: """Return the persisted note listing for a session (list-number order).""" if not session_id: return [] user_id = user_id or "default" db = await get_db() async with db.execute( """SELECT note_id, title, category, preview FROM notes_session_map WHERE session_id = ? AND user_id = ? ORDER BY seq ASC""", (session_id, user_id,), ) as cur: rows = await cur.fetchall() return [ {"id": r[0], "title": r[1], "category": r[2], "preview": r[3]} for r in rows ] async def clear_notes_map(session_id: str): """Drop the stored note listing for a session (used on history clear).""" if not session_id: return db = await get_db() await _write_with_retry(db, "DELETE FROM notes_session_map WHERE session_id = ?", (session_id,)) # -- Tasks Session Map -- # Same pattern as email/notes: model sees numbered list, we map to real UIDs. async def save_tasks_map(session_id: str, tasks: list[dict], user_id: str = "default"): """Replace the stored task listing for a session with a new one.""" if not session_id or not tasks: return user_id = user_id or "default" db = await get_db() async with _write_lock(): await db.execute( "DELETE FROM tasks_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id), ) for seq, t in enumerate(tasks, 1): await db.execute( "INSERT INTO tasks_session_map (session_id, seq, uid, summary, due, priority, completed, user_id) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( session_id, seq, str(t.get("uid", "")), t.get("summary", "")[:200], t.get("due", "")[:50], int(t.get("priority", 0)), 1 if t.get("completed") else 0, user_id, ), ) await _commit_with_retry(db) async def get_tasks_map(session_id: str, user_id: str = "default") -> list[dict]: """Return the persisted task listing for a session (list-number order).""" if not session_id: return [] user_id = user_id or "default" db = await get_db() async with db.execute( """SELECT uid, summary, due, priority, completed FROM tasks_session_map WHERE session_id = ? AND user_id = ? ORDER BY seq ASC""", (session_id, user_id,), ) as cur: rows = await cur.fetchall() return [ {"uid": r[0], "summary": r[1], "due": r[2], "priority": r[3], "completed": bool(r[4])} for r in rows ] async def clear_tasks_map(session_id: str): """Drop the stored task listing for a session (used on history clear).""" if not session_id: return db = await get_db() await _write_with_retry(db, "DELETE FROM tasks_session_map WHERE session_id = ?", (session_id,)) # -- Sync Items (two-way device sync) -- def _normalize_sync_ts(raw) -> str | None: """Canonical fixed-width ISO-8601 UTC ("YYYY-MM-DDTHH:MM:SS.ffffffZ"). Every comparison path (LWW ``WHERE excluded.updated_at > ...``, the ``since`` pull filter) is a STRING compare — only safe when all values share one format and width, where lexicographic order == chronological order. Free-text client timestamps made that a lie (mixed offsets, no microseconds: "…:00Z" vs "…:00.5Z" compares backwards). Unparseable input returns None (the caller drops the item / ignores the filter). """ if not isinstance(raw, str) or not raw.strip(): return None s = raw.strip() if s.endswith(("Z", "z")): s = s[:-1] + "+00:00" try: import datetime as _dt dt = _dt.datetime.fromisoformat(s) except ValueError: return None if dt.tzinfo is not None: dt = dt.astimezone(_dt.timezone.utc).replace(tzinfo=None) return dt.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z" async def _normalize_stored_sync_timestamps(db: aiosqlite.Connection) -> None: """Every-boot repair: rewrite stored updated_at values to canonical form. Idempotent — only rows whose value differs from its normalization are rewritten, so post-migration boots touch nothing. Canonical rows make the LWW and ``since`` string comparisons valid on legacy data too. """ try: cur = await db.execute("SELECT rowid, updated_at FROM sync_items") rows = await cur.fetchall() except Exception: return # table missing pre-migration — nothing to do for rowid, ts in rows: norm = _normalize_sync_ts(ts) if norm is not None and norm != ts: await db.execute( "UPDATE sync_items SET updated_at = ? WHERE rowid = ?", (norm, rowid), ) async def upsert_sync_items(user_id: str, items: list[dict]) -> int: """Upsert remote sync items for a user. Each item: uuid|entity_type|data|updated_at|deleted|origin. Last-write-wins: a STRICTLY newer updated_at overwrites the stored row wholesale; an equal or older write is dropped (a delayed old push can never clobber newer data). Timestamps are normalized to canonical fixed-width UTC so the comparison is valid. Returns the number of rows actually stored. """ if not items: return 0 db = await get_db() n = 0 async with _write_lock(): for it in items: uuid = str(it.get("uuid") or "").strip() etype = str(it.get("entity_type") or "").strip() or "generic" data = it.get("data") or {} if not isinstance(data, str): data = json.dumps(data, ensure_ascii=False, default=str) ts = _normalize_sync_ts(it.get("updated_at")) deleted = 1 if it.get("deleted") else 0 origin = str(it.get("origin") or "").strip() if not uuid or ts is None: continue # fail-closed: no key or unparseable timestamp await db.execute( """INSERT INTO sync_items (uuid, user_id, entity_type, data, updated_at, deleted, origin) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, uuid) DO UPDATE SET data = excluded.data, entity_type = excluded.entity_type, updated_at = excluded.updated_at, deleted = excluded.deleted, origin = excluded.origin WHERE excluded.updated_at > sync_items.updated_at""", (uuid, user_id, etype, data, ts, deleted, origin), ) # Py3.12: INSERT rowcount is -1 — read SELECT changes() instead. # Counts 1 for a real insert/update, 0 for an LWW-dropped write. ccur = await db.execute("SELECT changes()") n += (await ccur.fetchone())[0] await _commit_with_retry(db) return n async def get_sync_items(user_id: str, entity_type: str | None = None, since: str | None = None) -> list[dict]: """Return all sync items (or a subtype) for a user, optionally filtered by `since` (ISO updated_at cutoff, exclusive — normalized to canonical UTC, so client offsets are fine). Includes tombstones (deleted=1). """ db = await get_db() since_norm = _normalize_sync_ts(since) if since else None if since and since_norm is None: # Unparseable cutoff: return everything rather than a wrong subset — # the client re-filters from the full set. since_norm = None if entity_type and since_norm: sql = ("SELECT uuid, entity_type, data, updated_at, deleted, origin FROM sync_items " "WHERE user_id = ? AND entity_type = ? AND updated_at > ?") params = (user_id, entity_type, since_norm) elif entity_type: sql = "SELECT uuid, entity_type, data, updated_at, deleted, origin FROM sync_items WHERE user_id = ? AND entity_type = ?" params = (user_id, entity_type) elif since_norm: sql = "SELECT uuid, entity_type, data, updated_at, deleted, origin FROM sync_items WHERE user_id = ? AND updated_at > ?" params = (user_id, since_norm) else: sql = "SELECT uuid, entity_type, data, updated_at, deleted, origin FROM sync_items WHERE user_id = ?" params = (user_id,) async with db.execute(sql, params) as cur: rows = await cur.fetchall() out = [] for uuid, etype, data, ts, deleted, origin in rows: parsed = {} if data: try: parsed = json.loads(data) except Exception: parsed = {"_raw": data} out.append({ "uuid": uuid, "entity_type": etype, "data": parsed, "updated_at": ts, "origin": origin or "", "deleted": bool(deleted), }) return out # -- Calendar Session Map -- # Same pattern as email/notes/tasks: model sees numbered list, we map to real UIDs. async def save_calendar_map(session_id: str, events: list[dict], user_id: str = "default"): """Replace the stored calendar listing for a session with a new one.""" if not session_id or not events: return user_id = user_id or "default" db = await get_db() async with _write_lock(): await db.execute( "DELETE FROM calendar_session_map WHERE session_id = ? AND user_id = ?", (session_id, user_id), ) for seq, ev in enumerate(events, 1): await db.execute( "INSERT INTO calendar_session_map (session_id, seq, uid, summary, start_time, user_id) " "VALUES (?, ?, ?, ?, ?, ?)", ( session_id, seq, str(ev.get("uid", "")), ev.get("summary", "")[:200], ev.get("start", "")[:50], user_id, ), ) await _commit_with_retry(db) async def get_calendar_map(session_id: str, user_id: str = "default") -> list[dict]: """Return the persisted calendar listing for a session (list-number order).""" if not session_id: return [] user_id = user_id or "default" db = await get_db() async with db.execute( """SELECT uid, summary, start_time FROM calendar_session_map WHERE session_id = ? AND user_id = ? ORDER BY seq ASC""", (session_id, user_id,), ) as cur: rows = await cur.fetchall() return [ {"uid": r[0], "summary": r[1], "start": r[2]} for r in rows ] async def clear_calendar_map(session_id: str): """Drop the stored calendar listing for a session (used on history clear).""" if not session_id: return db = await get_db() await _write_with_retry(db, "DELETE FROM calendar_session_map WHERE session_id = ?", (session_id,)) # -- Sessions -- async def update_session_name(session_id: str, name: str, user_id: str = "default"): db = await get_db() async with _write_lock(): await db.execute( """INSERT INTO sessions (id, name, user_id) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, last_active = CURRENT_TIMESTAMP WHERE sessions.user_id = excluded.user_id""", (session_id, name, user_id), ) await _commit_with_retry(db) async def get_all_sessions(user_id: str = "default") -> list[dict]: db = await get_db() async with db.execute( """SELECT s.id, s.created_at, s.last_active, s.name, COUNT(c.session_id) as msg_count FROM sessions s LEFT JOIN conversations c ON c.session_id = s.id AND c.user_id = ? WHERE s.user_id = ? GROUP BY s.id ORDER BY s.last_active DESC""", (user_id, user_id), ) as cur: rows = await cur.fetchall() return [ {"session_id": r[0], "created_at": r[1], "last_active": r[2], "name": r[3], "message_count": r[4]} for r in rows ] async def get_session_meta(session_id: str, user_id: str = "default") -> dict: db = await get_db() async with db.execute( "SELECT summary, summarized_until FROM sessions WHERE id = ? AND user_id = ?", (session_id, user_id), ) as cur: row = await cur.fetchone() if row is None: return {"summary": "", "summarized_until": 0} return {"summary": row[0] or "", "summarized_until": row[1] or 0} async def session_owned_by(session_id: str, user_id: str) -> bool: """True when the session row belongs to user_id. Used by the abort guard: a foreign session must be indistinguishable from an idle one (no existence oracle). Sessions are created by save_message before any generation starts, so a live abort event always has an owner row to check against. """ db = await get_db() async with db.execute( "SELECT 1 FROM sessions WHERE id = ? AND user_id = ?", (session_id, user_id), ) as cur: return await cur.fetchone() is not None async def session_foreign(session_id: str, user_id: str) -> bool: """True when the session row exists but belongs to a different user. Distinct from ``not session_owned_by(...)``: a brand-new session id is NOT foreign (the caller is about to create it); only an existing row with a different owner is. """ db = await get_db() async with db.execute( "SELECT 1 FROM sessions WHERE id = ? AND user_id <> ?", (session_id, user_id), ) as cur: return await cur.fetchone() is not None # -- Users (multi-user identity) -- # # One row per human. API keys are stored as SHA-256 hashes only — the raw key # exists solely at creation/rotation time (returned once to the caller). # The pre-multi-user identity keeps id "default": existing rows already point # there, so admin bootstrap rewrites zero data rows. DEFAULT_USER_ID = "default" def generate_api_key() -> str: """Generate a new raw API key (returned to the caller exactly once).""" return secrets.token_urlsafe(32) def hash_api_key(key: str) -> str: """One-way hash for storage/comparison (never persist the raw key).""" return hashlib.sha256(key.encode("utf-8")).hexdigest() def _row_to_user(row) -> dict: return { "id": row[0], "name": row[1] or "", "is_admin": bool(row[2]), "created_at": row[3], "is_approved": bool(row[4]) if len(row) > 4 else False, } # Display names that would collide with system identities or enable # impersonation (login is by name; ensure_default_admin owns 'admin'). # Case-insensitive exact match. Homoglyph lookalikes are a known residual. RESERVED_USERNAMES = frozenset({ "admin", "administrator", "system", "support", "api", "null", "undefined", "server", "pisynapse", }) async def count_users() -> int: db = await get_db() cur = await db.execute("SELECT COUNT(*) FROM users") return (await cur.fetchone())[0] async def get_user(user_id: str) -> dict | None: db = await get_db() cur = await db.execute( "SELECT id, name, is_admin, created_at, password_hash, is_approved FROM users WHERE id = ?", (user_id,) ) row = await cur.fetchone() if row is None: return None user = _row_to_user((row[0], row[1], row[2], row[3], row[5])) user["has_password"] = bool(row[4]) return user async def get_user_by_key_hash(key_hash: str) -> dict | None: db = await get_db() cur = await db.execute( "SELECT id, name, is_admin, created_at, is_approved FROM users WHERE key_hash = ?", (key_hash,), ) row = await cur.fetchone() return _row_to_user(row) if row else None async def list_users() -> list[dict]: db = await get_db() cur = await db.execute("SELECT id, name, is_admin, created_at, is_approved FROM users ORDER BY created_at, id") return [_row_to_user(r) for r in await cur.fetchall()] async def create_user(name: str, *, is_admin: bool = False, user_id: str | None = None, approved: bool = False) -> tuple[dict, str]: """Create a user with a fresh API key. Returns (user_dict, raw_key). The raw key is returned exactly once — only its hash is stored. Display names are unique case-insensitively (login is by name). `approved` gates quorum counting (collective learning); admins are always approved at creation. """ import uuid as _uuid clean = (name or "").strip() if not clean: raise ValueError("Name must not be empty") if clean.casefold() in RESERVED_USERNAMES: raise ValueError(f"Name is reserved: {clean}") raw_key = generate_api_key() uid = user_id or _uuid.uuid4().hex[:12] db = await get_db() async with _write_lock(): cur = await db.execute("SELECT 1 FROM users WHERE lower(name) = lower(?)", (clean,)) if await cur.fetchone(): raise ValueError(f"Name already taken: {clean}") await db.execute( "INSERT INTO users (id, name, key_hash, is_admin, is_approved) VALUES (?, ?, ?, ?, ?)", (uid, clean[:100], hash_api_key(raw_key), 1 if is_admin else 0, 1 if (approved or is_admin) else 0), ) await _commit_with_retry(db) user = await get_user(uid) assert user is not None return user, raw_key async def rotate_user_key(user_id: str) -> str | None: """Replace a user's API key. Returns the new raw key once, else None.""" raw_key = generate_api_key() db = await get_db() async with _write_lock(): cur = await db.execute( "UPDATE users SET key_hash = ? WHERE id = ?", (hash_api_key(raw_key), user_id) ) await _commit_with_retry(db) if cur.rowcount == 0: return None invalidate_user_cache(user_id) return raw_key async def get_user_by_name(name: str) -> dict | None: """Case-insensitive lookup for password login. Returns row with hash.""" db = await get_db() cur = await db.execute( "SELECT id, name, is_admin, created_at, password_hash, is_approved FROM users " "WHERE lower(name) = lower(?)", ((name or "").strip(),), ) row = await cur.fetchone() if row is None: return None user = _row_to_user((row[0], row[1], row[2], row[3], row[5])) user["has_password"] = bool(row[4]) return user async def set_password(user_id: str, raw_password: str) -> None: """Store a bcrypt hash (CPU work offloaded to a thread).""" import asyncio as _asyncio import bcrypt as _bcrypt hashed = await _asyncio.to_thread( _bcrypt.hashpw, raw_password.encode("utf-8"), _bcrypt.gensalt() ) db = await get_db() async with _write_lock(): cur = await db.execute( "UPDATE users SET password_hash = ? WHERE id = ?", (hashed.decode("utf-8"), user_id), ) await _commit_with_retry(db) if cur.rowcount == 0: raise ValueError(f"Unknown user: {user_id}") async def verify_password(user_id: str, raw_password: str) -> bool: """Check a password against the stored bcrypt hash (thread-offloaded).""" import asyncio as _asyncio import bcrypt as _bcrypt db = await get_db() cur = await db.execute("SELECT password_hash FROM users WHERE id = ?", (user_id,)) row = await cur.fetchone() if row is None or not row[0]: return False try: return await _asyncio.to_thread( _bcrypt.checkpw, raw_password.encode("utf-8"), row[0].encode("utf-8") ) except (ValueError, TypeError): return False async def create_device_key(user_id: str, device_name: str = "") -> tuple[dict, str]: """Issue an additional per-device API key. Returns (row, raw_key once).""" import uuid as _uuid raw_key = generate_api_key() kid = _uuid.uuid4().hex[:12] label = (device_name or "").strip()[:100] db = await get_db() async with _write_lock(): await db.execute( "INSERT INTO user_api_keys (id, user_id, name, key_hash) VALUES (?, ?, ?, ?)", (kid, user_id, label, hash_api_key(raw_key)), ) await _commit_with_retry(db) return {"id": kid, "user_id": user_id, "name": label}, raw_key async def list_device_keys(user_id: str) -> list[dict]: """Device keys for a user (never includes hashes).""" db = await get_db() cur = await db.execute( "SELECT id, user_id, name, created_at FROM user_api_keys " "WHERE user_id = ? ORDER BY created_at, id", (user_id,), ) return [ {"id": r[0], "user_id": r[1], "name": r[2], "created_at": r[3]} for r in await cur.fetchall() ] async def revoke_device_key(user_id: str, key_id: str) -> bool: """Delete one of the user's device keys. Returns True when removed.""" db = await get_db() async with _write_lock(): cur = await db.execute( "SELECT key_hash FROM user_api_keys WHERE id = ? AND user_id = ?", (key_id, user_id), ) row = await cur.fetchone() if row is None: return False await db.execute( "DELETE FROM user_api_keys WHERE id = ? AND user_id = ?", (key_id, user_id) ) await _commit_with_retry(db) _user_cache.pop(row[0], None) return True # ── Browser sessions (cookie auth; API keys stay server-side) ───────────────── # Opaque tokens: 32 random bytes, SHA-256 stored (like API keys — the raw # value exists only at creation). autouse-tested hermeticity needs no clock # mocking: expiry is compared in SQL against CURRENT_TIMESTAMP. SESSION_COOKIE_NAME = "ps_session" SESSION_TTL_DAYS = 30 SESSION_TOUCH_DAYS = 15 def _hash_session_token(raw: str) -> str: import hashlib as _hl return _hl.sha256(raw.encode("utf-8")).hexdigest() async def create_session(user_id: str, device: str = "") -> tuple[dict, str]: """Open a browser session. Returns (session_row, raw_token) — raw once.""" import secrets as _secrets import uuid as _uuid raw = _secrets.token_urlsafe(32) sid = _uuid.uuid4().hex[:12] db = await get_db() async with _write_lock(): await db.execute( "INSERT INTO user_sessions (id, user_id, token_hash, device, expires_at) " "VALUES (?, ?, ?, ?, datetime('now', ?))", (sid, user_id, _hash_session_token(raw), (device or "")[:100], f"+{SESSION_TTL_DAYS} days"), ) await _commit_with_retry(db) return {"id": sid, "user_id": user_id}, raw async def resolve_session(raw_token: str) -> dict | None: """Resolve a session cookie to its user dict, or None. Never raises. Sliding expiry: sessions idle past half their TTL get a fresh window on use, so daily users never re-login while abandoned ones die on schedule. """ from datetime import datetime as _dt token = (raw_token or "").strip() if not token: return None try: db = await get_db() cur = await db.execute( "SELECT s.id, s.user_id, s.expires_at FROM user_sessions s WHERE s.token_hash = ?", (_hash_session_token(token),), ) row = await cur.fetchone() if not row: return None try: exp = _dt.strptime(row[2][:19], "%Y-%m-%d %H:%M:%S") except Exception: return None now = _dt.utcnow() if exp <= now: async with _write_lock(): await db.execute("DELETE FROM user_sessions WHERE id = ?", (row[0],)) await _commit_with_retry(db) return None if (exp - now).days < SESSION_TOUCH_DAYS: async with _write_lock(): await db.execute( "UPDATE user_sessions SET expires_at = datetime('now', ?), last_seen = CURRENT_TIMESTAMP WHERE id = ?", (f"+{SESSION_TTL_DAYS} days", row[0]), ) await _commit_with_retry(db) return await get_user(row[1]) except Exception: return None async def revoke_session(raw_token: str) -> bool: """Revoke one session (logout). Always safe to call.""" token = (raw_token or "").strip() if not token: return False try: db = await get_db() async with _write_lock(): cur = await db.execute( "DELETE FROM user_sessions WHERE token_hash = ?", (_hash_session_token(token),) ) await _commit_with_retry(db) return cur.rowcount > 0 except Exception: return False async def revoke_other_sessions(user_id: str, keep_raw_token: str) -> int: """Revoke all of a user's sessions except the current one (password change). Returns the revoked count. Never raises. """ try: db = await get_db() async with _write_lock(): cur = await db.execute( "DELETE FROM user_sessions WHERE user_id = ? AND token_hash != ?", (user_id, _hash_session_token((keep_raw_token or "").strip())), ) await _commit_with_retry(db) return cur.rowcount except Exception: return 0 async def delete_user(user_id: str) -> bool: """Delete a user and ALL of their data (privacy-complete wipe). Removes conversations (+FTS rows), sessions, memories, session maps, tool audits, feedback on their messages, sync items, personal settings, device keys, browser sessions, quorum votes and the user row itself — in one locked transaction. Returns False when the user does not exist. """ db = await get_db() async with _write_lock(): cur = await db.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)) if await cur.fetchone() is None: return False try: await db.execute( "DELETE FROM conversations_fts WHERE rowid IN " "(SELECT id FROM conversations WHERE user_id = ?)", (user_id,), ) except Exception as fts_e: # Same external-content quirk as the dedupe path: missing index # entries mean nothing to orphan. logger.warning(f"FTS cleanup skipped during user delete (non-fatal): {fts_e}") await db.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM memories WHERE user_id = ?", (user_id,)) for _map_tbl in ("email_session_map", "notes_session_map", "tasks_session_map", "calendar_session_map"): await db.execute( f"DELETE FROM {_map_tbl} WHERE user_id = ?", (user_id,) ) await db.execute("DELETE FROM tool_audit_log WHERE user_id = ?", (user_id,)) await db.execute( "DELETE FROM message_feedback WHERE message_id NOT IN (SELECT id FROM conversations)" ) await db.execute("DELETE FROM sync_items WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM user_settings WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM user_api_keys WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM user_sessions WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM feedback_votes WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM user_credentials WHERE user_id = ?", (user_id,)) await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) await _commit_with_retry(db) invalidate_user_cache(user_id) return True async def ensure_default_admin() -> dict | None: """Bootstrap the admin onto the pre-existing 'default' identity. - Users table non-empty → nothing to do (returns None). - Empty + `.env` API_KEY set + LEGACY DATA ROWS (conversations/sessions/ memories from a pre-multi-user install) → create admin id 'default' bound to that key's hash (the single-user owner's key keeps working, now as admin). - Empty + no key, or empty + key but a FRESH database (no rows anywhere) → nothing (the first registration becomes admin — installer or browser). This keeps fresh installs free of phantom admins and closes the first-visitor race: ownership is explicit, never positional. Existing data rows already point at 'default': zero rewrites, and the invisibility invariant holds from the first multi-user row on. """ db = await get_db() cur = await db.execute("SELECT COUNT(*) FROM users") if (await cur.fetchone())[0] > 0: return None import os as _os env_key = (_os.getenv("API_KEY") or "").strip() if not env_key: return None legacy = False for _tbl in ("conversations", "sessions", "memories"): try: cur = await db.execute(f"SELECT 1 FROM {_tbl} LIMIT 1") if await cur.fetchone() is not None: legacy = True break except Exception: continue if not legacy: return None async with _write_lock(): await db.execute( "INSERT OR IGNORE INTO users (id, name, key_hash, is_admin, is_approved) VALUES (?, ?, ?, 1, 1)", (DEFAULT_USER_ID, "admin", hash_api_key(env_key)), ) await _commit_with_retry(db) return await get_user(DEFAULT_USER_ID) async def set_approved(user_id: str, approved: bool) -> bool: """Set a user's quorum-approval flag (admin only via endpoint). Approval gates collective-learning quorum counting only — chat, tools, settings and feedback all work identically for unapproved users. Returns False when the user does not exist. """ db = await get_db() async with _write_lock(): cur = await db.execute( "UPDATE users SET is_approved = ? WHERE id = ?", (1 if approved else 0, user_id), ) await _commit_with_retry(db) ok = cur.rowcount > 0 if ok: invalidate_user_cache(user_id) return ok _USER_CACHE_TTL = 60.0 _user_cache: dict[str, tuple[dict, float]] = {} def invalidate_user_cache(user_id: str | None = None) -> None: """Drop cached key resolutions (all, or one user's). Call on rotation.""" if user_id is None: _user_cache.clear() return for k in [k for k, (u, _) in _user_cache.items() if u.get("id") == user_id]: _user_cache.pop(k, None) async def resolve_user_by_key(api_key: str) -> dict | None: """Resolve a raw API key to its user dict, or None. Never raises. Order: legacy `.env` API_KEY (maps to the default identity, preserving single-user behavior) → DB `key_hash` lookup with a short TTL cache. DB errors resolve to None (deny closed; health still reports the outage). """ key = (api_key or "").strip() if not key: return None digest = hash_api_key(key) now = time.monotonic() hit = _user_cache.get(digest) if hit is not None: user, expires = hit if expires > now: return user try: import os as _os env_key = (_os.getenv("API_KEY") or "").strip() user: dict | None = None if env_key and hmac.compare_digest(key, env_key): user = await get_user(DEFAULT_USER_ID) if user is None: user = {"id": DEFAULT_USER_ID, "name": "", "is_admin": True, "created_at": None} else: user = await get_user_by_key_hash(digest) if user is None: db = await get_db() cur = await db.execute( "SELECT user_id FROM user_api_keys WHERE key_hash = ?", (digest,) ) row = await cur.fetchone() if row is not None: user = await get_user(row[0]) except Exception as e: logger.warning(f"User resolution failed (denying closed): {e}") return None if user is not None: _user_cache[digest] = (user, now + _USER_CACHE_TTL) return user async def get_user_settings(user_id: str) -> dict[str, str]: """Return all personal settings for a user as {key: value}.""" db = await get_db() cur = await db.execute( "SELECT key, value FROM user_settings WHERE user_id = ?", (user_id,) ) return {r[0]: r[1] for r in await cur.fetchall()} async def seed_admin_settings() -> int: """One-time migration: copy current `.env` personal values into the default admin's rows — but only keys the admin has never set (never overwrite a deliberate user choice). Returns the seeded count. Idempotent; safe to run on every boot (usually a no-op). """ import os as _os from config import PERSONAL_KEYS stored = await get_user_settings(DEFAULT_USER_ID) missing = [k for k in PERSONAL_KEYS if k not in stored and (_os.getenv(k) or "").strip()] if not missing: return 0 db = await get_db() async with _write_lock(): for key in sorted(missing): await db.execute( "INSERT OR IGNORE INTO user_settings (user_id, key, value, updated_at) " "VALUES (?, ?, ?, CURRENT_TIMESTAMP)", (DEFAULT_USER_ID, key, _os.getenv(key, "").strip()), ) await _commit_with_retry(db) return len(missing) async def set_user_setting(user_id: str, key: str, value: str) -> None: """Upsert one personal setting. Raises ValueError for non-personal keys.""" from config import PERSONAL_KEYS if key not in PERSONAL_KEYS: raise ValueError(f"Not a personal setting: {key}") db = await get_db() async with _write_lock(): await db.execute( "INSERT INTO user_settings (user_id, key, value, updated_at) " "VALUES (?, ?, ?, CURRENT_TIMESTAMP) " "ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value, " "updated_at = CURRENT_TIMESTAMP", (user_id, key, value), ) await _commit_with_retry(db) async def effective_setting(user_id: str | None, key: str, default: str = "") -> str: """Resolve a setting with user > global precedence. Personal keys: the user's row wins; otherwise the live global value (env-derived config attr); otherwise the given default. Unknown or empty user falls straight to global. """ if user_id: try: db = await get_db() cur = await db.execute( "SELECT value FROM user_settings WHERE user_id = ? AND key = ?", (user_id, key), ) row = await cur.fetchone() if row is not None: return row[0] except Exception as e: logger.warning(f"Personal setting lookup failed ({key}): {e}") import config as _cfg val = getattr(_cfg, key, default) return default if val is None else str(val) # -- Rolling Summary -- # Bounds for a single fold. Normal sessions fold a handful of messages per # turn; without a cap, a long-unfolded session (or a lowered history limit) # would hand the summarizer an unbounded transcript and blow the engine's # context (observed: 15k input tokens vs a 6656 budget on-device; the server # default context is 8192). Folding is oldest-first and the boundary advances # by exactly what was folded, so a huge backlog drains over successive turns. FOLD_MAX_MESSAGES = 30 FOLD_MAX_CHARS_PER_MESSAGE = 500 async def _repair_summary_boundaries(db: aiosqlite.Connection) -> None: """Reconcile summary boundaries with surviving messages. Call inside the caller's write lock, before its commit, after any delete path (branch / last / clear / retention). - Clamps every session's ``summarized_until`` to its surviving MAX(id): otherwise the fold computes an empty pending span forever (stall) while the prompt keeps injecting a summary of deleted content. - Clears summary+until where no messages remain. Stale facts in a surviving summary are NOT purged here — they dilute as new folds append (documented residual); re-summarizing from scratch on a synchronous delete path would stall the API. """ await db.execute( "UPDATE sessions SET summarized_until = MIN(summarized_until, " "COALESCE((SELECT MAX(c.id) FROM conversations c " "WHERE c.session_id = sessions.id AND c.user_id = sessions.user_id), 0))" ) await db.execute( "UPDATE sessions SET summary = '', summarized_until = 0 " "WHERE NOT EXISTS (SELECT 1 FROM conversations c " "WHERE c.session_id = sessions.id AND c.user_id = sessions.user_id)" ) async def get_messages_to_summarize( session_id: str, history_limit: int, summarized_until: int, batch_size: int, early_trigger: int = 0, user_id: str = "default", ) -> tuple[list[dict], int]: """Return the next batch of aged-out messages not yet folded into the summary. early_trigger: if >0 and this is the first summary (summarized_until == 0), fire as soon as this many messages are available. """ db = await get_db() async with db.execute( "SELECT id FROM conversations WHERE session_id = ? AND user_id = ? ORDER BY id ASC", (session_id, user_id), ) as cur: ids = [r[0] for r in await cur.fetchall()] total = len(ids) if total <= history_limit: return [], summarized_until boundary_id = ids[total - history_limit - 1] pending_ids = [i for i in ids if summarized_until < i <= boundary_id] pending_count = len(pending_ids) effective_batch = ( early_trigger if (early_trigger > 0 and summarized_until == 0 and pending_count >= early_trigger) else batch_size ) if pending_count < effective_batch: return [], summarized_until # Fold at most FOLD_MAX_MESSAGES, oldest-first; the boundary advances by # exactly the folded span so the remainder drains on later turns. take_end = pending_ids[min(len(pending_ids), FOLD_MAX_MESSAGES) - 1] async with db.execute( """SELECT role, content FROM conversations WHERE session_id = ? AND user_id = ? AND id > ? AND id <= ? ORDER BY id ASC""", (session_id, user_id, summarized_until, take_end), ) as cur: rows = await cur.fetchall() out = [] for role, content in rows: c = content or "" if len(c) > FOLD_MAX_CHARS_PER_MESSAGE: c = c[:FOLD_MAX_CHARS_PER_MESSAGE] + "…" if c.strip(): out.append({"role": role, "content": c}) if not out: # Nothing foldable (all blank): advance past the empty span so the # fold does not retry it on every turn. return [], take_end return out, take_end async def update_session_summary(session_id: str, summary: str, summarized_until: int, user_id: str = "default", expected_until: int | None = None) -> bool: """Store a folded summary; returns True when applied. ``expected_until`` enables a conditional write for the background fold race: two concurrent ``_update_summary`` tasks read the same boundary, the loser must not clobber the winner's wider summary. When given, the write applies only if the stored boundary still equals it (changes() 0 = skipped). ``None`` keeps the unconditional legacy behavior (still checks the owner guard on the conflict path). """ db = await get_db() async with _write_lock(): if expected_until is None: await db.execute( """INSERT INTO sessions (id, summary, summarized_until, user_id) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET summary = excluded.summary, summarized_until = excluded.summarized_until, last_active = CURRENT_TIMESTAMP WHERE sessions.user_id = excluded.user_id""", (session_id, summary, summarized_until, user_id), ) else: await db.execute( """INSERT INTO sessions (id, summary, summarized_until, user_id) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET summary = excluded.summary, summarized_until = excluded.summarized_until, last_active = CURRENT_TIMESTAMP WHERE sessions.user_id = excluded.user_id AND sessions.summarized_until = ?""", (session_id, summary, summarized_until, user_id, expected_until), ) # Python >= 3.12 reports cursor.rowcount = -1 for every INSERT (upserts # included), which made this function return False unconditionally; # changes() is the portable source of truth for "did the write apply". applied = (await (await db.execute("SELECT changes()")).fetchone())[0] > 0 await _commit_with_retry(db) return applied # -- Long-term Memories -- async def save_memory(content: str, category: str = "general", importance: int = 5, user_id: str | None = None) -> tuple[str, int]: from config import DEFAULT_USER, MEMORY_SIMILARITY_THRESHOLD from embedding import cosine_similarity, embed_async user_id = user_id or DEFAULT_USER try: new_embedding = await embed_async(content) except Exception as e: logger.error(f"Embedding failed, saving memory without vector: {e}") new_embedding = None db = await get_db() if new_embedding is not None: async with db.execute( "SELECT id, embedding FROM memories WHERE user_id = ? AND embedding IS NOT NULL", (user_id,), ) as cur: existing = await cur.fetchall() for mem_id, existing_embedding in existing: if cosine_similarity(new_embedding, existing_embedding) >= MEMORY_SIMILARITY_THRESHOLD: async with _write_lock(): await db.execute( """UPDATE memories SET last_accessed = CURRENT_TIMESTAMP, access_count = access_count + 1 WHERE id = ?""", (mem_id,), ) await _commit_with_retry(db) return "Memory updated (similar content exists).", mem_id async with _write_lock(): cur = await db.execute( "INSERT INTO memories (user_id, content, category, importance, embedding) VALUES (?, ?, ?, ?, ?)", (user_id, content, category, importance, new_embedding), ) await _commit_with_retry(db) rowid = cur.lastrowid return "Memory saved.", rowid async def search_memories(query: str, user_id: str | None = None, limit: int = 5, query_embedding: bytes | None = None) -> list[dict]: from config import DEFAULT_USER from embedding import cosine_similarity, embed_async user_id = user_id or DEFAULT_USER try: if query_embedding is None: query_embedding = await embed_async(query) except Exception as e: logger.error(f"Embedding failed for memory search: {e}") return [] db = await get_db() # Phase 1: load only IDs + embeddings for fast similarity scoring async with db.execute( "SELECT id, embedding FROM memories WHERE user_id = ?", (user_id,), ) as cur: rows = await cur.fetchall() scored = [] backfill_ids = [] for mem_id, blob in rows: if blob is None: backfill_ids.append(mem_id) continue sim = cosine_similarity(query_embedding, blob) scored.append((sim, mem_id)) scored.sort(key=lambda x: x[0], reverse=True) top = [(sim, mid) for sim, mid in scored[:limit] if sim >= 0.35] # Phase 2: fetch full content only for top results result = [] if top: placeholders = ",".join("?" * len(top)) ids = [m_id for _, m_id in top] async with db.execute( f"SELECT id, content, category, importance, created_at FROM memories WHERE id IN ({placeholders})", ids, ) as cur: content_map = {r[0]: r[1:] for r in await cur.fetchall()} for sim, mem_id in top: c, cat, imp, cat_at = content_map.get(mem_id, ("", "", 0, "")) result.append({ "id": mem_id, "content": c, "category": cat, "importance": imp, "created_at": cat_at, "similarity": sim, }) if backfill_ids: async def _backfill(): # Own connection: the shared `db` is mid-writing the caller's # search results, and a backfill commit on it would commit the # caller's partially-assembled transaction too. WAL allows # concurrent writers from a separate connection safely. async with _BACKFILL_SEMAPHORE: conn = await aiosqlite.connect(DB_PATH) try: await conn.execute("PRAGMA busy_timeout=10000") for i in range(0, len(backfill_ids), _BACKFILL_BATCH_SIZE): batch = backfill_ids[i : i + _BACKFILL_BATCH_SIZE] for mid in batch: try: row = await conn.execute("SELECT content FROM memories WHERE id = ?", (mid,)) r = await row.fetchone() if r: emb = await embed_async(r[0]) await conn.execute("UPDATE memories SET embedding = ? WHERE id = ?", (emb, mid)) except Exception as e: logger.error("Embedding backfill failed for memory %s: %s", mid, e) await conn.commit() # Small yield between batches to avoid starving the event loop await asyncio.sleep(0.05) except asyncio.CancelledError: pass finally: await conn.close() global _backfill_task # Cancel any previous backfill to avoid duplicate work if _backfill_task and not _backfill_task.done(): _backfill_task.cancel() _backfill_task = asyncio.create_task(_backfill()) # Update access stats for retrieved results. The write AND its commit # must hold the process write lock: an unconditional commit on the # shared connection would commit another coroutine's partially- # assembled transaction (D2/D4). Commit only when we actually wrote — # a no-hit search has nothing of its own to flush. if result: ids = [m["id"] for m in result] placeholders = ",".join("?" * len(ids)) async with _write_lock(): await db.execute( f"""UPDATE memories SET last_accessed = CURRENT_TIMESTAMP, access_count = access_count + 1 WHERE id IN ({placeholders})""", ids, ) await _commit_with_retry(db) return result async def get_memories(user_id: str | None = None, limit: int = 10) -> list[dict]: from config import DEFAULT_USER user_id = user_id or DEFAULT_USER try: db = await get_db() async with db.execute( """SELECT id, content, category, importance, created_at FROM memories WHERE user_id = ? ORDER BY importance DESC, last_accessed DESC LIMIT ?""", (user_id, limit), ) as cur: rows = await cur.fetchall() return [{"id": r[0], "content": r[1], "category": r[2], "importance": r[3], "created_at": r[4]} for r in rows] except Exception: logger.exception("Failed to retrieve memories") return [] async def delete_memory(user_id: str, memory_id: int): db = await get_db() async with _write_lock(): await db.execute("DELETE FROM memories WHERE id = ? AND user_id = ?", (memory_id, user_id)) await _commit_with_retry(db) async def get_all_memories(user_id: str | None = None) -> list[dict]: from config import DEFAULT_USER user_id = user_id or DEFAULT_USER try: db = await get_db() async with db.execute( """SELECT id, content, category, importance, created_at FROM memories WHERE user_id = ? ORDER BY importance DESC""", (user_id,), ) as cur: rows = await cur.fetchall() return [{"id": r[0], "content": r[1], "category": r[2], "importance": r[3], "created_at": r[4]} for r in rows] except Exception: logger.exception("Failed to retrieve all memories") return []