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
+368 -46

No files matched your search

@@ -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<ChannelListPayload>()?.let { setAll(it.channels) }
}
else -> Unit
else -> {
Unit
}
}
}
@@ -48,7 +57,10 @@ class ChannelStore {
private fun remove(frame: Frame) {
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> =
@@ -62,18 +74,14 @@ class ChannelStore {
// ── UI helpers ────────────────────────────────────────────────────────
/** Channels (default + user channels) for the drawer/rail. */
fun channelsForDrawer(): List<ChannelInfo> =
_channels.value.filter { it.kind != "thread" }
fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
/** Threads under a channel, for the topic switcher. */
fun threadsFor(chatId: String): List<ChannelInfo> =
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
fun threadsFor(chatId: String): List<ChannelInfo> = _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()
@@ -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
+11 -4
View File
@@ -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,
+4 -2
View File
@@ -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
+27 -8
View File
@@ -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**
+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." } } },
"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" } } }
}
+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 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
+14 -6
View File
@@ -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 ─────────────────────────────────────────────────────────────
+84
View File
@@ -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:
+1 -1
View File
@@ -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})
+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()