From 91885a32b3f1758a19c4b488e57d86cb42659f55 Mon Sep 17 00:00:00 2001 From: web3blind <264741654+web3blind@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:05:25 +0000 Subject: [PATCH] feat(sessions): export sessions to markdown --- hermes_cli/main.py | 129 +++++++ hermes_cli/session_export_md.py | 251 ++++++++++++++ hermes_state.py | 111 ++++++ tests/hermes_cli/test_session_export_md.py | 145 ++++++++ .../hermes_cli/test_sessions_export_md_cli.py | 315 ++++++++++++++++++ tests/hermes_state/test_session_md_export.py | 76 +++++ website/docs/user-guide/sessions.md | 23 ++ 7 files changed, 1050 insertions(+) create mode 100644 hermes_cli/session_export_md.py create mode 100644 tests/hermes_cli/test_session_export_md.py create mode 100644 tests/hermes_cli/test_sessions_export_md_cli.py create mode 100644 tests/hermes_state/test_session_md_export.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e08246f1682..6b3a0626541 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13442,6 +13442,40 @@ def main(): sessions_export.add_argument("--source", help="Filter by source") sessions_export.add_argument("--session-id", help="Export a specific session") + sessions_export_md = sessions_subparsers.add_parser( + "export-md", + help="Export sessions to Markdown/QMD files", + ) + sessions_export_md.add_argument( + "--session-id", help="Session ID or unique prefix to export" + ) + sessions_export_md.add_argument( + "--older-than", type=int, help="Bulk-export ended sessions older than N days" + ) + sessions_export_md.add_argument("--source", help="Filter bulk export by source") + sessions_export_md.add_argument( + "--dry-run", action="store_true", help="Preview matching sessions without writing files" + ) + sessions_export_md.add_argument( + "--lineage", choices=["single", "logical"], default="single", help="Export one row or compression lineage" + ) + sessions_export_md.add_argument( + "--delete-after-verified", action="store_true", help="After verified single-session export, delete that session" + ) + sessions_export_md.add_argument( + "--yes", "-y", action="store_true", help="Required with --delete-after-verified" + ) + sessions_export_md.add_argument( + "--output", + help="Output directory (default: ~/.hermes/session-exports)", + ) + sessions_export_md.add_argument( + "--format", choices=["md", "qmd"], default="md", help="Export format" + ) + sessions_export_md.add_argument( + "--force", action="store_true", help="Overwrite an existing export file" + ) + sessions_delete = sessions_subparsers.add_parser( "delete", help="Delete a specific session" ) @@ -13746,6 +13780,101 @@ def main(): f.write(_json.dumps(s, ensure_ascii=False) + "\n") print(f"Exported {len(sessions)} sessions to {args.output}") + elif action == "export-md": + from hermes_cli.session_export_md import ( + append_manifest_entry, + verify_export_file, + write_session_markdown, + ) + + output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports" + + def _export_one(session_id: str): + data = ( + db.export_session_lineage(session_id) + if getattr(args, "lineage", "single") == "logical" + else db.export_session(session_id) + ) + if not data: + return None, None + path = write_session_markdown( + data, + output_dir, + fmt=args.format, + force=args.force, + ) + append_manifest_entry(output_dir, data, path, fmt=args.format) + return data, path + + if args.delete_after_verified and not args.yes: + print("--delete-after-verified requires --yes.") + db.close() + return + if args.delete_after_verified and not args.session_id: + print("--delete-after-verified is only supported with --session-id.") + db.close() + return + + if args.session_id: + resolved_session_id = db.resolve_session_id(args.session_id) + if not resolved_session_id: + 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}") + if args.delete_after_verified: + ok, reason = verify_export_file(exported_path, data) + if not ok: + print(f"Export verification failed; not deleting: {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}'.") + else: + print(f"Exported, but session '{resolved_session_id}' was not deleted because it was not found.") + db.close() + return + + if args.older_than is None and not args.source: + print("Refusing bulk export without a filter. Pass --session-id, --older-than, or --source.") + db.close() + return + candidates = db.list_export_candidates( + older_than_days=args.older_than, + source=args.source, + ) + if args.dry_run: + print(f"Would export {len(candidates)} session(s).") + for row in candidates[:100]: + print(f" {row.get('id')} {row.get('source', '')}") + if len(candidates) > 100: + print(f" ... {len(candidates) - 100} more") + db.close() + return + exported = 0 + for row in candidates: + try: + data, exported_path = _export_one(row["id"]) + except FileExistsError as e: + print(f"Skipping existing export: {e}. Pass --force to overwrite.") + continue + if data and exported_path: + exported += 1 + print(f"Exported {exported} session(s) to {output_dir}") + elif action == "delete": resolved_session_id = db.resolve_session_id(args.session_id) if not resolved_session_id: diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py new file mode 100644 index 00000000000..560ad93c650 --- /dev/null +++ b/hermes_cli/session_export_md.py @@ -0,0 +1,251 @@ +"""Markdown/QMD export helpers for Hermes sessions. + +This module is intentionally filesystem-only: it formats already-exported +SessionDB dictionaries and writes them to user-selected export directories. It +must not mutate state.db or call delete/prune/archive APIs. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +EXPORTER_VERSION = "hermes sessions export-md v1" +_SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`") + + +def _iso_timestamp(value: Any) -> str: + if value is None or value == "": + return "" + try: + ts = float(value) + except (TypeError, ValueError): + return str(value) + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def _frontmatter_value(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, list): + return json.dumps(value, ensure_ascii=False) + return json.dumps(str(value), ensure_ascii=False) + + +def _frontmatter_line(key: str, value: Any) -> str: + return f"{key}: {_frontmatter_value(value)}" + + +def _message_heading(message: dict[str, Any]) -> str: + role = str(message.get("role") or "message") + label = role.capitalize() + name = message.get("name") or message.get("tool_name") + if role == "tool" and name: + label = f"Tool — {name}" + timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp")) + return f"### {label}{' — ' + timestamp if timestamp else ''}" + + +def _render_content(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content.rstrip() + return "```json\n" + json.dumps(content, ensure_ascii=False, indent=2) + "\n```" + + +def _render_tool_calls(tool_calls: Any) -> str: + if not tool_calls: + return "" + return "\n\n## Tool calls\n\n```json\n" + json.dumps(tool_calls, ensure_ascii=False, indent=2) + "\n```" + + +def _session_id(session: dict[str, Any]) -> str: + return str(session.get("id") or session.get("session_id") or "unknown-session") + + +def _segments(session: dict[str, Any]) -> list[dict[str, Any]]: + segments = session.get("segments") + if isinstance(segments, list) and segments: + return [s for s in segments if isinstance(s, dict)] + return [session] + + +def _message_count(session: dict[str, Any]) -> int: + return sum(len(seg.get("messages") or []) for seg in _segments(session)) + + +def _render_messages(session: dict[str, Any]) -> str: + parts: list[str] = ["## Messages\n"] + segments = _segments(session) + total_messages = _message_count(session) + if total_messages == 0: + parts.append("_No messages in this session._\n") + return "\n".join(parts).rstrip() + "\n" + + multi_segment = len(segments) > 1 + for segment in segments: + if multi_segment: + parts.append(f"## Compression segment: {_session_id(segment)}\n") + for message in list(segment.get("messages") or []): + parts.append(_message_heading(message) + "\n") + rendered_content = _render_content(message.get("content")) + if rendered_content: + parts.append(rendered_content + "\n") + tool_calls = _render_tool_calls(message.get("tool_calls")) + if tool_calls: + parts.append(tool_calls + "\n") + parts.append("") + return "\n".join(parts).rstrip() + "\n" + + +def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str: + session_id = _session_id(session) + title = session.get("title") or session_id + provider = session.get("billing_provider") or session.get("provider") + started_at = _iso_timestamp(session.get("started_at") or session.get("created_at")) + last_active = _iso_timestamp(session.get("last_active") or session.get("updated_at")) + ended_at = _iso_timestamp(session.get("ended_at")) + exported_iso = _iso_timestamp(exported_at) + message_count = _message_count(session) + + frontmatter = [ + "---", + _frontmatter_line("session_id", session_id), + _frontmatter_line("title", session.get("title")), + _frontmatter_line("source", session.get("source")), + _frontmatter_line("created_at", started_at), + _frontmatter_line("updated_at", last_active), + _frontmatter_line("ended_at", ended_at), + _frontmatter_line("model", session.get("model")), + _frontmatter_line("provider", provider), + _frontmatter_line("cwd", session.get("cwd")), + _frontmatter_line("archived", bool(session.get("archived"))), + _frontmatter_line("message_count", message_count), + _frontmatter_line("tool_call_count", session.get("tool_call_count") or 0), + ] + if session.get("lineage_session_ids"): + frontmatter.append(_frontmatter_line("lineage_session_ids", session.get("lineage_session_ids"))) + frontmatter.extend([ + _frontmatter_line("format", fmt), + _frontmatter_line("exported_at", exported_iso), + _frontmatter_line("exporter", EXPORTER_VERSION), + "---", + "", + ]) + + parts = ["\n".join(frontmatter), f"# {title}\n"] + parts.append(f"Session ID: `{session_id}`\n") + if session.get("source"): + parts.append(f"Source: `{session.get('source')}`\n") + if session.get("cwd"): + parts.append(f"Working directory: `{session.get('cwd')}`\n") + + parts.append(_render_messages(session)) + parts.append("## Export verification\n") + parts.append(f"- Session id: `{session_id}`") + parts.append(f"- Exported messages: `{message_count}`") + parts.append(f"- Source DB message count at export: `{session.get('message_count', message_count)}`") + parts.append(f"- Exported at: `{exported_iso}`") + parts.append("- SHA256 of exported body: `__SHA256_PLACEHOLDER__`") + return "\n".join(parts).rstrip() + "\n" + + +def _body_for_digest(text: str) -> str: + return _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text) + + +def render_session_markdown( + session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True +) -> str: + """Render a SessionDB export dictionary as Markdown/QMD text.""" + if fmt not in {"md", "qmd"}: + raise ValueError("fmt must be 'md' or 'qmd'") + exported_at = time.time() + body = _export_body_without_hash(session, fmt=fmt, exported_at=exported_at) + digest_body = body.replace("`__SHA256_PLACEHOLDER__`", "`pending`") + digest = hashlib.sha256(digest_body.encode("utf-8")).hexdigest() + if include_verification: + return body.replace("__SHA256_PLACEHOLDER__", digest) + before_verification = body.split("\n## Export verification\n", 1)[0].rstrip() + "\n" + return before_verification + + +def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str: + """Return a deterministic, path-safe filename for a session export.""" + if fmt not in {"md", "qmd"}: + raise ValueError("fmt must be 'md' or 'qmd'") + session_id = _session_id(session) + title = str(session.get("title") or "session") + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower() + if not slug: + slug = "session" + slug = slug[:60] + return f"{session_id}-{slug}.{fmt}" + + +def file_sha256(path: Path | str) -> str: + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]: + p = Path(path) + if not p.exists(): + return False, "file missing" + text = p.read_text(encoding="utf-8") + match = _SHA_LINE_RE.search(text) + if not match: + return False, "sha256 marker missing" + actual = hashlib.sha256(_body_for_digest(text).encode("utf-8")).hexdigest() + if actual != match.group(1): + return False, "sha256 mismatch" + expected_count = _message_count(session) + if f"- Exported messages: `{expected_count}`" not in text: + return False, "message count mismatch" + if f"- Session id: `{_session_id(session)}`" not in text: + return False, "session id mismatch" + return True, "ok" + + +def write_session_markdown( + session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False +) -> Path: + """Write a Markdown/QMD export file and return its path. + + Raises FileExistsError when the destination exists and force=False. + """ + out_dir = Path(output_dir).expanduser() + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / safe_session_filename(session, fmt=fmt) + if path.exists() and not force: + raise FileExistsError(str(path)) + path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8") + return path + + +def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path: + out_dir = Path(output_dir).expanduser() + out_dir.mkdir(parents=True, exist_ok=True) + export_path = Path(path) + entry = { + "session_id": _session_id(session), + "lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)], + "path": str(export_path), + "format": fmt, + "message_count": _message_count(session), + "sha256": file_sha256(export_path), + "exported_at": time.time(), + } + manifest = out_dir / "manifest.jsonl" + with manifest.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n") + return manifest diff --git a/hermes_state.py b/hermes_state.py index 3702623aac4..c58464c6059 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4958,6 +4958,97 @@ class SessionDB: # Export and cleanup # ========================================================================= + def list_export_candidates( + self, + older_than_days: Optional[int] = None, + source: str = None, + limit: int = 100000, + ) -> List[Dict[str, Any]]: + """List ended sessions that match read-only export filters. + + This helper intentionally does not delete, archive, or mutate rows. It + backs `hermes sessions export-md` bulk exports. + """ + clauses = ["ended_at IS NOT NULL"] + params: list[Any] = [] + if older_than_days is not None: + cutoff = time.time() - (older_than_days * 86400) + clauses.append("started_at < ?") + params.append(cutoff) + if source: + clauses.append("source = ?") + params.append(source) + params.append(limit) + with self._lock: + rows = self._conn.execute( + f""" + SELECT * FROM sessions + WHERE {' AND '.join(clauses)} + ORDER BY started_at ASC + LIMIT ? + """, + params, + ).fetchall() + return [dict(row) for row in rows] + + def _is_branch_child_row(self, session: Dict[str, Any]) -> bool: + raw = session.get("model_config") + if not raw: + return False + try: + cfg = json.loads(raw) if isinstance(raw, str) else raw + except (TypeError, json.JSONDecodeError): + return False + return isinstance(cfg, dict) and cfg.get("_branched_from") is not None + + def _is_compression_child_row(self, child: Dict[str, Any]) -> bool: + parent_id = child.get("parent_session_id") + if not parent_id or self._is_branch_child_row(child): + return False + parent = self.get_session(parent_id) + return bool(parent and parent.get("end_reason") == "compression") + + def get_compression_lineage(self, session_id: str) -> List[str]: + """Return compression ancestors through tip in chronological order.""" + session = self.get_session(session_id) + if not session or self._is_branch_child_row(session): + return [session_id] if session else [] + + root = session + while self._is_compression_child_row(root): + parent = self.get_session(root["parent_session_id"]) + if not parent: + break + root = parent + + lineage = [root["id"]] + current = root + while current.get("end_reason") == "compression": + with self._lock: + rows = self._conn.execute( + """ + SELECT * FROM sessions + WHERE parent_session_id = ? + ORDER BY started_at ASC + """, + (current["id"],), + ).fetchall() + next_child = None + for row in rows: + candidate = dict(row) + if not self._is_branch_child_row(candidate): + next_child = candidate + break + if not next_child: + break + lineage.append(next_child["id"]) + current = next_child + if current["id"] == session_id: + # Continue to include later compression tips only when the + # requested session itself was compacted. + continue + return lineage if session_id in lineage else [session_id] + def export_session(self, session_id: str) -> Optional[Dict[str, Any]]: """Export a single session with all its messages as a dict.""" session = self.get_session(session_id) @@ -4966,6 +5057,26 @@ class SessionDB: messages = self.get_messages(session_id) return {**session, "messages": messages} + def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]: + """Export a compression lineage as one logical session dict.""" + lineage_ids = self.get_compression_lineage(session_id) + if not lineage_ids: + return None + segments = [] + for sid in lineage_ids: + segment = self.export_session(sid) + if segment: + segments.append(segment) + if not segments: + return None + base = dict(segments[-1]) + total_messages = sum(len(seg.get("messages") or []) for seg in segments) + base["segments"] = segments + base["lineage_session_ids"] = [seg["id"] for seg in segments] + base["message_count"] = total_messages + base["messages"] = [msg for seg in segments for msg in (seg.get("messages") or [])] + return base + def export_all(self, source: str = None) -> List[Dict[str, Any]]: """ Export all sessions (with messages) as a list of dicts. diff --git a/tests/hermes_cli/test_session_export_md.py b/tests/hermes_cli/test_session_export_md.py new file mode 100644 index 00000000000..221fc304d3f --- /dev/null +++ b/tests/hermes_cli/test_session_export_md.py @@ -0,0 +1,145 @@ +from pathlib import Path + +import pytest + +from hermes_cli.session_export_md import ( + append_manifest_entry, + render_session_markdown, + safe_session_filename, + verify_export_file, + write_session_markdown, +) + + +def _session(**overrides): + data = { + "id": "20260706_123456_abcd1234", + "title": "Export Test", + "source": "telegram", + "model": "gpt-5.5", + "billing_provider": "openai-codex", + "cwd": "/tmp/project", + "started_at": 1783331696.0, + "last_active": 1783331705.0, + "ended_at": 1783331710.0, + "message_count": 3, + "tool_call_count": 1, + "archived": 0, + "messages": [ + {"role": "user", "content": "Hello", "created_at": 1783331697.0}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"function": {"name": "terminal", "arguments": "{\"command\": \"pwd\"}"}} + ], + "created_at": 1783331698.0, + }, + {"role": "tool", "name": "terminal", "content": "output", "created_at": 1783331699.0}, + ], + } + data.update(overrides) + return data + + +def test_render_session_markdown_includes_frontmatter_messages_and_verification(): + rendered = render_session_markdown(_session()) + + assert rendered.startswith("---\n") + assert 'session_id: "20260706_123456_abcd1234"' in rendered + assert 'title: "Export Test"' in rendered + assert 'source: "telegram"' in rendered + assert 'model: "gpt-5.5"' in rendered + assert 'provider: "openai-codex"' in rendered + assert "# Export Test" in rendered + assert "## Messages" in rendered + assert "### User" in rendered + assert "Hello" in rendered + assert "### Assistant" in rendered + assert "## Tool calls" in rendered + assert '"name": "terminal"' in rendered + assert "### Tool — terminal" in rendered + assert "output" in rendered + assert "## Export verification" in rendered + assert "Exported messages: `3`" in rendered + assert "SHA256 of exported body:" in rendered + + +def test_render_session_markdown_renders_structured_content_as_json_fence(): + rendered = render_session_markdown( + _session(messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}]) + ) + + assert "```json" in rendered + assert '"type": "text"' in rendered + + +def test_safe_session_filename_is_deterministic_and_path_safe(): + filename = safe_session_filename( + _session(id="20260706_123456_abcd1234", title="Bad / title: * ?"), fmt="qmd" + ) + + assert filename.startswith("20260706_123456_abcd1234-") + assert filename.endswith(".qmd") + assert "/" not in filename + assert ":" not in filename + assert "*" not in filename + assert "?" not in filename + + +def test_render_session_markdown_includes_logical_lineage_segments(): + rendered = render_session_markdown( + _session( + id="tip", + title="Logical", + lineage_session_ids=["root", "tip"], + segments=[ + _session(id="root", messages=[{"role": "user", "content": "root text"}]), + _session(id="tip", messages=[{"role": "assistant", "content": "tip text"}]), + ], + ) + ) + + assert 'lineage_session_ids: ["root", "tip"]' in rendered + assert "## Compression segment: root" in rendered + assert "root text" in rendered + assert "## Compression segment: tip" in rendered + assert "tip text" in rendered + assert "Exported messages: `2`" in rendered + + +def test_write_session_markdown_refuses_to_overwrite_without_force(tmp_path): + session = _session() + first = write_session_markdown(session, tmp_path) + + assert first.exists() + with pytest.raises(FileExistsError): + write_session_markdown(session, tmp_path) + + second = write_session_markdown(session, tmp_path, force=True) + assert second == first + + +def test_verify_export_file_checks_count_and_sha(tmp_path): + session = _session() + path = write_session_markdown(session, tmp_path) + + ok, reason = verify_export_file(path, session) + assert ok is True + assert reason == "ok" + + path.write_text(path.read_text(encoding="utf-8").replace("Hello", "Tampered"), encoding="utf-8") + ok, reason = verify_export_file(path, session) + assert ok is False + assert "sha256" in reason + + +def test_append_manifest_entry_writes_jsonl_with_sha(tmp_path): + session = _session() + path = write_session_markdown(session, tmp_path) + manifest = append_manifest_entry(tmp_path, session, path, fmt="md") + + text = manifest.read_text(encoding="utf-8") + assert '"session_id": "20260706_123456_abcd1234"' in text + assert '"path":' in text + assert '"sha256":' in text diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py new file mode 100644 index 00000000000..70076daeb85 --- /dev/null +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -0,0 +1,315 @@ +import sys + + +def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + + class FakeDB: + def resolve_session_id(self, session_id): + captured["resolved_from"] = session_id + return "20260706_123456_abcd1234" + + def export_session(self, session_id): + captured["exported"] = session_id + return { + "id": session_id, + "title": "Export CLI Test", + "source": "cli", + "message_count": 1, + "messages": [{"role": "user", "content": "hello"}], + } + + def delete_session(self, *args, **kwargs): + raise AssertionError("export-md must not delete sessions") + + def prune_sessions(self, *args, **kwargs): + raise AssertionError("export-md must not prune sessions") + + def close(self): + captured["closed"] = True + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--session-id", + "20260706_123456", + "--output", + str(tmp_path), + ], + ) + + main_mod.main() + + output = capsys.readouterr().out + files = list(tmp_path.glob("*.md")) + assert len(files) == 1 + text = files[0].read_text(encoding="utf-8") + assert "# Export CLI Test" in text + assert "hello" in text + assert captured == { + "resolved_from": "20260706_123456", + "exported": "20260706_123456_abcd1234", + "closed": True, + } + assert "Exported 1 session" in output + assert "1 message" in output + assert str(files[0]) in output + + +def test_sessions_export_md_reports_unknown_session(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + output_dir = tmp_path / "exports" + + class FakeDB: + def resolve_session_id(self, session_id): + return None + + def export_session(self, session_id): + raise AssertionError("export_session should not be called") + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--session-id", + "missing", + "--output", + str(output_dir), + ], + ) + + main_mod.main() + + output = capsys.readouterr().out + assert "Session 'missing' not found." in output + assert not output_dir.exists() + + +def test_sessions_export_md_supports_qmd_format(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def resolve_session_id(self, session_id): + return "s1" + + def export_session(self, session_id): + return {"id": "s1", "title": "QMD", "messages": []} + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--session-id", + "s1", + "--output", + str(tmp_path), + "--format", + "qmd", + ], + ) + + main_mod.main() + + assert len(list(tmp_path.glob("*.qmd"))) == 1 + assert "Exported 1 session" in capsys.readouterr().out + + +def test_sessions_export_md_bulk_dry_run_lists_candidates(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_export_candidates(self, **kwargs): + assert kwargs == {"older_than_days": 30, "source": "cron"} + return [{"id": "s1", "source": "cron"}, {"id": "s2", "source": "cron"}] + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--older-than", + "30", + "--source", + "cron", + "--output", + str(tmp_path), + "--dry-run", + ], + ) + + main_mod.main() + + output = capsys.readouterr().out + assert "Would export 2 session(s)" in output + assert "s1" in output + assert "s2" in output + assert not list(tmp_path.glob("*.md")) + + +def test_sessions_export_md_bulk_requires_filter(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_export_candidates(self, **kwargs): + raise AssertionError("bulk export without filters should refuse") + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "sessions", "export-md", "--output", str(tmp_path)], + ) + + main_mod.main() + + assert "Refusing bulk export without a filter" in capsys.readouterr().out + + +def test_sessions_export_md_bulk_writes_manifest(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_export_candidates(self, **kwargs): + return [{"id": "s1"}, {"id": "s2"}] + + def export_session_lineage(self, session_id): + return {"id": session_id, "title": session_id, "messages": [{"role": "user", "content": session_id}]} + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--older-than", + "90", + "--output", + str(tmp_path), + "--lineage", + "logical", + ], + ) + + main_mod.main() + + assert len(list(tmp_path.glob("*.md"))) == 2 + manifest = tmp_path / "manifest.jsonl" + assert manifest.exists() + lines = manifest.read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + assert "Exported 2 session(s)" in capsys.readouterr().out + + +def test_sessions_export_md_delete_after_verified_requires_yes(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--session-id", + "s1", + "--output", + str(tmp_path), + "--delete-after-verified", + ], + ) + + main_mod.main() + + assert "requires --yes" in capsys.readouterr().out + + +def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + + class FakeDB: + def resolve_session_id(self, session_id): + return "s1" + + def export_session(self, session_id): + return {"id": "s1", "title": "Delete", "message_count": 1, "messages": [{"role": "user", "content": "safe"}]} + + def delete_session(self, session_id, **kwargs): + captured["deleted"] = session_id + return True + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export-md", + "--session-id", + "s1", + "--output", + str(tmp_path), + "--delete-after-verified", + "--yes", + ], + ) + + main_mod.main() + + assert captured == {"deleted": "s1"} + assert len(list(tmp_path.glob("*.md"))) == 1 + assert "Deleted exported session 's1'" in capsys.readouterr().out diff --git a/tests/hermes_state/test_session_md_export.py b/tests/hermes_state/test_session_md_export.py new file mode 100644 index 00000000000..50d6cf322bb --- /dev/null +++ b/tests/hermes_state/test_session_md_export.py @@ -0,0 +1,76 @@ +import time + +import hermes_state +from hermes_state import SessionDB + + +def test_list_export_candidates_filters_ended_old_sessions(tmp_path, monkeypatch): + db = SessionDB(db_path=tmp_path / "state.db") + monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0) + try: + db.create_session("old_cli", source="cli") + db.end_session("old_cli", "done") + db._conn.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_000_000.0, 1_000_010.0, "old_cli")) + + db.create_session("new_cli", source="cli") + db.end_session("new_cli", "done") + db._conn.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_990_000.0, 1_990_010.0, "new_cli")) + + db.create_session("old_active", source="cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (1_000_000.0, "old_active")) + db._conn.commit() + + candidates = db.list_export_candidates(older_than_days=5) + assert [c["id"] for c in candidates] == ["old_cli"] + finally: + db.close() + + +def test_list_export_candidates_ands_source_filter(tmp_path, monkeypatch): + db = SessionDB(db_path=tmp_path / "state.db") + monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0) + try: + for sid, source in [("old_cli", "cli"), ("old_telegram", "telegram")]: + db.create_session(sid, source=source) + db.end_session(sid, "done") + db._conn.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_000_000.0, 1_000_010.0, sid)) + db._conn.commit() + + candidates = db.list_export_candidates(older_than_days=5, source="telegram") + assert [c["id"] for c in candidates] == ["old_telegram"] + finally: + db.close() + + +def test_get_compression_lineage_returns_only_compression_chain(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + try: + db.create_session("root", source="cli") + db.end_session("root", "compression") + db.create_session("child", source="cli", parent_session_id="root") + db.end_session("child", "compression") + db.create_session("tip", source="cli", parent_session_id="child") + db.create_session("branch", source="cli", parent_session_id="root", model_config={"_branched_from": "root"}) + + assert db.get_compression_lineage("tip") == ["root", "child", "tip"] + assert db.get_compression_lineage("branch") == ["branch"] + finally: + db.close() + + +def test_export_session_lineage_combines_segments(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + try: + db.create_session("root", source="cli", model="m1") + db.append_message("root", "user", "before compression") + db.end_session("root", "compression") + db.create_session("tip", source="cli", parent_session_id="root", model="m1") + db.append_message("tip", "assistant", "after compression") + + exported = db.export_session_lineage("tip") + assert exported["id"] == "tip" + assert exported["lineage_session_ids"] == ["root", "tip"] + assert [s["id"] for s in exported["segments"]] == ["root", "tip"] + assert exported["message_count"] == 2 + finally: + db.close() diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index e4626357be4..a79009a68ca 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -307,6 +307,29 @@ hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4 Exported files contain one JSON object per line with full session metadata and all messages. +### Export Sessions to Markdown/QMD + +Use Markdown/QMD export when you want a readable, file-based archive before hiding or deleting old sessions. + +```bash +# Export one session to Markdown +hermes sessions export-md --session-id 20250305_091523_a1b2c3d4 + +# Export a compression lineage as one logical document +hermes sessions export-md --session-id 20250305_091523_a1b2c3d4 --lineage logical + +# Preview ended sessions older than 90 days without writing files +hermes sessions export-md --older-than 90 --dry-run + +# Export ended Telegram sessions older than 30 days to QMD files +hermes sessions export-md --older-than 30 --source telegram --format qmd + +# Only after verification, export and delete one explicitly named session +hermes sessions export-md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes +``` + +`export-md` 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 a filter such as `--older-than` or `--source`; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. + ### Delete a Session ```bash