Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 80 additions & 22 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16096,10 +16096,10 @@ def _render_trace(sid):
return
output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports"

def _export_one(session_id: str):
def _export_one(session_id: str, *, include_lineage: bool = False):
data = (
db.export_session_lineage(session_id)
if getattr(args, "lineage", "single") == "logical"
if include_lineage
else db.export_session(session_id)
)
if not data:
Expand Down Expand Up @@ -16129,30 +16129,85 @@ def _export_one(session_id: str):
print(f"Session '{args.session_id}' not found.")
db.close()
return
try:
data, exported_path = _export_one(resolved_session_id)
except FileExistsError as e:
print(f"Export already exists: {e}. Pass --force to overwrite.")
db.close()
return
if not data or not exported_path:
print(f"Session '{args.session_id}' not found.")
db.close()
return
message_count = len(data.get("messages") or [])
suffix = "" if message_count == 1 else "s"
print(f"Exported 1 session ({message_count} message{suffix}) to {exported_path}")
delete_target_ids = [resolved_session_id]
if args.delete_after_verified:
ok, reason = verify_export_file(exported_path, data)
if not ok:
print(f"Export verification failed; not deleting: {reason}")
delete_target_ids = db.get_session_delete_targets(
resolved_session_id
)

exported_items = []
for target_id in delete_target_ids:
try:
data, exported_path = _export_one(
target_id,
include_lineage=(
target_id == resolved_session_id
and getattr(args, "lineage", "single") == "logical"
),
)
except FileExistsError as e:
print(
f"Export already exists: {e}. "
"Pass --force to overwrite."
)
db.close()
return
if not data or not exported_path:
print(
f"Session '{target_id}' disappeared during export; "
"nothing was deleted."
)
db.close()
return
exported_items.append((data, exported_path))

message_count = sum(
len(data.get("messages") or [])
for data, _path in exported_items
)
suffix = "" if message_count == 1 else "s"
if len(exported_items) == 1:
print(
f"Exported 1 session ({message_count} message{suffix}) "
f"to {exported_items[0][1]}"
)
else:
print(
f"Exported {len(exported_items)} sessions "
f"({message_count} message{suffix}) to {output_dir}"
)
if args.delete_after_verified:
for data, exported_path in exported_items:
ok, reason = verify_export_file(exported_path, data)
if not ok:
print(
"Export verification failed; not deleting "
f"session '{data.get('id')}': {reason}"
)
db.close()
return
sessions_dir = get_hermes_home() / "sessions"
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
print(f"Deleted exported session '{resolved_session_id}'.")
if db.delete_session(
resolved_session_id,
sessions_dir=sessions_dir,
expected_delete_ids=delete_target_ids,
):
delegate_count = len(delete_target_ids) - 1
delegate_suffix = (
""
if not delegate_count
else f" and {delegate_count} delegate session"
f"{'' if delegate_count == 1 else 's'}"
)
print(
f"Deleted exported session '{resolved_session_id}'"
f"{delegate_suffix}."
)
else:
print(f"Exported, but session '{resolved_session_id}' was not deleted because it was not found.")
print(
f"Exported, but session '{resolved_session_id}' was "
"not deleted because its delegate set changed."
)
db.close()
return

Expand All @@ -16178,7 +16233,10 @@ def _export_one(session_id: str):
exported = 0
for row in candidates:
try:
data, exported_path = _export_one(row["id"])
data, exported_path = _export_one(
row["id"],
include_lineage=getattr(args, "lineage", "single") == "logical",
)
except FileExistsError as e:
print(f"Skipping existing export: {e}. Pass --force to overwrite.")
continue
Expand Down
34 changes: 33 additions & 1 deletion hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -8959,10 +8959,28 @@ def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None
except OSError:
pass

def get_session_delete_targets(self, session_id: str) -> List[str]:
"""Return every session row that :meth:`delete_session` would remove.

The requested session is first, followed by its recursively discovered
delegate/subagent children. Branch and compression children are not
included because deletion preserves them by orphaning their parent
reference.
"""
with self._lock:
exists = self._conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)
).fetchone()
if not exists:
return []
delegate_ids = _collect_delegate_child_ids(self._conn, [session_id])
return [session_id, *sorted(delegate_ids)]

def delete_session(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
expected_delete_ids: Optional[List[str]] = None,
) -> bool:
"""Delete a session and all its messages.

Expand All @@ -8972,16 +8990,30 @@ def delete_session(
(``parent_session_id → NULL``) so they remain accessible independently.
When *sessions_dir* is provided, also removes on-disk transcript
files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted
session. Returns True if the session was found and deleted.
session. When *expected_delete_ids* is provided, deletion proceeds only
if the parent plus delegate cascade still matches that exact set. This
lets export-before-delete callers fail closed if a new delegate appears
after they materialize their archive. Returns True if the session was
found and deleted.
"""
removed_delegate_ids: List[str] = []
expected_ids = (
set(expected_delete_ids) if expected_delete_ids is not None else None
)

def _do(conn):
cursor = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
)
if cursor.fetchone()[0] == 0:
return False
if expected_ids is not None:
actual_ids = {
session_id,
*_collect_delegate_child_ids(conn, [session_id]),
}
if actual_ids != expected_ids:
return False
removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id]))
# Orphan remaining child sessions (branches, etc.) so FK is satisfied.
conn.execute(
Expand Down
71 changes: 70 additions & 1 deletion tests/hermes_cli/test_sessions_export_md_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,12 @@ def resolve_session_id(self, session_id):
def export_session(self, session_id):
return {"id": "s1", "title": "Delete", "message_count": 1, "messages": [{"role": "user", "content": "safe"}]}

def get_session_delete_targets(self, session_id):
return [session_id]

def delete_session(self, session_id, **kwargs):
captured["deleted"] = session_id
captured["expected_delete_ids"] = kwargs["expected_delete_ids"]
return True

def close(self):
Expand All @@ -363,11 +367,76 @@ def close(self):

main_mod.main()

assert captured == {"deleted": "s1"}
assert captured == {"deleted": "s1", "expected_delete_ids": ["s1"]}
assert len(list(tmp_path.glob("*.md"))) == 1
assert "Deleted exported session 's1'" in capsys.readouterr().out


def test_sessions_export_md_exports_delegate_cascade_before_deleting(
monkeypatch, tmp_path, capsys
):
import hermes_cli.main as main_mod
import hermes_state

db_path = tmp_path / "state.db"
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
real_session_db = hermes_state.SessionDB
db = real_session_db(db_path)
db.create_session("parent", "cli")
db.append_message("parent", "user", "parent transcript")
db.create_session(
"delegate",
"subagent",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
db.append_message("delegate", "assistant", "delegate-only result")
db.close()
(sessions_dir / "parent.jsonl").write_text("parent", encoding="utf-8")
(sessions_dir / "delegate.jsonl").write_text("delegate", encoding="utf-8")

monkeypatch.setattr(
hermes_state, "SessionDB", lambda: real_session_db(db_path)
)
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
output_dir = tmp_path / "exports"
monkeypatch.setattr(
sys,
"argv",
[
"hermes",
"sessions",
"export",
"--format",
"md",
"--session-id",
"parent",
"--delete-after-verified",
"--yes",
str(output_dir),
],
)

main_mod.main()

exported = [
path.read_text(encoding="utf-8") for path in output_dir.glob("*.md")
]
assert len(exported) == 2
assert any("parent transcript" in text for text in exported)
assert any("delegate-only result" in text for text in exported)
check = real_session_db(db_path)
assert check.get_session("parent") is None
assert check.get_session("delegate") is None
check.close()
assert not (sessions_dir / "parent.jsonl").exists()
assert not (sessions_dir / "delegate.jsonl").exists()
output = capsys.readouterr().out
assert "Exported 2 sessions (2 messages)" in output
assert "and 1 delegate session" in output


def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path, capsys):
"""--older-than accepts the same AGE grammar as prune ('2w', '5h', ISO)."""
import hermes_cli.main as main_mod
Expand Down
33 changes: 33 additions & 0 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4818,6 +4818,39 @@ def test_delete_parent_cascades_delegate_children(self, db):
assert db.get_session("delegate") is None
assert db.get_session("branch") is not None

def test_delete_session_expected_targets_fail_closed_on_new_delegate(self, db):
db.create_session("parent", "cli")
db.create_session(
"delegate",
"cli",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)
db.create_session(
"branch",
"cli",
parent_session_id="parent",
model_config={"_branched_from": "parent"},
)

expected_ids = db.get_session_delete_targets("parent")
assert expected_ids == ["parent", "delegate"]

db.create_session(
"late-delegate",
"cli",
parent_session_id="parent",
model_config={"_delegate_from": "parent"},
)

assert (
db.delete_session("parent", expected_delete_ids=expected_ids) is False
)
assert db.get_session("parent") is not None
assert db.get_session("delegate") is not None
assert db.get_session("late-delegate") is not None
assert db.get_session("branch") is not None

def test_v16_migration_tags_linked_delegate_rows(self, tmp_path):
"""Pre-marker linked subagent rows get tagged, then cascade with parent."""
import json
Expand Down
2 changes: 1 addition & 1 deletion website/docs/user-guide/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ hermes sessions export --format md --model sonnet --min-messages 50 --redact
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes
```

Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share.
Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. Because deleting a parent session also removes its delegate/subagent sessions, this mode exports and verifies each delegate in a separate file before deleting anything. If the delegate set changes during export, deletion is refused. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share.

### Delete a Session

Expand Down
Loading