fix(sessions): transcript exports keep compacted turns, so --delete-after-verified no longer deletes them unseen

In-place compaction is the default. It soft-archives every earlier row of
a session under the same id (active = 0, compacted = 1), and Desktop and
the dashboard still show those turns. The md/qmd export read the session
through export_session -> get_messages with the default live-only clause,
so it wrote only the compaction summary and the carried tail.
verify_export_file then compared the file with that same dict, and
delete_session removed every row of the session, including the archived
turns that never reached the file.

The md/qmd export now reads the display history (include_compacted), for
a single session and for --lineage logical. export_session and
export_session_lineage take include_compacted, off by default:
import_sessions inserts every message as live context, so the JSON export
and stranded-session adoption keep reading live rows only.

The other transcripts people read had the same hole without the delete:
`/save md` and `/save html` (CLI and gateway), `sessions export --format
html` (one session or all of them) and `--only user-prompts` each held 1
of 6 answers on a six-turn session after one compaction. They now read
the display history too (SAVE_TRANSCRIPT_FORMATS; export_all gains
include_compacted and reads per session then, since the display read
dedupes per session). `/save json`, JSONL and the dashboard's JSON export
stay live-only for the import reason above.

Before deleting, the verify step also re-counts the store's display rows
for every session the file covers and refuses on a mismatch. A message
that lands while the files are written, or a later export change that
reads a narrower view, now refuses the delete instead of being removed
unseen. Like the adoption retire loop, the re-count runs just before
delete_session, not inside its transaction. Rewind rows (undone turns,
the superseded originals of a carried tail) are still deleted without
being exported, as `hermes sessions delete` does: they are not part of
the history the session shows.

Measured through the real CLI on a session with 6 turns and one default
in-place compaction (15 rows, 13 shown): before, 3 messages were exported
and all 15 rows deleted, with answers 1-5 missing from the file; after,
13 messages are exported in display order, then deleted.

(cherry picked from commit adeaff1e33f1ae2b8a066ef2374bb29ade50b3b8)
This commit is contained in:
John Paul Soliva
2026-09-23 07:51:18 +09:00
committed by kshitij
parent 55138d85b2
commit fe8b643db6
10 changed files with 223 additions and 23 deletions

View File

@@ -708,7 +708,8 @@ class GatewaySessionCommandsMixin:
"""Handle /save — export the current session and send it as a document."""
import tempfile
from hermes_cli.session_export import (
SAVE_USAGE, default_save_filename, normalize_save_format, render_session_for_save)
SAVE_TRANSCRIPT_FORMATS, SAVE_USAGE, default_save_filename, normalize_save_format,
render_session_for_save)
parts = event.get_command_args().split()
redact = bool(parts) and parts[-1].lower() in ("redact", "--redact")
@@ -729,7 +730,7 @@ class GatewaySessionCommandsMixin:
# Never trust path separators from chat input; the filename is only echoed to the platform.
filename = parts[1] if len(parts) > 1 else default_save_filename(session_id, fmt)
filename = os.path.basename(filename) or default_save_filename(session_id, fmt)
export_data = await self._session_db.export_session(session_id)
export_data = await self._session_db.export_session(session_id, include_compacted=fmt in SAVE_TRANSCRIPT_FORMATS)
if not export_data:
return f"No stored messages found for this session ({session_id})."
if redact:

View File

@@ -626,7 +626,7 @@ class CLISessionMixin:
"""
from cli import datetime
from hermes_cli.session_export import (
SAVE_USAGE, normalize_save_format, render_session_for_save)
SAVE_TRANSCRIPT_FORMATS, SAVE_USAGE, normalize_save_format, render_session_for_save)
parts = cmd.split()[1:]
redact = bool(parts) and parts[-1].lower() in ("redact", "--redact")
@@ -650,7 +650,7 @@ class CLISessionMixin:
_sid = getattr(self, "session_id", None)
if _db and _sid:
try:
session_data = _db.export_session(_sid)
session_data = _db.export_session(_sid, include_compacted=fmt in SAVE_TRANSCRIPT_FORMATS)
except Exception:
session_data = None
if not session_data:

View File

@@ -207,6 +207,9 @@ def _fenced_text(text: str, *, language: str = "text") -> str:
# --- Current-session save helper (shared by CLI /save and gateway /save) ---
SAVE_FORMATS = ("json", "md", "html")
# Transcripts show what the user sees, compaction-archived turns included. JSON stays the live rows that
# import_sessions restores: it would replay archived turns as live context.
SAVE_TRANSCRIPT_FORMATS = frozenset({"md", "html"})
SAVE_USAGE = """/save — export the current session to a file
Usage: /save <format> [filename] [redact]

View File

@@ -332,11 +332,15 @@ def _cmd_export(db, args):
from hermes_cli.session_export_md import redact_session_data
return redact_session_data(data)
# HTML and --only are read by people, so they carry the turns in-place compaction archived; JSONL stays the
# live rows import_sessions restores.
shown = args.format == "html" or bool(getattr(args, "only", None))
def _collect_sessions():
"""--session-id / filters / bare export -> redacted session dicts, or None after printing an error."""
if args.session_id:
resolved = db.resolve_session_id(args.session_id)
data = _redact(db.export_session(resolved)) if resolved else None
data = _redact(db.export_session(resolved, include_compacted=shown)) if resolved else None
if not data:
_not_found(args.session_id)
return None
@@ -345,10 +349,10 @@ def _cmd_export(db, args):
candidates = db.list_prune_candidates(**filters)
if args.dry_run:
return _print_dry_run_preview(candidates, filters)
return [s for s in (_redact(db.export_session(row["id"])) for row in candidates) if s]
return [s for s in (_redact(db.export_session(row["id"], include_compacted=shown)) for row in candidates) if s]
if args.dry_run:
return print("--dry-run requires at least one filter.")
return [_redact(s) for s in db.export_all(source=None)]
return [_redact(s) for s in db.export_all(source=None, include_compacted=shown)]
if getattr(args, "only", None):
return _export_flat("only", args, _collect_sessions)
if args.format == "trace":
@@ -472,7 +476,10 @@ def _export_markdown(db, args, filters, redact):
output_dir = _export_dir(args.output)
def _export_one(session_id: str, *, include_lineage: bool = False):
data = db.export_session_lineage(session_id) if include_lineage else db.export_session(session_id)
# The history the user sees, not only the live rows: in-place compaction archives earlier turns under
# the same id, and --delete-after-verified removes every row of it.
export = db.export_session_lineage if include_lineage else db.export_session
data = export(session_id, include_compacted=True)
if not data:
return None, None
data = redact(data)
@@ -538,6 +545,14 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
return
for data, exported_path in exported_items:
ok, reason = verify_export_file(exported_path, data)
# The file only proves it matches the dict it was written from; the delete removes what the store holds
# now, so re-count the store just before it (like the adoption retire loop, outside its transaction).
exported = len(data.get("messages") or [])
shown = sum(len(db.get_messages(sid, include_compacted=True))
for sid in data.get("lineage_session_ids") or [data["id"]])
if ok and shown != exported:
ok, reason = False, (f"the session changed after it was exported ({shown} messages now, {exported} in "
"the file); run the export again")
if not ok:
print(f"Export verification failed; not deleting session '{data.get('id')}': {reason}")
return

View File

@@ -276,21 +276,23 @@ class SessionPortabilityMixin:
# ── Export ─────────────────────────────────────────────────────────────
def _with_messages(self, session: Dict[str, Any]) -> Dict[str, Any]:
messages = self.get_messages(session["id"])
def _with_messages(self, session: Dict[str, Any], include_compacted: bool = False) -> Dict[str, Any]:
messages = self.get_messages(session["id"], include_compacted=include_compacted)
return {**session, "messages": messages, "timings": _export_timings(messages, session["id"])}
def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Export a single session with all its messages as a dict."""
def export_session(self, session_id: str, include_compacted: bool = False) -> Optional[Dict[str, Any]]:
"""Export a single session with all its messages as a dict. ``include_compacted`` adds the turns
in-place compaction archived (the history the user still sees); it stays off for payloads that go
back through :meth:`import_sessions`, which would insert those turns as live context."""
session = self.get_session(session_id)
return self._with_messages(session) if session else None
return self._with_messages(session, include_compacted) if session else None
def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Export a compression lineage as one logical session dict."""
def export_session_lineage(self, session_id: str, include_compacted: bool = False) -> Optional[Dict[str, Any]]:
"""Export a compression lineage as one logical session dict (``include_compacted`` as in :meth:`export_session`)."""
lineage_ids = self.get_compression_lineage(session_id)
if not lineage_ids:
return None
segments = [seg for seg in map(self.export_session, lineage_ids) if seg]
segments = [seg for seg in (self.export_session(sid, include_compacted) for sid in lineage_ids) if seg]
if not segments:
return None
messages = [msg for seg in segments for msg in (seg.get("messages") or [])]
@@ -300,9 +302,12 @@ class SessionPortabilityMixin:
"messages": messages, "timings": _export_timings(messages, session_id),
}
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
"""Export all sessions (with messages) as dicts, e.g. for JSONL backup."""
def export_all(self, source: str = None, include_compacted: bool = False) -> List[Dict[str, Any]]:
"""Export all sessions (with messages) as dicts, e.g. for JSONL backup (``include_compacted`` as in
:meth:`export_session`; that display read dedupes per session, so it skips the batched read)."""
sessions = self.search_sessions(source=source, limit=100000)
if include_compacted:
return [self._with_messages(session, True) for session in sessions]
messages_by_session = {session["id"]: [] for session in sessions}
session_ids = list(messages_by_session)
# Stay below SQLite's legacy 999-variable limit while replacing the per-session N+1 reads.

View File

@@ -0,0 +1,67 @@
"""/save md|html is a transcript: after in-place compaction it still holds every turn the chat shows.
/save json stays the live rows import_sessions restores."""
import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
def _compacted_store(path):
from hermes_state import SessionDB
db = SessionDB(db_path=path)
db.create_session("s1", "telegram")
for i in range(1, 7):
db.append_message("s1", "user", f"question {i}")
db.append_message("s1", "assistant", f"answer {i}")
tail = [{"role": "user", "content": "question 6"}, {"role": "assistant", "content": "answer 6"}]
db.archive_and_compact("s1", [{"role": "user", "content": "[CONTEXT COMPACTION] summary"}, *tail],
watermark=db.get_active_message_watermark("s1"), tail_count=len(tail))
return db
def _cli_save(db, fmt, out):
import cli
stub = SimpleNamespace(_session_db=db, session_id="s1", conversation_history=[], model="m",
session_start=datetime(2026, 1, 1))
cli.HermesCLI.save_conversation(stub, f"/save {fmt} {out}")
return out.read_text(encoding="utf-8")
def _gateway_save(db, fmt, out):
from gateway.config import Platform
from gateway.platforms.event import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionEntry, SessionSource, build_session_key
from hermes_state import AsyncSessionDB
source = SessionSource(platform=Platform.TELEGRAM, user_id="u1", chat_id="c1", user_name="t", chat_type="dm")
runner = object.__new__(GatewayRunner)
delivered = {}
adapter = MagicMock()
adapter.send_document = AsyncMock(side_effect=lambda **kw: delivered.update(
text=open(kw["file_path"], encoding="utf-8").read()))
runner.adapters, runner._profile_adapters = {Platform.TELEGRAM: adapter}, {}
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key=build_session_key(source), session_id="s1", created_at=datetime.now(),
updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm")
runner._session_db = AsyncSessionDB(db)
event = MessageEvent(text=f"/save {fmt} {out.name}", source=source, message_id="m1")
assert asyncio.run(runner._handle_save_command(event)) == "Export complete."
return delivered["text"]
@pytest.mark.parametrize("save", [_cli_save, _gateway_save], ids=["cli", "gateway"])
@pytest.mark.parametrize("fmt, expected", [("md", 6), ("html", 6), ("json", 1)])
def test_save_transcript_holds_every_turn_the_chat_shows(tmp_path, monkeypatch, save, fmt, expected):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
db = _compacted_store(tmp_path / "state.db")
try:
text = save(db, fmt, tmp_path / f"saved.{fmt}")
finally:
db.close()
assert sum(f"answer {i}" in text for i in range(1, 7)) == expected

View File

@@ -106,7 +106,7 @@ def test_sessions_export_cli_prompt_only_stdout(monkeypatch, capsys):
captured["resolved_from"] = session_id
return "sess-123"
def export_session(self, session_id):
def export_session(self, session_id, include_compacted=False):
captured["exported"] = session_id
return _sample_session()

View File

@@ -1,5 +1,7 @@
import sys
import pytest
def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys):
import hermes_cli.main as main_mod
@@ -12,7 +14,7 @@ def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys)
captured["resolved_from"] = session_id
return "20260706_123456_abcd1234"
def export_session(self, session_id):
def export_session(self, session_id, include_compacted=False):
captured["exported"] = session_id
return {
"id": session_id,
@@ -74,7 +76,7 @@ def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path):
def resolve_session_id(self, session_id):
return "s1"
def export_session(self, session_id):
def export_session(self, session_id, include_compacted=False):
return {
"id": "s1",
"title": "Redact",
@@ -101,3 +103,110 @@ def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path):
text = next(tmp_path.glob("*.md")).read_text(encoding="utf-8")
assert secret not in text
assert "api key:" in text
def _real_store(monkeypatch, tmp_path):
"""Point the CLI's SessionDB at one real file; returns an opener for the test's own handles."""
import hermes_state
real_session_db = hermes_state.SessionDB
db_path = tmp_path / "state.db"
class _StoreAtTmp(real_session_db):
def __init__(self, *args, **kwargs):
super().__init__(db_path=db_path)
monkeypatch.setattr(hermes_state, "SessionDB", _StoreAtTmp)
return _StoreAtTmp
def _seed_six_turns(open_db, session_id, *, compact):
db = open_db()
try:
db.create_session(session_id, "cli")
for i in range(1, 7):
db.append_message(session_id, "user", f"question {i}")
db.append_message(session_id, "assistant", f"answer {i}")
if compact:
# Default in-place compaction, production shape: watermark from compression start, last turn carried.
watermark = db.get_active_message_watermark(session_id)
tail = [{"role": "user", "content": "question 6"}, {"role": "assistant", "content": "answer 6"}]
db.archive_and_compact(session_id, [{"role": "user", "content": "[CONTEXT COMPACTION] summary"}, *tail],
watermark=watermark, tail_count=len(tail))
finally:
db.close()
def _export_and_delete(monkeypatch, out_dir, session_id, *extra):
import hermes_cli.main as main_mod
monkeypatch.setattr(sys, "argv", [
"hermes", "sessions", "export", "--format", "md", "--session-id", session_id,
"--delete-after-verified", "--yes", *extra, str(out_dir),
])
main_mod.main()
@pytest.mark.parametrize("lineage", ["single", "logical"])
def test_delete_after_verified_exports_the_turns_in_place_compaction_archived(monkeypatch, tmp_path, capsys, lineage):
open_db = _real_store(monkeypatch, tmp_path)
_seed_six_turns(open_db, "s1", compact=True)
_export_and_delete(monkeypatch, tmp_path / "out", "s1", "--lineage", lineage)
text = next((tmp_path / "out").glob("*.md")).read_text(encoding="utf-8")
assert [f"answer {i}" in text for i in range(1, 7)] == [True] * 6
assert "Deleted exported session 's1'." in capsys.readouterr().out
db = open_db()
try:
assert db.get_session("s1") is None
finally:
db.close()
def test_delete_after_verified_keeps_a_session_that_gained_a_message_after_the_export(monkeypatch, tmp_path, capsys):
import hermes_cli.session_export_md as session_export_md
open_db = _real_store(monkeypatch, tmp_path)
_seed_six_turns(open_db, "s1", compact=False)
write_session_markdown = session_export_md.write_session_markdown
def write_then_a_turn_lands(*args, **kwargs):
path = write_session_markdown(*args, **kwargs)
writer = open_db()
try:
writer.append_message("s1", "user", "sent after the export was read")
finally:
writer.close()
return path
monkeypatch.setattr(session_export_md, "write_session_markdown", write_then_a_turn_lands)
_export_and_delete(monkeypatch, tmp_path / "out", "s1")
assert "Export verification failed; not deleting session 's1'" in capsys.readouterr().out
db = open_db()
try:
assert db.get_messages("s1")[-1]["content"] == "sent after the export was read"
finally:
db.close()
@pytest.mark.parametrize("argv, marker, expected", [
pytest.param(["--format", "html", "--session-id", "s1"], "answer", 6, id="html"),
pytest.param(["--format", "html"], "answer", 6, id="html-every-session"),
pytest.param(["--format", "md", "--only", "user-prompts", "--session-id", "s1"], "question", 6, id="only-prompts"),
# The importable payload keeps the live rows: import_sessions would replay archived turns as live context.
pytest.param(["--format", "jsonl", "--session-id", "s1"], "answer", 1, id="jsonl-live-only"),
])
def test_human_readable_exports_carry_the_turns_in_place_compaction_archived(
monkeypatch, tmp_path, argv, marker, expected):
import hermes_cli.main as main_mod
open_db = _real_store(monkeypatch, tmp_path)
_seed_six_turns(open_db, "s1", compact=True)
out = tmp_path / "export.out"
monkeypatch.setattr(sys, "argv", ["hermes", "sessions", "export", *argv, str(out)])
main_mod.main()
text = out.read_text(encoding="utf-8")
assert sum(f"{marker} {i}" in text for i in range(1, 7)) == expected

View File

@@ -11,7 +11,7 @@ class _FakeDB:
def resolve_session_id(self, session_id):
return "sess-123"
def export_session(self, session_id):
def export_session(self, session_id, include_compacted=False):
return {"id": "sess-123", "source": "cli", "messages": [{"role": "user", "content": "hi"}]}
def close(self):

View File

@@ -456,7 +456,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`. 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.
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. Markdown/QMD files hold the full history you see in the session, including turns that in-place compaction summarized away, and deletion is also refused if a session's message count no longer matches its file. The same holds for `--format html`, `--only user-prompts` and `/save md` or `/save html`; JSON and JSONL exports carry only the live rows, because importing them would restore the summarized turns as live context. `--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