feat: /save exports the current session as json, md, or html on all platforms

Rework of salvaged PR #6372 (@ag9920) onto current main:

- /save promoted from CLI-only JSON snapshot to a cross-platform session
  export: `/save [json|md|html] [filename] [redact]` on CLI and every
  gateway platform (sent as a document via adapter.send_document).
- Rendering routes through the existing shared renderers
  (hermes_cli/session_export.py + session_export_html.py) instead of the
  PR's new hermes_state formatter — new helpers normalize_save_format /
  render_session_for_save / default_save_filename are shared by both
  surfaces.
- `redact` arg runs the export through the force-mode secret redaction
  pass (session_export_md.redact_session_data) before writing.
- Gateway handler awaits AsyncSessionDB correctly, sanitizes user-supplied
  filenames with basename, and lands in gateway/slash_commands.py (the
  handlers moved out of gateway/run.py since the PR was authored).
- /export stays profile export (name collision resolved: session export
  lives on /save).
- Slack 50-slash cap curation: /platform moves to the /hermes-only set to
  free a native slot for /save (parity test updated rationale comment).
- Folds in PR #62268 (@briandevans): None title/model coalescing in the
  single-session HTML export.

Closes #4249. Closes #51200.
This commit is contained in:
Teknium
2026-08-15 00:49:51 -07:00
parent edf6d2a074
commit 26aa12337a
10 changed files with 255 additions and 159 deletions
-36
View File
@@ -291,42 +291,6 @@ class SessionPortabilityMixin:
base["messages"] = [msg for seg in segments for msg in (seg.get("messages") or [])]
return base
@staticmethod
def format_session_as_markdown(export_data: Dict[str, Any]) -> str:
"""Format exported session data as a readable Markdown document."""
lines = []
session_id = export_data.get("id", "Unknown")
lines.append(f"# Session Export: {session_id}\n")
title = export_data.get("title")
if title:
lines.append(f"**Title**: {title}")
created_at = export_data.get("created_at")
if created_at:
lines.append(f"**Started**: {created_at}")
if title or created_at:
lines.append("\n---\n")
for msg in export_data.get("messages", []):
role = msg.get("role", "unknown").capitalize()
content = msg.get("content", "")
if role == "User":
lines.append(f"## {role}\n\n{content}\n")
elif role == "Assistant":
reasoning = msg.get("reasoning", "")
if reasoning:
lines.append(f"## {role} (Reasoning)\n\n{reasoning}\n")
lines.append(f"## {role}\n\n{content}\n")
elif role == "Tool":
lines.append(f"### Tool Result\n\n```\n{content}\n```\n")
else:
lines.append(f"## {role}\n\n{content}\n")
return "\n".join(lines)
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
"""
Export all sessions (with messages) as a list of dicts.