diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e08246f168284..c7120483a4b13 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13433,23 +13433,6 @@ def cmd_computer_use(args): "--limit", type=int, default=20, help="Max sessions to show" ) - sessions_export = sessions_subparsers.add_parser( - "export", help="Export sessions to a JSONL file" - ) - sessions_export.add_argument( - "output", help="Output JSONL file path (use - for stdout)" - ) - sessions_export.add_argument("--source", help="Filter by source") - sessions_export.add_argument("--session-id", help="Export a specific session") - - sessions_delete = sessions_subparsers.add_parser( - "delete", help="Delete a specific session" - ) - sessions_delete.add_argument("session_id", help="Session ID to delete") - sessions_delete.add_argument( - "--yes", "-y", action="store_true", help="Skip confirmation" - ) - def _add_session_filter_args(p, default_older_help): p.add_argument( "--older-than", @@ -13547,6 +13530,61 @@ def _add_session_filter_args(p, default_older_help): "--yes", "-y", action="store_true", help="Skip confirmation" ) + sessions_export = sessions_subparsers.add_parser( + "export", help="Export sessions to JSONL, Markdown, or QMD" + ) + sessions_export.add_argument( + "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( + "--session-id", help="Session ID or unique prefix to export" + ) + _add_session_filter_args( + sessions_export, + "Only export sessions older than AGE (duration like '5h'/'2d', " + "bare number of days, or an ISO timestamp)", + ) + sessions_export.add_argument( + "--redact", + action="store_true", + help="Redact secrets (API keys, tokens, credentials) from exported content", + ) + sessions_export.add_argument( + "--lineage", + choices=["single", "logical"], + default="single", + help="md/qmd only: export one row or its compression lineage", + ) + sessions_export.add_argument( + "--delete-after-verified", + action="store_true", + help="md/qmd only: after verified single-session export, delete that session (needs --yes)", + ) + sessions_export.add_argument( + "--force", + action="store_true", + help="md/qmd only: overwrite an existing export file", + ) + + sessions_delete = sessions_subparsers.add_parser( + "delete", help="Delete a specific session" + ) + sessions_delete.add_argument("session_id", help="Session ID to delete") + sessions_delete.add_argument( + "--yes", "-y", action="store_true", help="Skip confirmation" + ) + sessions_prune = sessions_subparsers.add_parser( "prune", help="Delete old sessions (filterable by time window, source, title, ...)", @@ -13717,34 +13755,202 @@ def cmd_sessions(args): print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}") elif action == "export": + from hermes_cli.session_filters import ( + build_prune_filters, + describe_filters, + ) + + _filter_arg_names = ( + "older_than", "newer_than", "before", "after", + "source", "title", "end_reason", "cwd", + "min_messages", "max_messages", "model", "provider", + "user", "chat_id", "chat_type", "branch", + "min_tokens", "max_tokens", "min_cost", "max_cost", + "min_tool_calls", "max_tool_calls", + ) + _any_filters = any( + getattr(args, a, None) is not None for a in _filter_arg_names + ) + filters = None + if _any_filters: + try: + filters = build_prune_filters(args) + except ValueError as e: + print(f"Error: {e}") + return + # Unlike prune/archive, export includes archived sessions. + filters["archived"] = None + + def _redact(data): + if not args.redact or data is None: + return data + from hermes_cli.session_export_md import redact_session_data + + return redact_session_data(data) + + if args.format == "jsonl": + if not args.output: + print("JSONL export requires an output path (use - for stdout).") + 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.") + return + data = _redact(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) + else: + with open(args.output, "w", encoding="utf-8") as f: + f.write(line) + print(f"Exported 1 session to {args.output}") + else: + if filters: + candidates = db.list_prune_candidates(**filters) + if args.dry_run: + print( + f"Would export {len(candidates)} session(s) " + f"({describe_filters(filters)})." + ) + for row in candidates[:100]: + print(f" {row.get('id')} {row.get('source', '')}") + if len(candidates) > 100: + print(f" ... {len(candidates) - 100} more") + return + sessions = [ + s + for s in ( + db.export_session(row["id"]) for row in candidates + ) + if s + ] + else: + if args.dry_run: + print("--dry-run requires at least one filter.") + return + sessions = db.export_all(source=None) + if args.output == "-": + + for s in sessions: + sys.stdout.write( + _json.dumps(_redact(s), ensure_ascii=False) + "\n" + ) + else: + with open(args.output, "w", encoding="utf-8") as f: + for s in sessions: + f.write( + _json.dumps(_redact(s), ensure_ascii=False) + "\n" + ) + print(f"Exported {len(sessions)} sessions to {args.output}") + return + + # 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): + 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 + data = _redact(data) + 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 - data = db.export_session(resolved_session_id) - if not data: + 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 - line = _json.dumps(data, ensure_ascii=False) + "\n" - if args.output == "-": - - 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: - sessions = db.export_all(source=args.source) - if args.output == "-": + 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 - 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}") + if not filters: + print( + "Refusing bulk export without a filter. Pass --session-id or " + "at least one filter (e.g. --older-than 90, --source telegram)." + ) + db.close() + return + candidates = db.list_prune_candidates(**filters) + if args.dry_run: + print( + f"Would export {len(candidates)} session(s) " + f"({describe_filters(filters)})." + ) + 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) diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py new file mode 100644 index 0000000000000..e2ab0c8a6d3bb --- /dev/null +++ b/hermes_cli/session_export_md.py @@ -0,0 +1,279 @@ +"""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/qmd) 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 redact_session_data(session: dict[str, Any]) -> dict[str, Any]: + """Return a deep copy of a session export dict with secrets redacted. + + Runs every message's content and tool-call arguments through the + force-mode redaction pass (``agent.redact.redact_sensitive_text``), so + API keys, tokens, and credentials that appeared in tool output never + land in plaintext export files. Force mode ignores the user's global + ``security.redact_secrets`` preference — an explicit ``--redact`` export + must never emit raw secrets. + """ + from agent.redact import redact_sensitive_text + + def _clean(value: Any) -> Any: + if isinstance(value, str): + return redact_sensitive_text(value, force=True) + if isinstance(value, list): + return [_clean(v) for v in value] + if isinstance(value, dict): + return {k: _clean(v) for k, v in value.items()} + return value + + redacted = dict(session) + for key in ("messages", "segments"): + if key in redacted and redacted[key] is not None: + redacted[key] = _clean(redacted[key]) + return redacted + + +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 3702623aac46b..9071f4b34b05d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4958,6 +4958,64 @@ def has_platform_message_id( # Export and cleanup # ========================================================================= + 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 +5024,26 @@ def export_session(self, session_id: str) -> Optional[Dict[str, Any]]: 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/scripts/release.py b/scripts/release.py index 51bc07a8a8909..65001e564fb41 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -838,6 +838,7 @@ "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_session_export_md.py b/tests/hermes_cli/test_session_export_md.py new file mode 100644 index 0000000000000..221fc304d3fcd --- /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 0000000000000..eed3cb2443328 --- /dev/null +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -0,0 +1,506 @@ +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("markdown export must not delete sessions") + + def prune_sessions(self, *args, **kwargs): + raise AssertionError("markdown export must not prune sessions") + + def close(self): + captured["closed"] = True + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", + "sessions", + "export", + "--format", + "md", + "--session-id", + "20260706_123456", + 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", + "--format", + "md", + "--session-id", + "missing", + 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", + "--session-id", + "s1", + "--format", + "qmd", + str(tmp_path), + ], + ) + + main_mod.main() + + assert len(list(tmp_path.glob("*.qmd"))) == 1 + 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 + + class FakeDB: + def list_prune_candidates(self, **kwargs): + # Export flows through the shared prune-filter machinery: + # --older-than 30 becomes a started_before epoch bound, source + # passes through, and archived is tri-state None (export includes + # archived sessions). + assert kwargs.get("source") == "cron" + assert kwargs.get("started_before") is not None + assert kwargs.get("archived") is None + 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", + "--format", + "md", + "--older-than", + "30", + "--source", + "cron", + "--dry-run", + str(tmp_path), + ], + ) + + 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_prune_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", "--format", "md", 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_prune_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", + "--format", + "md", + "--older-than", + "90", + "--lineage", + "logical", + str(tmp_path), + ], + ) + + 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", + "--format", + "md", + "--session-id", + "s1", + "--delete-after-verified", + str(tmp_path), + ], + ) + + 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", + "--format", + "md", + "--session-id", + "s1", + "--delete-after-verified", + "--yes", + str(tmp_path), + ], + ) + + main_mod.main() + + assert captured == {"deleted": "s1"} + assert len(list(tmp_path.glob("*.md"))) == 1 + assert "Deleted exported session 's1'" in capsys.readouterr().out + + +def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path, capsys): + """--older-than accepts the same AGE grammar as prune ('2w', '5h', ISO).""" + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_prune_candidates(self, **kwargs): + assert kwargs.get("started_before") is not None + return [{"id": "s1", "source": "cli"}] + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "md", + "--older-than", "2w", "--dry-run", str(tmp_path), + ], + ) + + main_mod.main() + + assert "Would export 1 session(s)" in capsys.readouterr().out + + +def test_sessions_export_md_supports_extended_prune_filters(monkeypatch, tmp_path, capsys): + """Filters like --model/--min-messages pass through the shared machinery.""" + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + + class FakeDB: + def list_prune_candidates(self, **kwargs): + captured.update(kwargs) + return [] + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "md", + "--model", "sonnet", "--min-messages", "5", "--dry-run", + str(tmp_path), + ], + ) + + main_mod.main() + + assert captured.get("model_like") == "sonnet" + assert captured.get("min_messages") == 5 + assert "Would export 0 session(s)" in capsys.readouterr().out + + +def test_sessions_export_jsonl_honors_filters(monkeypatch, tmp_path, capsys): + """JSONL bulk export uses the same filter machinery as md/qmd.""" + import json + + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_prune_candidates(self, **kwargs): + assert kwargs.get("source") == "telegram" + return [{"id": "s1", "source": "telegram"}] + + def export_session(self, session_id): + return {"id": session_id, "messages": [{"role": "user", "content": "hi"}]} + + def export_all(self, **kwargs): + raise AssertionError("filtered jsonl export must not fall back to export_all") + + def close(self): + pass + + out = tmp_path / "out.jsonl" + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "sessions", "export", "--source", "telegram", str(out)], + ) + + main_mod.main() + + lines = out.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + assert json.loads(lines[0])["id"] == "s1" + assert "Exported 1 sessions" in capsys.readouterr().out + + +def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path): + """--redact runs exported content through force-mode secret redaction.""" + import hermes_cli.main as main_mod + import hermes_state + + secret = "sk-proj-Zz12345678901234567890123456789012345678" + + class FakeDB: + def resolve_session_id(self, session_id): + return "s1" + + def export_session(self, session_id): + return { + "id": "s1", + "title": "Redact", + "messages": [ + {"role": "tool", "name": "terminal", "content": f"api key: {secret}"} + ], + } + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "md", + "--session-id", "s1", "--redact", str(tmp_path), + ], + ) + + main_mod.main() + + text = next(tmp_path.glob("*.md")).read_text(encoding="utf-8") + assert secret not in text + assert "api key:" in text 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 0000000000000..8efbd2f50c769 --- /dev/null +++ b/tests/hermes_state/test_session_md_export.py @@ -0,0 +1,83 @@ +import time + +import hermes_state +from hermes_state import SessionDB + + +def test_export_candidates_via_prune_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() + + # Export uses the shared prune/archive candidate selection. + candidates = db.list_prune_candidates( + started_before=2_000_000.0 - 5 * 86400, archived=None + ) + assert [c["id"] for c in candidates] == ["old_cli"] + finally: + db.close() + + +def test_export_candidates_via_prune_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_prune_candidates( + started_before=2_000_000.0 - 5 * 86400, + source="telegram", + archived=None, + ) + 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 e4626357be42a..fdbea52cbed0f 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -303,10 +303,41 @@ hermes sessions export telegram-history.jsonl --source telegram # Export a single session hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4 + +# Redact API keys/tokens/credentials from the exported content +hermes sessions export backup.jsonl --redact ``` Exported files contain one JSON object per line with full session metadata and all messages. +`export` accepts the same filters as `prune` / `archive` — `--older-than` / `--newer-than` / `--before` / `--after` (durations like `5h`/`2d`/`1w`, bare days, or ISO timestamps), `--source`, `--title`, `--model`, `--provider`, `--cwd`, `--min-messages` / `--max-messages`, `--min-tokens` / `--max-tokens`, `--min-cost` / `--max-cost`, `--min-tool-calls` / `--max-tool-calls`, `--user`, `--chat-id`, `--chat-type`, `--branch`, and `--end-reason`. Add `--dry-run` to preview which sessions match without writing anything. Note: bulk filters match *ended* sessions; unfiltered `export` dumps everything, including active ones. + +### Export Sessions to Markdown/QMD + +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 --format md --session-id 20250305_091523_a1b2c3d4 + +# Export a compression lineage as one logical document +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 --format md --older-than 90 --dry-run + +# Export ended Telegram sessions older than 2 weeks to QMD files +hermes sessions export --format qmd --older-than 2w --source telegram + +# Export long Claude sessions, secrets redacted +hermes sessions export --format md --model sonnet --min-messages 50 --redact + +# Only after verification, export and delete one explicitly named session +hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes +``` + +Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share. + ### Delete a Session ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index 60a6ab78f69f6..3b0da17196e0f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -280,10 +280,41 @@ hermes sessions export telegram-history.jsonl --source telegram # 导出单个 session hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4 + +# 从导出内容中脱敏 API key/token/凭据 +hermes sessions export backup.jsonl --redact ``` 导出文件每行包含一个 JSON 对象,包含完整的 session 元数据和所有消息。 +`export` 接受与 `prune` / `archive` 相同的过滤器 — `--older-than` / `--newer-than` / `--before` / `--after`(时长如 `5h`/`2d`/`1w`、纯数字天数或 ISO 时间戳)、`--source`、`--title`、`--model`、`--provider`、`--cwd`、`--min-messages` / `--max-messages`、`--min-tokens` / `--max-tokens`、`--min-cost` / `--max-cost`、`--min-tool-calls` / `--max-tool-calls`、`--user`、`--chat-id`、`--chat-type`、`--branch` 和 `--end-reason`。加 `--dry-run` 可预览匹配的 session 而不写入任何内容。注意:带过滤器的批量导出只匹配*已结束*的 session;不带过滤器的 `export` 会导出所有 session(包括活跃的)。 + +### 导出 Session 为 Markdown/QMD + +当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。 + +```bash +# 将单个 session 导出为 Markdown +hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 + +# 将压缩链(compression lineage)导出为一个逻辑文档 +hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --lineage logical + +# 预览 90 天前已结束的 session,不写入文件 +hermes sessions export --format md --older-than 90 --dry-run + +# 将 2 周前已结束的 Telegram session 导出为 QMD 文件 +hermes sessions export --format qmd --older-than 2w --source telegram + +# 导出长的 Claude session,并脱敏 +hermes sessions export --format md --model sonnet --min-messages 50 --redact + +# 导出并在校验通过后删除一个明确指定的 session +hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes +``` + +Markdown/QMD 导出为每个 session 写入一个 `.md` 或 `.qmd` 文件,并附带一个 `manifest.jsonl`,记录文件路径、消息数量、lineage id 和 SHA-256。批量导出必须带至少一个过滤条件,不带过滤条件的批量导出会被拒绝。`--delete-after-verified` 仅限与 `--session-id` 搭配使用,且必须加 `--yes`。`--redact` 会在写入前从消息内容和工具输出中清除密钥(API key、token、凭据)— 任何打算分享的导出都建议加上。 + ### 删除 Session ```bash