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

+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()