Files
hermes-agent/hermes_cli/session_export.py
kshitijk4poor b564815995 fix(tui_gateway): TUI/Desktop /save json exports the stored session and obeys the export cap
session.save wrote the in-memory history, so turns in-place compaction archived were missing from the
snapshot and sessions.max_export_messages never applied, unlike CLI and messaging /save json since the
#130339 fix. The two existing call sites' cap check + export + undone-row filter move into
hermes_cli.session_export.load_save_snapshot; the CLI, the messaging gateway (one off-loop hop) and the
tui_gateway handler all call it. The TUI handler reads the session's own profile db under that profile's
config, refuses an oversized session with rpc error 4131 (the same message as the CLI), and keeps the
in-memory export as the fallback when no stored row exists. Turn-isolated sessions run the same handler on
the compute host; the host now forwards the handler's error code so the refusal arrives as 4131, not 5011.

Refs #130339
2026-10-03 23:28:02 +05:30

298 lines
12 KiB
Python

"""Shared renderers for session export commands.
CLI, dashboard, and slash-command surfaces all deal with the same session-shaped data (a session
dict with a ``messages`` list); filtering and human-readable rendering live here so each surface
only loads sessions and writes bytes.
"""
from __future__ import annotations
from datetime import datetime, timezone
from html import escape as html_escape
import json
from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Tuple
from hermes_cli.timefmt import coerce_epoch
ExportFormat = Literal["jsonl", "markdown"]
ExportOnly = Literal["user-prompts"]
_EXPORT_FORMATS = {"jsonl": "jsonl", "markdown": "markdown", "md": "markdown"}
_ONLY_ALIASES = {"user", "prompts", "user-prompts", "user_prompts"}
def normalize_export_format(fmt: str) -> ExportFormat:
"""Return the canonical export format name."""
value = _EXPORT_FORMATS.get((fmt or "jsonl").strip().lower())
if value is None:
raise ValueError(f"Unsupported session export format: {fmt}")
return value # type: ignore[return-value]
def normalize_export_only(only: Optional[str]) -> Optional[ExportOnly]:
"""Return the canonical export filter name."""
if only is None:
return None
if only.strip().lower() in _ONLY_ALIASES:
return "user-prompts"
raise ValueError(f"Unsupported session export filter: {only}")
def render_sessions_export(sessions: Iterable[Dict[str, Any]], *, fmt: str = "jsonl", only: Optional[str] = None) -> str:
"""Render exported sessions in a stable, reusable format.
``fmt=jsonl`` with no filter keeps the legacy shape (one full session object per line);
``only=user-prompts`` switches the unit to one prompt record per line for piping into
review / memory-ingestion / prompt-library tooling.
"""
session_list = list(sessions)
export_format = normalize_export_format(fmt)
prompts_only = normalize_export_only(only) == "user-prompts"
if export_format == "jsonl":
rows = iter_user_prompt_records(session_list) if prompts_only else session_list
lines = [json.dumps(row, ensure_ascii=False) for row in rows]
return ("\n".join(lines) + "\n") if lines else ""
# One session → its own H1 with body at H2; several → a shared H1, each session H2/H3.
if prompts_only:
multi_title, append_body = "User prompts export", _append_prompt_records
headings = (lambda s: f"User prompts for session {_heading_text(_session_id(s))}",
lambda s: f"Session {_heading_text(_session_id(s))}")
else:
multi_title, append_body = "Hermes sessions export", _append_session_messages
headings = (lambda s: f"Session: {_heading_text(_session_title_or_id(s))}",) * 2
lines: List[str] = []
single = len(session_list) == 1
if not single:
lines += [f"# {multi_title}", ""]
for session in session_list:
level = 1 if single else 2
lines += [f"{'#' * level} {headings[level - 1](session)}", *_session_metadata_lines(session), ""]
append_body(lines, session, heading_level=level + 1)
if prompts_only and not session_list:
lines += ["_No user prompts found._", ""]
while lines and lines[-1] == "":
lines.pop()
return "\n".join(lines) + "\n"
def export_record_count(sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None) -> Tuple[int, str]:
"""Return ``(count, noun)`` for status messages after an export."""
session_list = list(sessions)
if normalize_export_only(only) == "user-prompts":
return sum(1 for _ in iter_user_prompt_records(session_list)), "prompt"
return len(session_list), "session"
def iter_user_prompt_records(sessions: Iterable[Dict[str, Any]]) -> Iterator[Dict[str, Any]]:
"""Yield one normalized record for each user-authored prompt."""
for session in sessions:
session_id = str(session.get("id") or session.get("session_id") or "")
prompts = [m for m in _messages(session) if m.get("role") == "user"]
for index, message in enumerate(prompts, start=1):
record: Dict[str, Any] = {
"session_id": session_id,
"index": index,
"created_at": _format_timestamp(message.get("timestamp"), session_id),
"role": "user",
"text": _message_text(message.get("content")),
}
if (message_id := message.get("id")) is not None:
record["message_id"] = message_id
if event_id := message.get("platform_message_id") or message.get("event_id"):
record["event_id"] = event_id
yield record
def _append_prompt_records(lines: List[str], session: Dict[str, Any], *, heading_level: int) -> None:
prompts = list(iter_user_prompt_records([session]))
if not prompts:
lines += ["_No user prompts found._", ""]
return
marker = "#" * heading_level
for prompt in prompts:
lines.append(f"{marker} {prompt['index']}. {prompt.get('created_at') or 'timestamp unavailable'}")
if (message_id := prompt.get("message_id")) is not None:
lines += [f"Message ID: `{message_id}`", ""]
lines += [str(prompt.get("text") or ""), ""]
def _append_session_messages(lines: List[str], session: Dict[str, Any], *, heading_level: int) -> None:
marker = "#" * heading_level
visible_messages = [message for message in _messages(session) if message.get("role") != "system"]
if not visible_messages:
lines += ["_No messages found._", ""]
return
for message in visible_messages:
role = str(message.get("role") or "unknown")
timestamp = _format_timestamp(message.get("timestamp"), _session_id(session))
suffix = f" - {timestamp}" if timestamp else ""
text = _message_text(message.get("content"))
if role == "tool":
tool_name = str(message.get("tool_name") or message.get("name") or "tool")
lines += [
f"{marker} Tool: {_heading_text(tool_name)}{suffix}", "",
f"<details><summary>{html_escape(tool_name)}</summary>", "",
_fenced_text(text), "", "</details>", "",
]
else:
label = {"user": "User", "assistant": "Assistant"}.get(role, role.title())
lines += [f"{marker} {label}{suffix}", "", text, ""]
def _messages(session: Dict[str, Any]) -> List[Dict[str, Any]]:
return [message for message in session.get("messages") or [] if isinstance(message, dict)]
def _message_text(content: Any) -> str:
if isinstance(content, list):
return "\n".join(part for part in map(_content_part_text, content) if part)
return "" if content is None else _content_part_text(content)
def _content_part_text(part: Any) -> str:
if not isinstance(part, dict):
return part if isinstance(part, str) else str(part)
for key in ("text", "content"):
if isinstance(value := part.get(key), str):
return value
return json.dumps(part, ensure_ascii=False, sort_keys=True)
def _format_timestamp(value: Any, session_id: Optional[str] = None) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
dt = (value if value.tzinfo else value.replace(tzinfo=timezone.utc)).astimezone(timezone.utc)
elif (ts := coerce_epoch(value, session_id=session_id)) is None:
return str(value) # corrupt cell: odd-looking date, not an aborted export
else:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
return dt.isoformat(timespec="seconds").replace("+00:00", "Z")
def _session_metadata_lines(session: Dict[str, Any]) -> List[str]:
lines: List[str] = [f"- Session ID: `{_session_id(session)}`"]
for key, label in (("source", "Source"), ("model", "Model")):
if session.get(key):
lines.append(f"- {label}: `{session[key]}`")
if title := session.get("title"):
lines.append(f"- Title: {' '.join(str(title).splitlines()).strip()}")
if started := _format_timestamp(session.get("started_at"), _session_id(session)):
lines.append(f"- Started: {started}")
if (message_count := session.get("message_count")) is not None:
lines.append(f"- Messages: {message_count}")
return lines
def _session_id(session: Dict[str, Any]) -> str:
return str(session.get("id") or session.get("session_id") or "unknown")
def _session_title_or_id(session: Dict[str, Any]) -> str:
return str(session.get("title") or "").strip() or _session_id(session)
def _heading_text(value: str) -> str:
return " ".join(str(value).splitlines()).strip() or "unknown"
def _fenced_text(text: str, *, language: str = "text") -> str:
fence = "```"
while fence in text:
fence += "`"
return f"{fence}{language}\n{text}\n{fence}"
# --- Current-session save helper (shared by CLI /save and gateway /save) ---
SAVE_FORMATS = ("json", "md", "html")
SAVE_TRANSCRIPT_FORMATS = frozenset({"md", "html"})
def export_projection(transcript: bool) -> Dict[str, bool]:
"""``export_session`` flags for an export. A transcript shows what the user sees, compaction-archived
turns included. A JSON snapshot is what an import restores, so it carries every stored row with its
``active``/``compacted`` flags: live rows alone would drop every turn in-place compaction archived, and
``import_sessions`` brings the flagged rows back archived, never as live context."""
return {"include_compacted": True} if transcript else {"include_inactive": True}
def drop_undone_rows(export: Dict[str, Any]) -> Dict[str, Any]:
"""Keep live and compaction-archived rows in a ``/save`` JSON snapshot; rows removed by /undo, rewind
or edit stay out. ``timings`` is rebuilt from the kept rows so it names no dropped row."""
from hermes_state_portability import _export_timings
export["messages"] = [m for m in export["messages"] if m["active"] or m["compacted"]]
export["timings"] = _export_timings(export["messages"], export.get("id"))
return export
def load_save_snapshot(db: Any, session_id: str, fmt: str) -> Optional[Dict[str, Any]]:
"""The stored session a ``/save <fmt>`` writes, or None when it has no row. A JSON snapshot loads every
stored row in memory, so it is refused past ``sessions.max_export_messages`` (raises
``SessionExportTooLargeError``), like ``hermes sessions export``."""
transcript = fmt in SAVE_TRANSCRIPT_FORMATS
if not transcript:
db.assert_export_safe(session_id)
export = db.export_session(session_id, **export_projection(transcript))
if export and not transcript:
drop_undone_rows(export)
return export
SAVE_USAGE = """/save — export the current session to a file
Usage: /save <format> [filename] [redact]
Formats:
json full session snapshot (canonical export shape)
md readable Markdown transcript
html standalone single-file HTML page (shareable, no dependencies)
Options:
filename optional output name/path (default: auto-named;
CLI saves under ~/.hermes/sessions/saved/)
redact scrub API keys, tokens, and credentials before writing
Examples:
/save json
/save html
/save md notes.md
/save html session.html redact"""
_SAVE_FORMAT_ALIASES = {"json": "json", "snapshot": "json", "md": "md", "markdown": "md", "html": "html"}
def normalize_save_format(fmt: Optional[str]) -> str:
"""Map a user-typed /save format token to a canonical format."""
token = (fmt or "json").strip().lower()
if token not in _SAVE_FORMAT_ALIASES:
raise ValueError(f"Unknown format {token!r} — expected one of: json, md, html")
return _SAVE_FORMAT_ALIASES[token]
def _render_html_for_save(session: Dict[str, Any]) -> str:
from hermes_cli.session_export_html import generate_html_export
return generate_html_export(session)
_SAVE_RENDERERS = {
"json": lambda session: json.dumps(session, indent=2, ensure_ascii=False, default=str),
"md": lambda session: render_sessions_export([session], fmt="markdown"),
"html": _render_html_for_save,
}
def render_session_for_save(session: Dict[str, Any], fmt: str) -> str:
"""Render one exported session dict for /save."""
renderer = _SAVE_RENDERERS.get(fmt)
if renderer is None:
raise ValueError(f"Unknown save format: {fmt!r}")
return renderer(session)
def default_save_filename(session_id: str, fmt: str) -> str:
"""Default filename for a /save export of the given session."""
safe_id = "".join(ch for ch in str(session_id) if ch.isalnum() or ch in ("-", "_")) or "session"
return f"hermes_session_{safe_id}.{fmt}"