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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user