feat(sessions): export sessions to markdown

This commit is contained in:
web3blind 2026-07-06 10:05:25 +00:00 committed by Teknium
parent 2e42bb2da5
commit 91885a32b3
7 changed files with 1050 additions and 0 deletions

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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