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:
1 parent
9286937e2d
commit
17bf41a0b9
11 files changed
+373
-51
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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_<n>`, 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).
|
||||
`10-android-app.md` state rules).
|
||||
+29
-10
@@ -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.
|
||||
with honest copy and a way out.
|
||||
@@ -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" } } }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user