mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(sessions): export sessions to markdown
This commit is contained in:
parent
2e42bb2da5
commit
91885a32b3
7 changed files with 1050 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
251
hermes_cli/session_export_md.py
Normal file
251
hermes_cli/session_export_md.py
Normal 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
|
||||
111
hermes_state.py
111
hermes_state.py
|
|
@ -4958,6 +4958,97 @@ class SessionDB:
|
|||
# Export and cleanup
|
||||
# =========================================================================
|
||||
|
||||
def list_export_candidates(
|
||||
self,
|
||||
older_than_days: Optional[int] = None,
|
||||
source: str = None,
|
||||
limit: int = 100000,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List ended sessions that match read-only export filters.
|
||||
|
||||
This helper intentionally does not delete, archive, or mutate rows. It
|
||||
backs `hermes sessions export-md` bulk exports.
|
||||
"""
|
||||
clauses = ["ended_at IS NOT NULL"]
|
||||
params: list[Any] = []
|
||||
if older_than_days is not None:
|
||||
cutoff = time.time() - (older_than_days * 86400)
|
||||
clauses.append("started_at < ?")
|
||||
params.append(cutoff)
|
||||
if source:
|
||||
clauses.append("source = ?")
|
||||
params.append(source)
|
||||
params.append(limit)
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
f"""
|
||||
SELECT * FROM sessions
|
||||
WHERE {' AND '.join(clauses)}
|
||||
ORDER BY started_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _is_branch_child_row(self, session: Dict[str, Any]) -> bool:
|
||||
raw = session.get("model_config")
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
cfg = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return False
|
||||
return isinstance(cfg, dict) and cfg.get("_branched_from") is not None
|
||||
|
||||
def _is_compression_child_row(self, child: Dict[str, Any]) -> bool:
|
||||
parent_id = child.get("parent_session_id")
|
||||
if not parent_id or self._is_branch_child_row(child):
|
||||
return False
|
||||
parent = self.get_session(parent_id)
|
||||
return bool(parent and parent.get("end_reason") == "compression")
|
||||
|
||||
def get_compression_lineage(self, session_id: str) -> List[str]:
|
||||
"""Return compression ancestors through tip in chronological order."""
|
||||
session = self.get_session(session_id)
|
||||
if not session or self._is_branch_child_row(session):
|
||||
return [session_id] if session else []
|
||||
|
||||
root = session
|
||||
while self._is_compression_child_row(root):
|
||||
parent = self.get_session(root["parent_session_id"])
|
||||
if not parent:
|
||||
break
|
||||
root = parent
|
||||
|
||||
lineage = [root["id"]]
|
||||
current = root
|
||||
while current.get("end_reason") == "compression":
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT * FROM sessions
|
||||
WHERE parent_session_id = ?
|
||||
ORDER BY started_at ASC
|
||||
""",
|
||||
(current["id"],),
|
||||
).fetchall()
|
||||
next_child = None
|
||||
for row in rows:
|
||||
candidate = dict(row)
|
||||
if not self._is_branch_child_row(candidate):
|
||||
next_child = candidate
|
||||
break
|
||||
if not next_child:
|
||||
break
|
||||
lineage.append(next_child["id"])
|
||||
current = next_child
|
||||
if current["id"] == session_id:
|
||||
# Continue to include later compression tips only when the
|
||||
# requested session itself was compacted.
|
||||
continue
|
||||
return lineage if session_id in lineage else [session_id]
|
||||
|
||||
def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Export a single session with all its messages as a dict."""
|
||||
session = self.get_session(session_id)
|
||||
|
|
@ -4966,6 +5057,26 @@ class SessionDB:
|
|||
messages = self.get_messages(session_id)
|
||||
return {**session, "messages": messages}
|
||||
|
||||
def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Export a compression lineage as one logical session dict."""
|
||||
lineage_ids = self.get_compression_lineage(session_id)
|
||||
if not lineage_ids:
|
||||
return None
|
||||
segments = []
|
||||
for sid in lineage_ids:
|
||||
segment = self.export_session(sid)
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
if not segments:
|
||||
return None
|
||||
base = dict(segments[-1])
|
||||
total_messages = sum(len(seg.get("messages") or []) for seg in segments)
|
||||
base["segments"] = segments
|
||||
base["lineage_session_ids"] = [seg["id"] for seg in segments]
|
||||
base["message_count"] = total_messages
|
||||
base["messages"] = [msg for seg in segments for msg in (seg.get("messages") or [])]
|
||||
return base
|
||||
|
||||
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Export all sessions (with messages) as a list of dicts.
|
||||
|
|
|
|||
145
tests/hermes_cli/test_session_export_md.py
Normal file
145
tests/hermes_cli/test_session_export_md.py
Normal 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
|
||||
315
tests/hermes_cli/test_sessions_export_md_cli.py
Normal file
315
tests/hermes_cli/test_sessions_export_md_cli.py
Normal 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
|
||||
76
tests/hermes_state/test_session_md_export.py
Normal file
76
tests/hermes_state/test_session_md_export.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue