diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt index a12b040..85a574c 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt @@ -29,12 +29,21 @@ class ChannelStore { /** Reconcile a server frame into the cache. */ fun onFrame(frame: Frame) { when (frame.type) { - TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame) - TYPE_CHANNEL_DELETED -> remove(frame) + TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> { + upsert(frame) + } + + TYPE_CHANNEL_DELETED -> { + remove(frame) + } + TYPE_CHANNEL_LIST -> { frame.payloadAs()?.let { setAll(it.channels) } } - else -> Unit + + else -> { + Unit + } } } @@ -48,7 +57,10 @@ class ChannelStore { private fun remove(frame: Frame) { val p = frame.payloadAs() ?: return - _channels.value = _channels.value.filter { it.chatId != p.chatId } + // A channel delete also removes its threads (child entries); a thread + // delete removes just that thread. Both are hard deletes server-side. + _channels.value = + _channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId } } private fun sorted(list: List): List = @@ -62,20 +74,16 @@ class ChannelStore { // ── UI helpers ──────────────────────────────────────────────────────── /** Channels (default + user channels) for the drawer/rail. */ - fun channelsForDrawer(): List = - _channels.value.filter { it.kind != "thread" } + fun channelsForDrawer(): List = _channels.value.filter { it.kind != "thread" } /** Threads under a channel, for the topic switcher. */ - fun threadsFor(chatId: String): List = - _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId } + fun threadsFor(chatId: String): List = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId } - fun byId(chatId: String): ChannelInfo? = - _channels.value.firstOrNull { it.chatId == chatId } + fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId } - fun defaultChannel(): ChannelInfo? = - _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull() + fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull() fun clear() { _channels.value = emptyList() } -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index db9058c..84cfe7d 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -839,7 +839,7 @@ fun ChatScreen(controller: IrisController) { onDismissRequest = { deleteThread = null }, title = { Text("Delete topic?") }, text = { - Text("Delete \"${t.name}\"? It will be removed from this channel.") + Text("Delete \"${t.name}\"? The topic and all its messages will be permanently deleted.") }, confirmButton = { TextButton(onClick = { @@ -861,9 +861,9 @@ fun ChatScreen(controller: IrisController) { text = { Text( if (n == 1) { - "This message will be deleted for all devices." + "This message will be permanently deleted for all devices and can no longer be found in search." } else { - "These messages will be deleted for all devices." + "These messages will be permanently deleted for all devices and can no longer be found in search." }, ) }, @@ -1049,7 +1049,7 @@ fun ChatScreen(controller: IrisController) { AlertDialog( onDismissRequest = { channelToDelete = null }, title = { Text("Delete channel?") }, - text = { Text("Delete \"${ch.name}\"? Its history stays available for search.") }, + text = { Text("Delete \"${ch.name}\"? The channel and all its messages will be permanently deleted.") }, confirmButton = { TextButton(onClick = { channelToDelete = null diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index 49c587d..1e4cb72 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -376,6 +376,11 @@ Answer an interactive picker. {"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}} ``` +`channel.delete` is a **hard delete**: the channel/thread row is removed from +the directory and the lane's history is wiped from the outbox and the hermes +session store (no search trace, not recoverable). Deleting a channel also +removes its threads. The default channel cannot be deleted. + ### `search` ```json @@ -409,10 +414,12 @@ Load a page of messages for a chat/thread (initial open, scroll-up pagination). ### `message.delete` -Delete the given message(s) from a chat/thread. The server removes them from the -outbox (so `history`/`sync` no longer return them) and broadcasts -`message.deleted` to every device. Idempotent: a message already gone (pruned by -retention) still yields a `message.deleted` broadcast so live caches drop it. +Completely delete the given message(s) from a chat/thread. The server removes +them from the outbox (so `history`/`sync` no longer return them) **and** from +the hermes session store (so no search trace survives and they are not +recoverable), then broadcasts `message.deleted` to every device. Idempotent: a +message already gone (pruned by retention) still yields a `message.deleted` +broadcast so live caches drop it. ```json {"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null, diff --git a/docs/06-channels-cron-search.md b/docs/06-channels-cron-search.md index 284cd48..db52103 100644 --- a/docs/06-channels-cron-search.md +++ b/docs/06-channels-cron-search.md @@ -8,7 +8,7 @@ The hermes gateway already models conversations as `SessionSource` with gateway identity concepts**. | App concept | hermes primitive | Example | -|---|---|---| +| --- | --- | --- | | Default chat | home channel `chat_id` | `android:default` | | A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` | | A user-created channel | a new `chat_id` | `android:chan_7` | @@ -85,8 +85,10 @@ lane (nothing to title / session-scoped, not conversation starters). `chat_id = android:chan_`, stores in directory, broadcasts `channel.created` to all devices. The new channel appears in the channel list. - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the - directory (rename broadcasts `channel.renamed`; delete is soft — marks - archived, keeps history for search). + directory (rename broadcasts `channel.renamed`; delete is a **hard delete** + -- the channel/thread row is removed and the lane's history is wiped from + the outbox and the hermes session store, so nothing is recoverable and no + search trace survives). - **Automation channels:** a channel can be marked *automation* (`channel.set_automation {on}`, long-press / right-click menu; the default channel cannot be marked). Automation channels are **read-only for the @@ -148,4 +150,4 @@ lane (nothing to title / session-scoped, not conversation starters). `hello.ack` and `channel.*` frames carry the directory. App keeps a local copy (Room) and reconciles on `channel.*` events (merge, don't clobber — see -`10-android-app.md` state rules). \ No newline at end of file +`10-android-app.md` state rules). diff --git a/docs/10-android-app.md b/docs/10-android-app.md index 7bfca2a..5dec5fc 100644 --- a/docs/10-android-app.md +++ b/docs/10-android-app.md @@ -6,7 +6,7 @@ the code is in `app/shared` (commonMain) so the Desktop app reuses it. ## 10.1 Tech stack | Concern | Choice | -|---|---| +| --- | --- | | Language | Kotlin | | UI | Jetpack Compose (Material 3), Compose Navigation | | Async | Kotlinx Coroutines + Flow | @@ -74,6 +74,7 @@ app/shared/src/ ## 10.5 Feature implementation (your checklist) ### Input box, auto-grow (max height) + - Compose `BasicTextField` inside a `Box` with `Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at 160dp, then scrolls internally. @@ -82,6 +83,7 @@ app/shared/src/ (configurable: Enter=send vs Enter=newline). ### Slash commands + - **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up over the input listing the command catalog. The catalog is served by the gateway via `commands.catalog` request → response with @@ -102,6 +104,7 @@ app/shared/src/ sheet with the options; answer via `picker.select`) — planned. ### Streaming (app-controlled) + - **Settings → "Streaming"** toggle (default on). When off, the app ignores `message.start`/`message.update` frames and shows each reply as a single final message on `message.stop` (the typing indicator covers the wait). @@ -109,6 +112,7 @@ app/shared/src/ devices (see `05-streaming.md` §5.1). ### Tool output (app-controlled verbosity) + - `ToolCard` renders `tool.start/progress/end` frames. - The gateway always supplies the **full** tool data: it forces `display.platforms.android.tool_progress: verbose` (so the progress line @@ -123,6 +127,7 @@ app/shared/src/ - Spinner while running; ✓/✗ + duration on `tool.end`. ### Reasoning before message + - `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy** button) rendered **above** the message body from the `reasoning` field. Matches the reference screenshot. @@ -132,9 +137,11 @@ app/shared/src/ tap-to-toggle always works. ### Intermediate messages + - `commentary` frames → dimmed/smaller bubble, distinct from final answers. ### Message selection + delete + - **Long-press** a message bubble (touch) or **right-click** it (desktop mouse) enters selection mode: the tapped message is selected (a circular check appears beside each bubble) and the composer is replaced by a selection toolbar @@ -143,12 +150,15 @@ app/shared/src/ message) exits selection mode. Only finalized messages are selectable — a streaming bubble has no final id yet and a pending echo isn't on the server. - **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the - current lane. The server removes the message(s) from the outbox (so - `history`/`sync` no longer return them) and broadcasts `message.deleted` to - every device; each device drops them from its cache (the requesting device - also drops them locally for snappy UX). Deleting is idempotent. + current lane. The server completely deletes the message(s): they are removed + from the outbox (so `history`/`sync` no longer return them) **and** from the + hermes session store (so no search trace survives and they are not + recoverable), and `message.deleted` is broadcast to every device; each device + drops them from its cache (the requesting device also drops them locally for + snappy UX). Deleting is idempotent. ### Agent busy / stop / steer + - `agent.busy` → show "thinking…" indicator in chat header (animated dots). - `agent.idle` → clear indicator. - **Stop button** (appears in header while busy): sends `agent.stop`. @@ -156,8 +166,9 @@ app/shared/src/ `agent.steer` (injects mid-turn) rather than queuing a new `message.send`. ### Threading + channels + - **Channel list** (drawer on single-pane; left rail on two-pane) = default chat - + user channels (avatar, name, last-message preview, unread badge, active + - user channels (avatar, name, last-message preview, unread badge, active highlight bar) — matches the reference left sidebar. - **Thread toggle** in the default chat header: "Threads on/off". On → topic switcher above the message list (each topic = a `thread_id`). @@ -171,23 +182,27 @@ app/shared/src/ menu offers "Set as cron target". - **Topic context menu** — long-press a topic chip (touch) or right-click it (desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled - dialog); Delete → confirm → `channel.delete` (soft-delete; the thread leaves - the switcher and, if it was the open lane, the app falls back to the channel's - flat lane). The right-click handler is a skiko `expect`/`actual` + dialog); Delete → confirm → `channel.delete` (hard delete; the thread leaves + the switcher, its history is wiped from the outbox and session store, and if + it was the open lane the app falls back to the channel's flat lane). The + right-click handler is a skiko `expect`/`actual` (`iris/ui/ContextMenu.kt`); on touch it is a no-op (long-press covers it). ### Search + - Search bar (chat header or top) with a **scope toggle**: "Search everywhere" / "Search in this chat/channel". → `search` frame → results list → tap jumps to the message (navigate + highlight). ### Attach media + - Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF. - Selected files show as **preview chips** in the composer (thumbnail + name + remove). On send: `media.upload` (chunked) for each, then `message.send` with `media_refs`. ### Voice input (mic button) + - Mic button (right of composer, toggles to send when text is present). - Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder, OGG/Opus, 44.1 kHz mono). @@ -199,11 +214,13 @@ app/shared/src/ transcribes it. No client-side STT. ### Push notifications + - FCM service (see `08-push.md`): foreground banner + background foreground service → `sync`. Notification channel per chat. Tap → deep-link to chat. - ntfy fallback: foreground service maintains the subscription. ### Live playback + - AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player (audio: mini-player; video: inline + fullscreen + PiP). Documents/images → viewer / open-with. @@ -211,12 +228,14 @@ app/shared/src/ ## 10.6 Layout (Telegram-style, per reference image) **Two layout modes** (decision: user-toggleable, **single-pane default**): + - **Single-pane (default on phones):** chat full-screen; channel list in a swipeable drawer (hamburger / edge swipe). - **Two-pane (Telegram-style, like the reference):** persistent left channel rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings. **Chat screen anatomy (matches reference):** + - **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow (⋮) menu (thread toggle, channel menu, set cron target, clear). - **Message list:** date separators ("7. August"); user bubbles **right** @@ -245,4 +264,4 @@ color. Accent = user's chosen brand color (default indigo, like the reference). does a real `hello` (not just a TCP probe — per hermes desktop guidance, the auth leg must be exercised). On success → save (secure storage) → main. - States: connecting / connected / reconnecting / degraded / auth-failed — each - with honest copy and a way out. \ No newline at end of file + with honest copy and a way out. diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index a3a58bf..e6328dd 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -86,7 +86,7 @@ "search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } }, "sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } }, "history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } }, - "message.delete": { "description": "Delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } }, + "message.delete": { "description": "Completely delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), then broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } }, "fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, "ping": { "payload": { "ts": { "type": "integer" } } } } diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 5115d48..932bc5c 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -114,6 +114,7 @@ from hermes_constants import get_hermes_home # noqa: E402 from . import media as media_bridge # noqa: E402 from . import protocol # noqa: E402 +from . import purge as purge_bridge # noqa: E402 from . import search as search_bridge # noqa: E402 from .channels import get_directory # noqa: E402 from .outbox import Outbox # noqa: E402 @@ -2519,6 +2520,28 @@ class AndroidAdapter(BasePlatformAdapter): ), ) return + # Complete deletion: wipe the lane's history from the outbox (so + # ``history`` / ``sync`` can't resurrect it) and from the hermes + # session store (so no search trace survives). A channel delete takes + # its threads with it (thread_id=None); a thread delete is scoped to + # its parent channel + thread_id. + if entry.get("kind") == "thread": + lane_chat_id = entry.get("parent_chat_id") or chat_id + thread_id = chat_id + else: + lane_chat_id = chat_id + thread_id = None + removed_frames = self._outbox.delete_lane(lane_chat_id, thread_id=thread_id) + removed_msgs = purge_bridge.delete_lane( + get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id + ) + logger.info( + "android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s", + chat_id, + entry.get("kind"), + removed_frames, + removed_msgs, + ) resp = protocol.channel_deleted(chat_id) resp.id = frame.id await self._ws_server.broadcast(resp) @@ -2663,13 +2686,14 @@ class AndroidAdapter(BasePlatformAdapter): async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None: """Handle an inbound ``message.delete`` request. - Removes the requested message(s) from the outbox (so ``history`` and - ``sync`` no longer return them) and broadcasts ``message.deleted`` to - every device (outboxed too, so an offline device learns of the - deletion on its next ``sync``). Deleting is idempotent: a message that - is already gone (pruned by retention) simply yields 0 removed rows, - and the ``message.deleted`` broadcast is still emitted so live caches - drop it. + Completely deletes the requested message(s): they are removed from the + outbox (so ``history`` and ``sync`` no longer return them) **and** from + the hermes session store (so no search trace survives and they are not + recoverable). ``message.deleted`` is broadcast to every device + (outboxed too, so an offline device learns of the deletion on its next + ``sync``). Deleting is idempotent: a message that is already gone + (pruned by retention) simply yields 0 removed rows, and the + ``message.deleted`` broadcast is still emitted so live caches drop it. """ payload = frame.payload chat_id = frame.chat_id or payload.get("chat_id") @@ -2698,15 +2722,30 @@ class AndroidAdapter(BasePlatformAdapter): ) return removed = 0 + purged = 0 + db_path = get_hermes_home() / "state.db" for mid in message_ids: + # Read the final frame data first (role / text / ts) so the + # session-store row can be matched, then drop the outbox frames. + info = self._outbox.message_info(chat_id, mid, thread_id=thread_id) removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id) + if info: + purged += purge_bridge.delete_message( + db_path, + chat_id, + thread_id, + info.get("role") or "", + info.get("text") or "", + info.get("ts"), + ) logger.info( - "android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s", + "android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s", device_id, chat_id, thread_id, message_ids, removed, + purged, ) resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id) resp.id = frame.id diff --git a/gateway-plugin/channels.py b/gateway-plugin/channels.py index 5fb1c94..8d2a788 100644 --- a/gateway-plugin/channels.py +++ b/gateway-plugin/channels.py @@ -293,20 +293,28 @@ class ChannelDirectory: return self.get(chat_id) def delete(self, chat_id: str) -> dict[str, Any] | None: - """Soft-delete (archive) a channel. History stays for search. + """Hard-delete a channel or thread (and, for a channel, its threads). - The default channel cannot be deleted. Returns the (archived) entry, - or ``None`` when the id is unknown / is the default. + The row is removed from the directory entirely -- not recoverable. The + caller (adapter) is responsible for wiping the lane's history from the + outbox and the hermes session store so no search trace survives. + + The default channel cannot be deleted. Returns the (deleted) entry, or + ``None`` when the id is unknown / is the default. """ with self._lock: row = self._conn.execute( - "SELECT is_default FROM channels WHERE chat_id = ?", (chat_id,) + "SELECT * FROM channels WHERE chat_id = ?", (chat_id,) ).fetchone() if row is None or row["is_default"]: return None - self._conn.execute("UPDATE channels SET archived = 1 WHERE chat_id = ?", (chat_id,)) + entry = _row_to_entry(row) + self._conn.execute("DELETE FROM channels WHERE chat_id = ?", (chat_id,)) + # A channel takes its threads with it. + if entry["kind"] != KIND_THREAD: + self._conn.execute("DELETE FROM channels WHERE parent_chat_id = ?", (chat_id,)) self._conn.commit() - return self.get(chat_id) + return entry # ── reads ───────────────────────────────────────────────────────────── diff --git a/gateway-plugin/outbox.py b/gateway-plugin/outbox.py index 0e2c252..89a304d 100644 --- a/gateway-plugin/outbox.py +++ b/gateway-plugin/outbox.py @@ -272,6 +272,56 @@ class Outbox: # ── message deletion ────────────────────────────────────────────────── + def message_info( + self, + chat_id: str, + message_id: str, + thread_id: str | None = None, + ) -> dict[str, Any] | None: + """Look up a message's final frame data (role / text / ts) in the outbox. + + Used to match a ``message.delete`` to the hermes session-store row + (which is keyed by content + timestamp, not the plugin's message id). + Returns ``{role, text, ts}`` for the message's final frame -- a + standalone ``message`` frame when present, else the ``message.stop`` + frame of a streamed reply -- or ``None`` when the message is not in the + outbox (e.g. already pruned by retention). + """ + if not message_id: + return None + with self._lock: + rows = self._conn.execute( + "SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,) + ).fetchall() + msg_frame: dict[str, Any] | None = None + stop_frame: dict[str, Any] | None = None + for r in rows: + try: + frame = json.loads(r["frame"]) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(frame, dict): + continue + if thread_id is not None and frame.get("thread_id") != thread_id: + continue + payload = frame.get("payload") + if not isinstance(payload, dict) or payload.get("message_id") != message_id: + continue + ftype = frame.get("type") + if ftype == "message": + msg_frame = { + "role": payload.get("role"), + "text": payload.get("text", ""), + "ts": payload.get("ts"), + } + elif ftype == "message.stop": + stop_frame = { + "role": "assistant", + "text": payload.get("final_text", ""), + "ts": payload.get("ts"), + } + return msg_frame or stop_frame + def delete_message( self, chat_id: str, @@ -319,6 +369,40 @@ class Outbox: self._conn.commit() return len(cursors) + def delete_lane(self, chat_id: str, thread_id: str | None = None) -> int: + """Remove every outbox frame for a lane (channel or thread). + + * ``thread_id is None`` -> a **channel**: all frames whose ``chat_id`` + column is *chat_id* (the flat lane plus every thread under it). + * ``thread_id`` set -> a **thread**: frames for *chat_id* whose frame + carries that ``thread_id``. + + Called on channel/thread deletion so neither ``history`` nor a ``sync`` + replay can resurrect the lane's messages. Returns the number of rows + removed. + """ + with self._lock: + rows = self._conn.execute( + "SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,) + ).fetchall() + cursors: list[int] = [] + for r in rows: + if thread_id is None: + cursors.append(int(r["cursor"])) + continue + try: + frame = json.loads(r["frame"]) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(frame, dict) and frame.get("thread_id") == thread_id: + cursors.append(int(r["cursor"])) + if not cursors: + return 0 + for cursor in cursors: + self._conn.execute("DELETE FROM outbox WHERE cursor = ?", (cursor,)) + self._conn.commit() + return len(cursors) + # ── retention ───────────────────────────────────────────────────────── def _maybe_prune(self) -> None: diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index 51cf1f6..5990934 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -556,7 +556,7 @@ def channel_renamed(entry: dict[str, Any]) -> Frame: def channel_deleted(chat_id: str) -> Frame: - """Broadcast: a channel was archived (soft-deleted).""" + """Broadcast: a channel or thread was deleted (its history wiped too).""" return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id}) diff --git a/gateway-plugin/purge.py b/gateway-plugin/purge.py new file mode 100644 index 0000000..41b895a --- /dev/null +++ b/gateway-plugin/purge.py @@ -0,0 +1,155 @@ +"""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()