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

+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: