Make thread/channel/message deletion complete (hard delete)

Deleting a thread, channel, or message was a no-op/soft-delete: messages
were only dropped from the plugin outbox (still in hermes' session store,
hence searchable/recoverable) and channels/threads were merely archived.

Now deletion is complete and non-recoverable, with no search trace:

- purge.py (new): hard-delete from hermes' session store (state.db).
  delete_lane wipes a channel's/thread's sessions + messages; deleting a
  messages row also drops it from the FTS5 index via the delete triggers.
  delete_message removes one message, matched by (session, role, exact
  content, closest timestamp) since plugin m_<hex> ids aren't persisted.
- channels.py: delete() hard-deletes the row (and a channel's child
  threads) instead of archiving.
- outbox.py: add delete_lane() (wipe all frames for a lane) and
  message_info() (read a message's final role/text/ts for the match).
- adapter.py: on_channel_delete wipes outbox + session store;
  on_message_delete purges the session-store row per message.
- App: delete confirmations no longer claim history stays for search;
  ChannelStore removes a channel's threads on channel delete.
- Docs updated to describe hard deletion.
This commit is contained in:
ARIA committed 2026-08-21 19:47:55 +02:00
1 parent 9286937e2d
commit 17bf41a0b9
11 files changed
+373 -51

No files matched your search

@@ -29,12 +29,21 @@ class ChannelStore {
/** Reconcile a server frame into the cache. */ /** Reconcile a server frame into the cache. */
fun onFrame(frame: Frame) { fun onFrame(frame: Frame) {
when (frame.type) { when (frame.type) {
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame) TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> {
TYPE_CHANNEL_DELETED -> remove(frame) upsert(frame)
}
TYPE_CHANNEL_DELETED -> {
remove(frame)
}
TYPE_CHANNEL_LIST -> { TYPE_CHANNEL_LIST -> {
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) } frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
} }
else -> Unit
else -> {
Unit
}
} }
} }
@@ -48,7 +57,10 @@ class ChannelStore {
private fun remove(frame: Frame) { private fun remove(frame: Frame) {
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return val p = frame.payloadAs<ChannelDeletedPayload>() ?: 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<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
@@ -62,20 +74,16 @@ class ChannelStore {
// ── UI helpers ──────────────────────────────────────────────────────── // ── UI helpers ────────────────────────────────────────────────────────
/** Channels (default + user channels) for the drawer/rail. */ /** Channels (default + user channels) for the drawer/rail. */
fun channelsForDrawer(): List<ChannelInfo> = fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
_channels.value.filter { it.kind != "thread" }
/** Threads under a channel, for the topic switcher. */ /** Threads under a channel, for the topic switcher. */
fun threadsFor(chatId: String): List<ChannelInfo> = fun threadsFor(chatId: String): List<ChannelInfo> = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
fun byId(chatId: String): ChannelInfo? = fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId }
_channels.value.firstOrNull { it.chatId == chatId }
fun defaultChannel(): ChannelInfo? = fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
fun clear() { fun clear() {
_channels.value = emptyList() _channels.value = emptyList()
} }
} }
@@ -839,7 +839,7 @@ fun ChatScreen(controller: IrisController) {
onDismissRequest = { deleteThread = null }, onDismissRequest = { deleteThread = null },
title = { Text("Delete topic?") }, title = { Text("Delete topic?") },
text = { 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 = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
@@ -861,9 +861,9 @@ fun ChatScreen(controller: IrisController) {
text = { text = {
Text( Text(
if (n == 1) { 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 { } 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( AlertDialog(
onDismissRequest = { channelToDelete = null }, onDismissRequest = { channelToDelete = null },
title = { Text("Delete channel?") }, 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 = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
channelToDelete = null channelToDelete = null
+11 -4
View File
@@ -376,6 +376,11 @@ Answer an interactive picker.
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}} {"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` ### `search`
```json ```json
@@ -409,10 +414,12 @@ Load a page of messages for a chat/thread (initial open, scroll-up pagination).
### `message.delete` ### `message.delete`
Delete the given message(s) from a chat/thread. The server removes them from the Completely delete the given message(s) from a chat/thread. The server removes
outbox (so `history`/`sync` no longer return them) and broadcasts them from the outbox (so `history`/`sync` no longer return them) **and** from
`message.deleted` to every device. Idempotent: a message already gone (pruned by the hermes session store (so no search trace survives and they are not
retention) still yields a `message.deleted` broadcast so live caches drop it. 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 ```json
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null, {"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
+6 -4
View File
@@ -8,7 +8,7 @@ The hermes gateway already models conversations as `SessionSource` with
gateway identity concepts**. gateway identity concepts**.
| App concept | hermes primitive | Example | | App concept | hermes primitive | Example |
|---|---|---| | --- | --- | --- |
| Default chat | home channel `chat_id` | `android:default` | | 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 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` | | 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_<n>`, stores in directory, broadcasts `chat_id = android:chan_<n>`, stores in directory, broadcasts
`channel.created` to all devices. The new channel appears in the channel list. `channel.created` to all devices. The new channel appears in the channel list.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is soft — marks directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
archived, keeps history for search). -- 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* - **Automation channels:** a channel can be marked *automation*
(`channel.set_automation {on}`, long-press / right-click menu; the default (`channel.set_automation {on}`, long-press / right-click menu; the default
channel cannot be marked). Automation channels are **read-only for the 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 `hello.ack` and `channel.*` frames carry the directory. App keeps a local copy
(Room) and reconciles on `channel.*` events (merge, don't clobber — see (Room) and reconciles on `channel.*` events (merge, don't clobber — see
`10-android-app.md` state rules). `10-android-app.md` state rules).
+29 -10
View File
@@ -6,7 +6,7 @@ the code is in `app/shared` (commonMain) so the Desktop app reuses it.
## 10.1 Tech stack ## 10.1 Tech stack
| Concern | Choice | | Concern | Choice |
|---|---| | --- | --- |
| Language | Kotlin | | Language | Kotlin |
| UI | Jetpack Compose (Material 3), Compose Navigation | | UI | Jetpack Compose (Material 3), Compose Navigation |
| Async | Kotlinx Coroutines + Flow | | Async | Kotlinx Coroutines + Flow |
@@ -74,6 +74,7 @@ app/shared/src/
## 10.5 Feature implementation (your checklist) ## 10.5 Feature implementation (your checklist)
### Input box, auto-grow (max height) ### Input box, auto-grow (max height)
- Compose `BasicTextField` inside a `Box` with - Compose `BasicTextField` inside a `Box` with
`Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at `Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at
160dp, then scrolls internally. 160dp, then scrolls internally.
@@ -82,6 +83,7 @@ app/shared/src/
(configurable: Enter=send vs Enter=newline). (configurable: Enter=send vs Enter=newline).
### Slash commands ### Slash commands
- **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up - **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up
over the input listing the command catalog. The catalog is served by the over the input listing the command catalog. The catalog is served by the
gateway via `commands.catalog` request → response with gateway via `commands.catalog` request → response with
@@ -102,6 +104,7 @@ app/shared/src/
sheet with the options; answer via `picker.select`) — planned. sheet with the options; answer via `picker.select`) — planned.
### Streaming (app-controlled) ### Streaming (app-controlled)
- **Settings → "Streaming"** toggle (default on). When off, the app ignores - **Settings → "Streaming"** toggle (default on). When off, the app ignores
`message.start`/`message.update` frames and shows each reply as a single `message.start`/`message.update` frames and shows each reply as a single
final message on `message.stop` (the typing indicator covers the wait). 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). devices (see `05-streaming.md` §5.1).
### Tool output (app-controlled verbosity) ### Tool output (app-controlled verbosity)
- `ToolCard` renders `tool.start/progress/end` frames. - `ToolCard` renders `tool.start/progress/end` frames.
- The gateway always supplies the **full** tool data: it forces - The gateway always supplies the **full** tool data: it forces
`display.platforms.android.tool_progress: verbose` (so the progress line `display.platforms.android.tool_progress: verbose` (so the progress line
@@ -123,6 +127,7 @@ app/shared/src/
- Spinner while running; ✓/✗ + duration on `tool.end`. - Spinner while running; ✓/✗ + duration on `tool.end`.
### Reasoning before message ### Reasoning before message
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy** - `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
button) rendered **above** the message body from the `reasoning` field. button) rendered **above** the message body from the `reasoning` field.
Matches the reference screenshot. Matches the reference screenshot.
@@ -132,9 +137,11 @@ app/shared/src/
tap-to-toggle always works. tap-to-toggle always works.
### Intermediate messages ### Intermediate messages
- `commentary` frames → dimmed/smaller bubble, distinct from final answers. - `commentary` frames → dimmed/smaller bubble, distinct from final answers.
### Message selection + delete ### Message selection + delete
- **Long-press** a message bubble (touch) or **right-click** it (desktop mouse) - **Long-press** a message bubble (touch) or **right-click** it (desktop mouse)
enters selection mode: the tapped message is selected (a circular check appears enters selection mode: the tapped message is selected (a circular check appears
beside each bubble) and the composer is replaced by a selection toolbar 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 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. 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 - **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the
current lane. The server removes the message(s) from the outbox (so current lane. The server completely deletes the message(s): they are removed
`history`/`sync` no longer return them) and broadcasts `message.deleted` to from the outbox (so `history`/`sync` no longer return them) **and** from the
every device; each device drops them from its cache (the requesting device hermes session store (so no search trace survives and they are not
also drops them locally for snappy UX). Deleting is idempotent. 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 / stop / steer
- `agent.busy` → show "thinking…" indicator in chat header (animated dots). - `agent.busy` → show "thinking…" indicator in chat header (animated dots).
- `agent.idle` → clear indicator. - `agent.idle` → clear indicator.
- **Stop button** (appears in header while busy): sends `agent.stop`. - **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`. `agent.steer` (injects mid-turn) rather than queuing a new `message.send`.
### Threading + channels ### Threading + channels
- **Channel list** (drawer on single-pane; left rail on two-pane) = default chat - **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. highlight bar) — matches the reference left sidebar.
- **Thread toggle** in the default chat header: "Threads on/off". On → topic - **Thread toggle** in the default chat header: "Threads on/off". On → topic
switcher above the message list (each topic = a `thread_id`). switcher above the message list (each topic = a `thread_id`).
@@ -171,23 +182,27 @@ app/shared/src/
menu offers "Set as cron target". menu offers "Set as cron target".
- **Topic context menu** — long-press a topic chip (touch) or right-click it - **Topic context menu** — long-press a topic chip (touch) or right-click it
(desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled (desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled
dialog); Delete → confirm → `channel.delete` (soft-delete; the thread leaves dialog); Delete → confirm → `channel.delete` (hard delete; the thread leaves
the switcher and, if it was the open lane, the app falls back to the channel's the switcher, its history is wiped from the outbox and session store, and if
flat lane). The right-click handler is a skiko `expect`/`actual` 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). (`iris/ui/ContextMenu.kt`); on touch it is a no-op (long-press covers it).
### Search ### Search
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" / - Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
"Search in this chat/channel". → `search` frame → results list → tap jumps to "Search in this chat/channel". → `search` frame → results list → tap jumps to
the message (navigate + highlight). the message (navigate + highlight).
### Attach media ### Attach media
- Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF. - Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF.
- Selected files show as **preview chips** in the composer (thumbnail + name + - Selected files show as **preview chips** in the composer (thumbnail + name +
remove). On send: `media.upload` (chunked) for each, then `message.send` with remove). On send: `media.upload` (chunked) for each, then `message.send` with
`media_refs`. `media_refs`.
### Voice input (mic button) ### Voice input (mic button)
- Mic button (right of composer, toggles to send when text is present). - Mic button (right of composer, toggles to send when text is present).
- Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder, - Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder,
OGG/Opus, 44.1 kHz mono). OGG/Opus, 44.1 kHz mono).
@@ -199,11 +214,13 @@ app/shared/src/
transcribes it. No client-side STT. transcribes it. No client-side STT.
### Push notifications ### Push notifications
- FCM service (see `08-push.md`): foreground banner + background foreground - FCM service (see `08-push.md`): foreground banner + background foreground
service → `sync`. Notification channel per chat. Tap → deep-link to chat. service → `sync`. Notification channel per chat. Tap → deep-link to chat.
- ntfy fallback: foreground service maintains the subscription. - ntfy fallback: foreground service maintains the subscription.
### Live playback ### Live playback
- AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player - AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player
(audio: mini-player; video: inline + fullscreen + PiP). Documents/images → (audio: mini-player; video: inline + fullscreen + PiP). Documents/images →
viewer / open-with. viewer / open-with.
@@ -211,12 +228,14 @@ app/shared/src/
## 10.6 Layout (Telegram-style, per reference image) ## 10.6 Layout (Telegram-style, per reference image)
**Two layout modes** (decision: user-toggleable, **single-pane default**): **Two layout modes** (decision: user-toggleable, **single-pane default**):
- **Single-pane (default on phones):** chat full-screen; channel list in a - **Single-pane (default on phones):** chat full-screen; channel list in a
swipeable drawer (hamburger / edge swipe). swipeable drawer (hamburger / edge swipe).
- **Two-pane (Telegram-style, like the reference):** persistent left channel - **Two-pane (Telegram-style, like the reference):** persistent left channel
rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings. rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings.
**Chat screen anatomy (matches reference):** **Chat screen anatomy (matches reference):**
- **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow - **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow
(⋮) menu (thread toggle, channel menu, set cron target, clear). (⋮) menu (thread toggle, channel menu, set cron target, clear).
- **Message list:** date separators ("7. August"); user bubbles **right** - **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 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. auth leg must be exercised). On success → save (secure storage) → main.
- States: connecting / connected / reconnecting / degraded / auth-failed — each - States: connecting / connected / reconnecting / degraded / auth-failed — each
with honest copy and a way out. with honest copy and a way out.
+1 -1
View File
@@ -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." } } }, "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" } } }, "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)." } } }, "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" } } }, "fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"ping": { "payload": { "ts": { "type": "integer" } } } "ping": { "payload": { "ts": { "type": "integer" } } }
} }
+47 -8
View File
@@ -114,6 +114,7 @@ from hermes_constants import get_hermes_home # noqa: E402
from . import media as media_bridge # noqa: E402 from . import media as media_bridge # noqa: E402
from . import protocol # noqa: E402 from . import protocol # noqa: E402
from . import purge as purge_bridge # noqa: E402
from . import search as search_bridge # noqa: E402 from . import search as search_bridge # noqa: E402
from .channels import get_directory # noqa: E402 from .channels import get_directory # noqa: E402
from .outbox import Outbox # noqa: E402 from .outbox import Outbox # noqa: E402
@@ -2519,6 +2520,28 @@ class AndroidAdapter(BasePlatformAdapter):
), ),
) )
return 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 = protocol.channel_deleted(chat_id)
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) 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: async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``message.delete`` request. """Handle an inbound ``message.delete`` request.
Removes the requested message(s) from the outbox (so ``history`` and Completely deletes the requested message(s): they are removed from the
``sync`` no longer return them) and broadcasts ``message.deleted`` to outbox (so ``history`` and ``sync`` no longer return them) **and** from
every device (outboxed too, so an offline device learns of the the hermes session store (so no search trace survives and they are not
deletion on its next ``sync``). Deleting is idempotent: a message that recoverable). ``message.deleted`` is broadcast to every device
is already gone (pruned by retention) simply yields 0 removed rows, (outboxed too, so an offline device learns of the deletion on its next
and the ``message.deleted`` broadcast is still emitted so live caches ``sync``). Deleting is idempotent: a message that is already gone
drop it. (pruned by retention) simply yields 0 removed rows, and the
``message.deleted`` broadcast is still emitted so live caches drop it.
""" """
payload = frame.payload payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id") chat_id = frame.chat_id or payload.get("chat_id")
@@ -2698,15 +2722,30 @@ class AndroidAdapter(BasePlatformAdapter):
) )
return return
removed = 0 removed = 0
purged = 0
db_path = get_hermes_home() / "state.db"
for mid in message_ids: 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) 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( 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, device_id,
chat_id, chat_id,
thread_id, thread_id,
message_ids, message_ids,
removed, removed,
purged,
) )
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id) resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
resp.id = frame.id resp.id = frame.id
+14 -6
View File
@@ -293,20 +293,28 @@ class ChannelDirectory:
return self.get(chat_id) return self.get(chat_id)
def delete(self, chat_id: str) -> dict[str, Any] | None: 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, The row is removed from the directory entirely -- not recoverable. The
or ``None`` when the id is unknown / is the default. 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: with self._lock:
row = self._conn.execute( row = self._conn.execute(
"SELECT is_default FROM channels WHERE chat_id = ?", (chat_id,) "SELECT * FROM channels WHERE chat_id = ?", (chat_id,)
).fetchone() ).fetchone()
if row is None or row["is_default"]: if row is None or row["is_default"]:
return None 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() self._conn.commit()
return self.get(chat_id) return entry
# ── reads ───────────────────────────────────────────────────────────── # ── reads ─────────────────────────────────────────────────────────────
+84
View File
@@ -272,6 +272,56 @@ class Outbox:
# ── message deletion ────────────────────────────────────────────────── # ── 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( def delete_message(
self, self,
chat_id: str, chat_id: str,
@@ -319,6 +369,40 @@ class Outbox:
self._conn.commit() self._conn.commit()
return len(cursors) 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 ───────────────────────────────────────────────────────── # ── retention ─────────────────────────────────────────────────────────
def _maybe_prune(self) -> None: def _maybe_prune(self) -> None:
+1 -1
View File
@@ -556,7 +556,7 @@ def channel_renamed(entry: dict[str, Any]) -> Frame:
def channel_deleted(chat_id: str) -> 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}) return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id})
+155
View File
@@ -0,0 +1,155 @@
"""Complete (hard) deletion of android messages from the hermes session store.
The android plugin mints its own message ids (``m_<hex>``) 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()