"""Complete (hard) deletion of android messages from the hermes session store. The android plugin mints its own message ids (``m_``) that are **not** persisted in the hermes session DB (``state.db``), so a delete request cannot join on an id. Instead a message is matched to its ``messages`` row by (session, role, content, timestamp proximity) and that row is deleted. Deleting a ``messages`` row also drops it from the FTS5 search index via the ``messages_fts_*_delete`` triggers, so **no search trace survives** and the message is not recoverable. Channel / thread deletion wipes the whole lane: every session (and its messages) for the chat (a channel) or the specific thread. The session DB is opened read-write with a busy timeout. It is normally held by the gateway core in WAL mode, which allows one writer at a time, so a brief write from a second connection is safe. """ import contextlib import logging import sqlite3 from pathlib import Path logger = logging.getLogger(__name__) def _connect(db_path: Path) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path), timeout=10.0) conn.row_factory = sqlite3.Row return conn def _flat_session_id(conn: sqlite3.Connection, chat_id: str) -> str | None: """The flat-lane session (thread_id NULL / empty) for *chat_id*.""" row = conn.execute( "SELECT id FROM sessions WHERE chat_id = ? " "AND (thread_id IS NULL OR thread_id = '') LIMIT 1", (chat_id,), ).fetchone() return row["id"] if row else None def _thread_session_id(conn: sqlite3.Connection, chat_id: str, thread_id: str) -> str | None: row = conn.execute( "SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ? LIMIT 1", (chat_id, thread_id), ).fetchone() return row["id"] if row else None def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> int: """Hard-delete a whole lane from the session store. * ``thread_id is None`` -> a **channel**: every session under *chat_id* (the flat lane plus all of its threads) and all of their messages. * ``thread_id`` set -> a **thread**: the single session for (chat_id, thread_id) and its messages. Returns the number of message rows removed (0 when the lane is absent or the DB is missing). Never raises: any DB error yields 0 (the caller still deletes the directory entry + outbox frames, so the lane is gone from the app's point of view even if the session-store wipe fails). """ db_path = Path(db_path) if not db_path.exists(): return 0 conn = _connect(db_path) try: if thread_id: rows = conn.execute( "SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ?", (chat_id, thread_id), ).fetchall() else: rows = conn.execute("SELECT id FROM sessions WHERE chat_id = ?", (chat_id,)).fetchall() ids = [r["id"] for r in rows] if not ids: return 0 # Delete per session with fully parameterized queries (a channel has # only a handful of sessions: the flat lane plus its threads). Messages # first (foreign_keys is not enforced on this DB), then the session # rows. The FTS delete triggers fire on the message deletes. n_msgs = 0 for sid in ids: cur = conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,)) n_msgs += max(0, cur.rowcount) conn.execute("DELETE FROM sessions WHERE id = ?", (sid,)) conn.commit() return n_msgs except sqlite3.Error as e: logger.warning("android purge: delete_lane failed: %s", e) return 0 finally: with contextlib.suppress(Exception): conn.close() def delete_message( db_path: Path, chat_id: str, thread_id: str | None, role: str, content: str, ts_ms: int | None, ) -> int: """Hard-delete a single message from the session store. Matches the ``messages`` row by (session, role, content) and, when several rows share that content, the one whose timestamp is closest to *ts_ms*. The content must match exactly (after stripping) -- a mismatch deletes nothing rather than the wrong message. Returns 1 when a row was removed, 0 otherwise. Never raises. """ db_path = Path(db_path) if not db_path.exists() or not content or not role: return 0 conn = _connect(db_path) try: sid = ( _thread_session_id(conn, chat_id, thread_id) if thread_id else _flat_session_id(conn, chat_id) ) if sid is None: return 0 ts_s = (ts_ms or 0) / 1000.0 want = content.strip() rows = conn.execute( "SELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = ?", (sid, role), ).fetchall() target: int | None = None best_dt: float | None = None for r in rows: if (r["content"] or "").strip() != want: continue try: dt = abs(float(r["timestamp"]) - ts_s) except (TypeError, ValueError): dt = 0.0 if best_dt is None or dt < best_dt: best_dt = dt target = r["id"] if target is None: return 0 conn.execute("DELETE FROM messages WHERE id = ?", (target,)) conn.commit() return 1 except sqlite3.Error as e: logger.warning("android purge: delete_message failed: %s", e) return 0 finally: with contextlib.suppress(Exception): conn.close()