From f3c27e30ebbd555e6c256f346492ab5faf0558e6 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:26:07 -0700 Subject: [PATCH] refactor(sessions): fold Markdown/QMD export into 'sessions export --format' Replaces the parallel export-md subcommand from the salvaged commit with a --format jsonl|md|qmd flag on the existing export subcommand, so all session export formats share one surface. Adds AUTHOR_MAP entry. Salvage follow-up for PR #59542 by @web3blind. --- hermes_cli/main.py | 121 ++++++++++-------- hermes_cli/session_export_md.py | 2 +- hermes_state.py | 2 +- scripts/release.py | 1 + .../hermes_cli/test_sessions_export_md_cli.py | 91 +++++++++---- website/docs/user-guide/sessions.md | 14 +- 6 files changed, 148 insertions(+), 83 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6b3a0626541..49558253993 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13434,46 +13434,54 @@ def main(): ) sessions_export = sessions_subparsers.add_parser( - "export", help="Export sessions to a JSONL file" + "export", help="Export sessions to JSONL, Markdown, or QMD" ) sessions_export.add_argument( - "output", help="Output JSONL file path (use - for stdout)" + "output", + nargs="?", + help=( + "Output path. JSONL: file path (use - for stdout, required). " + "md/qmd: output directory (default: /session-exports)" + ), + ) + sessions_export.add_argument( + "--format", + choices=["jsonl", "md", "qmd"], + default="jsonl", + help="Export format (default: jsonl)", ) 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( + sessions_export.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.add_argument( + "--older-than", + type=int, + help="md/qmd only: 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.add_argument( + "--dry-run", + action="store_true", + help="md/qmd only: 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.add_argument( + "--lineage", + choices=["single", "logical"], + default="single", + help="md/qmd only: export one row or its compression lineage", ) - sessions_export_md.add_argument( - "--delete-after-verified", action="store_true", help="After verified single-session export, delete that session" + sessions_export.add_argument( + "--delete-after-verified", + action="store_true", + help="md/qmd only: after verified single-session export, delete that session", ) - sessions_export_md.add_argument( + sessions_export.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_export.add_argument( + "--force", + action="store_true", + help="md/qmd only: overwrite an existing export file", ) sessions_delete = sessions_subparsers.add_parser( @@ -13751,42 +13759,51 @@ def main(): print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}") elif action == "export": - 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.") + if args.format == "jsonl": + if not args.output: + print("JSONL export requires an output path (use - for stdout).") return - data = db.export_session(resolved_session_id) - if not data: - print(f"Session '{args.session_id}' not found.") - return - line = _json.dumps(data, ensure_ascii=False) + "\n" - if args.output == "-": + 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.") + return + data = db.export_session(resolved_session_id) + if not data: + print(f"Session '{args.session_id}' not found.") + return + line = _json.dumps(data, ensure_ascii=False) + "\n" + if args.output == "-": - sys.stdout.write(line) + sys.stdout.write(line) + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(line) + print(f"Exported 1 session to {args.output}") else: - with open(args.output, "w", encoding="utf-8") as f: - f.write(line) - print(f"Exported 1 session to {args.output}") - else: - sessions = db.export_all(source=args.source) - if args.output == "-": + sessions = db.export_all(source=args.source) + if args.output == "-": - for s in sessions: - sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n") - else: - with open(args.output, "w", encoding="utf-8") as f: for s in sessions: - f.write(_json.dumps(s, ensure_ascii=False) + "\n") - print(f"Exported {len(sessions)} sessions to {args.output}") + sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n") + else: + with open(args.output, "w", encoding="utf-8") as f: + for s in sessions: + f.write(_json.dumps(s, ensure_ascii=False) + "\n") + print(f"Exported {len(sessions)} sessions to {args.output}") + return - elif action == "export-md": + # Markdown / QMD export from hermes_cli.session_export_md import ( append_manifest_entry, verify_export_file, write_session_markdown, ) + if args.output == "-": + print("Markdown/QMD export writes files; stdout (-) is only supported with --format jsonl.") + db.close() + return output_dir = Path(args.output).expanduser() if args.output else get_hermes_home() / "session-exports" def _export_one(session_id: str): diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py index 560ad93c650..80619ed094b 100644 --- a/hermes_cli/session_export_md.py +++ b/hermes_cli/session_export_md.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any -EXPORTER_VERSION = "hermes sessions export-md v1" +EXPORTER_VERSION = "hermes sessions export (md/qmd) v1" _SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`") diff --git a/hermes_state.py b/hermes_state.py index c58464c6059..fe8bc5949fd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4967,7 +4967,7 @@ class SessionDB: """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. + backs `hermes sessions export --format md/qmd` bulk exports. """ clauses = ["ended_at IS NOT NULL"] params: list[Any] = [] diff --git a/scripts/release.py b/scripts/release.py index 51bc07a8a89..65001e564fb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -838,6 +838,7 @@ AUTHOR_MAP = { "maks.mir@yahoo.com": "say8hi", "27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky", "web3blind@users.noreply.github.com": "web3blind", + "264741654+web3blind@users.noreply.github.com": "web3blind", "julia@alexland.us": "alexg0bot", "christian@scheid.tech": "scheidti", # Moonshot schema anyOf+enum salvage (May 2026) diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py index 70076daeb85..4c4fdc0194e 100644 --- a/tests/hermes_cli/test_sessions_export_md_cli.py +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -23,10 +23,10 @@ def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys) } def delete_session(self, *args, **kwargs): - raise AssertionError("export-md must not delete sessions") + raise AssertionError("markdown export must not delete sessions") def prune_sessions(self, *args, **kwargs): - raise AssertionError("export-md must not prune sessions") + raise AssertionError("markdown export must not prune sessions") def close(self): captured["closed"] = True @@ -38,10 +38,11 @@ def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys) [ "hermes", "sessions", - "export-md", + "export", + "--format", + "md", "--session-id", "20260706_123456", - "--output", str(tmp_path), ], ) @@ -87,10 +88,11 @@ def test_sessions_export_md_reports_unknown_session(monkeypatch, tmp_path, capsy [ "hermes", "sessions", - "export-md", + "export", + "--format", + "md", "--session-id", "missing", - "--output", str(output_dir), ], ) @@ -123,13 +125,12 @@ def test_sessions_export_md_supports_qmd_format(monkeypatch, tmp_path, capsys): [ "hermes", "sessions", - "export-md", + "export", "--session-id", "s1", - "--output", - str(tmp_path), "--format", "qmd", + str(tmp_path), ], ) @@ -139,6 +140,48 @@ def test_sessions_export_md_supports_qmd_format(monkeypatch, tmp_path, capsys): assert "Exported 1 session" in capsys.readouterr().out +def test_sessions_export_md_rejects_stdout_target(monkeypatch, tmp_path, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def resolve_session_id(self, session_id): + raise AssertionError("md export to stdout must be refused before DB access") + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "sessions", "export", "--format", "md", "--session-id", "s1", "-"], + ) + + main_mod.main() + + assert "only supported with --format jsonl" in capsys.readouterr().out + + +def test_sessions_export_jsonl_requires_output_path(monkeypatch, capsys): + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def export_all(self, **kwargs): + raise AssertionError("jsonl export without an output path must be refused") + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr(sys, "argv", ["hermes", "sessions", "export"]) + + main_mod.main() + + assert "requires an output path" 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 @@ -158,14 +201,15 @@ def test_sessions_export_md_bulk_dry_run_lists_candidates(monkeypatch, tmp_path, [ "hermes", "sessions", - "export-md", + "export", + "--format", + "md", "--older-than", "30", "--source", "cron", - "--output", - str(tmp_path), "--dry-run", + str(tmp_path), ], ) @@ -193,7 +237,7 @@ def test_sessions_export_md_bulk_requires_filter(monkeypatch, tmp_path, capsys): monkeypatch.setattr( sys, "argv", - ["hermes", "sessions", "export-md", "--output", str(tmp_path)], + ["hermes", "sessions", "export", "--format", "md", str(tmp_path)], ) main_mod.main() @@ -222,13 +266,14 @@ def test_sessions_export_md_bulk_writes_manifest(monkeypatch, tmp_path, capsys): [ "hermes", "sessions", - "export-md", + "export", + "--format", + "md", "--older-than", "90", - "--output", - str(tmp_path), "--lineage", "logical", + str(tmp_path), ], ) @@ -257,12 +302,13 @@ def test_sessions_export_md_delete_after_verified_requires_yes(monkeypatch, tmp_ [ "hermes", "sessions", - "export-md", + "export", + "--format", + "md", "--session-id", "s1", - "--output", - str(tmp_path), "--delete-after-verified", + str(tmp_path), ], ) @@ -298,13 +344,14 @@ def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monke [ "hermes", "sessions", - "export-md", + "export", + "--format", + "md", "--session-id", "s1", - "--output", - str(tmp_path), "--delete-after-verified", "--yes", + str(tmp_path), ], ) diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index a79009a68ca..2c6b91a77a3 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -309,26 +309,26 @@ Exported files contain one JSON object per line with full session metadata and a ### Export Sessions to Markdown/QMD -Use Markdown/QMD export when you want a readable, file-based archive before hiding or deleting old sessions. +Pass `--format md` or `--format qmd` when you want a readable, file-based archive before hiding or deleting old sessions. Markdown/QMD exports write one file per session into a directory (default: `~/.hermes/session-exports`). ```bash # Export one session to Markdown -hermes sessions export-md --session-id 20250305_091523_a1b2c3d4 +hermes sessions export --format 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 +hermes sessions export --format 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 +hermes sessions export --format 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 +hermes sessions export --format qmd --older-than 30 --source telegram # Only after verification, export and delete one explicitly named session -hermes sessions export-md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes +hermes sessions export --format 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`. +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 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