diff --git a/gateway/slash_commands_session.py b/gateway/slash_commands_session.py index 4e580dc768..bfdb3cb730 100644 --- a/gateway/slash_commands_session.py +++ b/gateway/slash_commands_session.py @@ -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: diff --git a/hermes_cli/cli_session_mixin.py b/hermes_cli/cli_session_mixin.py index 8be28c7ca8..f785283881 100644 --- a/hermes_cli/cli_session_mixin.py +++ b/hermes_cli/cli_session_mixin.py @@ -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: diff --git a/hermes_cli/session_export.py b/hermes_cli/session_export.py index 819d6e8fff..188ba4ca8b 100644 --- a/hermes_cli/session_export.py +++ b/hermes_cli/session_export.py @@ -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 [filename] [redact] diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index 1778f5dce9..4488d221cf 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -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 diff --git a/hermes_state_portability.py b/hermes_state_portability.py index 147c34abd1..8e18e70ee4 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -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. diff --git a/tests/hermes_cli/test_save_transcript_history.py b/tests/hermes_cli/test_save_transcript_history.py new file mode 100644 index 0000000000..a296b43ba4 --- /dev/null +++ b/tests/hermes_cli/test_save_transcript_history.py @@ -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 diff --git a/tests/hermes_cli/test_session_export.py b/tests/hermes_cli/test_session_export.py index 4e76e84f57..cc2e3990f5 100644 --- a/tests/hermes_cli/test_session_export.py +++ b/tests/hermes_cli/test_session_export.py @@ -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() diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py index 3c2048b435..36c855fd2f 100644 --- a/tests/hermes_cli/test_sessions_export_md_cli.py +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -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 diff --git a/tests/hermes_cli/test_sessions_export_output_dir.py b/tests/hermes_cli/test_sessions_export_output_dir.py index f2d26ee264..42f1f965d6 100644 --- a/tests/hermes_cli/test_sessions_export_output_dir.py +++ b/tests/hermes_cli/test_sessions_export_output_dir.py @@ -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): diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 08a7f887ea..10531c2004 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -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