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
+58 -55
View File
@@ -9342,56 +9342,59 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return True
def _handle_export_command(self, cmd: str):
"""Handle /export command to export session history to file."""
if not self._session_db:
_cprint(" Session database not available.")
return
def save_conversation(self, cmd: str = "/save"):
"""Handle /save — export the current session to json, md, or html.
parts = cmd.split()
format_type = "markdown"
filename = f"session_{self.session_id}.md"
Usage: ``/save [json|md|html] [filename] [redact]``
if len(parts) > 1:
arg = parts[1].lower()
if arg in ["json", "md", "markdown"]:
format_type = "json" if arg == "json" else "markdown"
filename = f"session_{self.session_id}.{'json' if format_type == 'json' else 'md'}"
else:
filename = arg
The snapshot is a convenience export for sharing or off-line
inspection; every message is already persisted incrementally to the
SQLite session DB, so the live session remains resumable via
``hermes --resume <id>`` regardless of whether the user ever runs
``/save``. ``redact`` runs the export through the force-mode secret
redaction pass before writing.
"""
from hermes_cli.session_export import (
normalize_save_format,
render_session_for_save,
)
if len(parts) > 2:
filename = parts[2]
export_data = self._session_db.export_session(self.session_id)
if not export_data:
_cprint(f" Failed to export session {self.session_id}.")
return
parts = cmd.split()[1:]
redact = False
if parts and parts[-1].lower() in ("redact", "--redact"):
redact = True
parts = parts[:-1]
try:
with open(filename, "w", encoding="utf-8") as f:
if format_type == "json":
import json
json.dump(export_data, f, indent=2, ensure_ascii=False)
else:
from hermes_state import SessionDB
f.write(SessionDB.format_session_as_markdown(export_data))
_cprint(f" Session exported successfully to {filename}")
except Exception as e:
_cprint(f" Error writing export file: {e}")
def save_conversation(self):
"""Save the current conversation to a JSON snapshot under ~/.hermes/sessions/saved/.
The snapshot is a convenience export for sharing or off-line inspection;
every message is already persisted incrementally to the SQLite session
DB, so the live session remains resumable via ``hermes --resume <id>``
regardless of whether the user ever runs ``/save``.
"""
if not self.conversation_history:
print("(;_;) No conversation to save.")
fmt = normalize_save_format(parts[0] if parts else None)
except ValueError as e:
print(f"(._.) {e}")
return
filename = parts[1] if len(parts) > 1 else None
# Prefer the durable DB row (has metadata + tool calls); fall back to
# the in-memory history for sessions that never touched the DB.
session_data = None
if self._session_db and self.session_id:
try:
session_data = self._session_db.export_session(self.session_id)
except Exception:
session_data = None
if not session_data:
if not self.conversation_history:
print("(;_;) No conversation to save.")
return
session_data = {
"id": self.session_id,
"model": self.model,
"started_at": self.session_start.timestamp(),
"messages": self.conversation_history,
}
if redact:
from hermes_cli.session_export_md import redact_session_data
session_data = redact_session_data(session_data)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
saved_dir = get_hermes_home() / "sessions" / "saved"
@@ -9400,17 +9403,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception as e:
print(f"(x_x) Failed to create save directory {saved_dir}: {e}")
return
path = saved_dir / f"hermes_conversation_{timestamp}.json"
if filename:
path = Path(filename).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
else:
path = saved_dir / f"hermes_conversation_{timestamp}.{fmt}"
try:
content = render_session_for_save(session_data, fmt)
with open(path, "w", encoding="utf-8") as f:
json.dump({
"model": self.model,
"session_id": self.session_id,
"session_start": self.session_start.isoformat(),
"messages": self.conversation_history,
}, f, indent=2, ensure_ascii=False)
print(f"(^_^)v Conversation snapshot saved to: {path}")
f.write(content)
label = {"json": "JSON", "md": "Markdown", "html": "HTML"}[fmt]
print(f"(^_^)v Conversation saved to: {path} ({label})")
if self.session_id:
print(f" Resume the live session with: hermes --resume {self.session_id}")
except Exception as e:
@@ -11167,9 +11172,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
elif canonical == "branch":
self._handle_branch_command(cmd_original)
elif canonical == "save":
self.save_conversation()
elif canonical == "export":
self._handle_export_command(cmd_original)
self.save_conversation(cmd_original)
elif canonical == "cron":
self._handle_cron_command(cmd_original)
elif canonical == "suggestions":
@@ -0,0 +1 @@
ag9920
+2 -2
View File
@@ -16912,8 +16912,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
else:
return getattr(_blueprint_result, "text", "") or None
if canonical == "export":
return await self._handle_export_command(event)
if canonical == "save":
return await self._handle_save_command(event)
if canonical == "retry":
return await self._handle_retry_command(event)
+46 -38
View File
@@ -4531,8 +4531,17 @@ class GatewaySlashCommandsMixin:
return await self._telegram_topic_root_status_message(source)
async def _handle_export_command(self, event: MessageEvent) -> str:
"""Handle /export command — export session history and send it as a document."""
async def _handle_save_command(self, event: MessageEvent) -> str:
"""Handle /save — export the current session and send it as a document.
Usage: ``/save [json|md|html] [filename] [redact]``
"""
from hermes_cli.session_export import (
default_save_filename,
normalize_save_format,
render_session_for_save,
)
source = event.source
session_entry = self.session_store.get_or_create_session(source)
session_id = session_entry.session_id
@@ -4540,53 +4549,52 @@ class GatewaySlashCommandsMixin:
if not self._session_db:
return "Session database not available."
args = event.get_command_args().split()
format_type = "markdown"
filename = f"session_{session_id}.md"
if len(args) > 0:
arg = args[0].lower()
if arg in ["json", "md", "markdown"]:
format_type = "json" if arg == "json" else "markdown"
filename = f"session_{session_id}.{'json' if format_type == 'json' else 'md'}"
else:
filename = arg
if len(args) > 1:
filename = args[1]
export_data = self._session_db.export_session(session_id)
if not export_data:
return f"Failed to export session {session_id}."
import json
import tempfile
import os
# Write to a temp file, then send it via adapter
temp_dir = tempfile.mkdtemp(prefix="hermes_export_")
temp_path = os.path.join(temp_dir, filename)
parts = event.get_command_args().split()
redact = False
if parts and parts[-1].lower() in ("redact", "--redact"):
redact = True
parts = parts[:-1]
try:
fmt = normalize_save_format(parts[0] if parts else None)
except ValueError as e:
return str(e)
filename = parts[1] if len(parts) > 1 else default_save_filename(session_id, fmt)
# The filename is echoed to the platform only — never trust path
# separators from chat input.
filename = os.path.basename(filename) or default_save_filename(session_id, fmt)
# self._session_db is an AsyncSessionDB — every forwarded call is
# offloaded to a thread and must be awaited.
export_data = await self._session_db.export_session(session_id)
if not export_data:
return f"No stored messages found for this session ({session_id})."
if redact:
from hermes_cli.session_export_md import redact_session_data
export_data = redact_session_data(export_data)
import tempfile
temp_dir = tempfile.mkdtemp(prefix="hermes_save_")
temp_path = os.path.join(temp_dir, filename)
try:
content = render_session_for_save(export_data, fmt)
with open(temp_path, "w", encoding="utf-8") as f:
if format_type == "json":
json.dump(export_data, f, indent=2, ensure_ascii=False)
else:
from hermes_state import SessionDB
f.write(SessionDB.format_session_as_markdown(export_data))
f.write(content)
adapter = self.get_adapter(source.platform)
if adapter:
await adapter.send_document(
chat_id=source.chat_id,
file_path=temp_path,
caption=f"Here is your exported session: {filename}",
file_name=filename
caption=f"Session export: {filename}",
file_name=filename,
)
return "Export complete."
else:
return "Platform adapter not found to send the document."
return "Platform adapter not found to send the document."
except Exception as e:
logger.warning("Session /save failed: %s", e)
return f"Error exporting session: {e}"
finally:
try:
+8 -5
View File
@@ -154,10 +154,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("history", "Show conversation history", "Session",
cli_only=True),
CommandDef("save", "Save the current conversation", "Session",
cli_only=True),
CommandDef("export", "Export the current session history to a file", "Session",
args_hint="[format] [filename]"),
CommandDef("save", "Save/export the current conversation (json, md, or html)", "Session",
args_hint="[json|md|html] [filename] [redact]"),
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
CommandDef("prompt", "Compose your next prompt in $EDITOR (markdown), then send it", "Session",
cli_only=True, args_hint="[initial text]", aliases=("compose",)),
@@ -1328,7 +1326,12 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# interactive surface; whoami is a rare debug lookup) — without this
# entry /loop tips the registry past the 50-cap and silently clamps
# /platform, breaking Telegram parity.
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "pause", "whoami"})
# - platform: informational platform/environment lookup; reached via
# /hermes platform on Slack. Demoted when /save became gateway-available
# (session export is an interactive surface; platform is a rare
# informational lookup) — without this entry /save tips the registry
# past the 50-cap and silently clamps /platform, breaking parity.
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "pause", "whoami", "platform"})
def _sanitize_slack_name(raw: str) -> str:
+46
View File
@@ -315,3 +315,49 @@ def _finish_markdown(lines: List[str]) -> str:
while lines and lines[-1] == "":
lines.pop()
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Current-session save helper (shared by CLI /save and gateway /save)
# ---------------------------------------------------------------------------
SAVE_FORMATS = ("json", "md", "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 in ("json", "snapshot"):
return "json"
if token in ("md", "markdown"):
return "md"
if token == "html":
return "html"
raise ValueError(
f"Unknown format {token!r} — expected one of: json, md, html"
)
def render_session_for_save(session: Dict[str, Any], fmt: str) -> str:
"""Render one exported session dict for /save.
``json`` -> pretty-printed JSON; ``md`` -> the shared full-markdown
renderer; ``html`` -> the standalone single-file HTML export.
"""
if fmt == "json":
return json.dumps(session, indent=2, ensure_ascii=False, default=str)
if fmt == "md":
return render_sessions_export([session], fmt="markdown")
if fmt == "html":
from hermes_cli.session_export_html import generate_html_export
return generate_html_export(session)
raise ValueError(f"Unknown save format: {fmt!r}")
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}"
-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.
+7 -4
View File
@@ -47,11 +47,14 @@ def _completions(completer: SlashCommandCompleter, text: str):
class TestCommandRegistry:
def test_export_command_registered(self):
cmd = resolve_command("export")
def test_save_command_supports_formats(self):
cmd = resolve_command("save")
assert cmd is not None
assert cmd.name == "export"
assert cmd.args_hint == "[format] [filename]"
assert cmd.name == "save"
# /save is a cross-platform session export: json (default), md, html
assert not cmd.cli_only
for token in ("json", "md", "html"):
assert token in (cmd.args_hint or "")
def test_no_duplicate_canonical_names(self):
names = [cmd.name for cmd in COMMAND_REGISTRY]
+87
View File
@@ -0,0 +1,87 @@
"""Tests for the /save current-session export helpers."""
import json
import pytest
from hermes_cli.session_export import (
SAVE_FORMATS,
default_save_filename,
normalize_save_format,
render_session_for_save,
)
SESSION = {
"id": "20260814_abc123",
"title": "Test Session",
"model": "test-model",
"started_at": 1755100000,
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there"},
{"role": "tool", "content": "tool output"},
],
}
class TestNormalizeSaveFormat:
def test_default_is_json(self):
assert normalize_save_format(None) == "json"
assert normalize_save_format("") == "json"
def test_aliases(self):
assert normalize_save_format("markdown") == "md"
assert normalize_save_format("MD") == "md"
assert normalize_save_format("HTML") == "html"
assert normalize_save_format("snapshot") == "json"
def test_unknown_raises(self):
with pytest.raises(ValueError):
normalize_save_format("pdf")
def test_all_declared_formats_normalize_to_themselves(self):
for fmt in SAVE_FORMATS:
assert normalize_save_format(fmt) == fmt
class TestRenderSessionForSave:
def test_json_round_trips(self):
out = render_session_for_save(SESSION, "json")
data = json.loads(out)
assert data["id"] == SESSION["id"]
assert len(data["messages"]) == 3
def test_markdown_contains_messages(self):
out = render_session_for_save(SESSION, "md")
assert "Hello" in out
assert "Hi there" in out
def test_html_is_standalone_document(self):
out = render_session_for_save(SESSION, "html")
assert out.lstrip().lower().startswith("<!doctype html")
assert "Hello" in out
def test_html_survives_none_title_and_model(self):
# Async title generation may not have run yet — None title/model is
# the default state for a fresh session (PR #62268 regression).
session = {**SESSION, "title": None, "model": None}
out = render_session_for_save(session, "html")
assert "<title>None</title>" not in out
def test_unknown_format_raises(self):
with pytest.raises(ValueError):
render_session_for_save(SESSION, "pdf")
class TestDefaultSaveFilename:
def test_basic(self):
assert default_save_filename("abc-123", "md") == "hermes_session_abc-123.md"
def test_hostile_session_id_sanitized(self):
name = default_save_filename("../../etc/passwd", "json")
assert "/" not in name
assert ".." not in name.replace("etcpasswd", "")
def test_empty_session_id(self):
assert default_save_filename("", "html") == "hermes_session_session.html"
-19
View File
@@ -1056,25 +1056,6 @@ class TestDeleteAndExport:
def test_format_session_as_markdown(self, db):
db.create_session(session_id="s1", source="cli", model="test")
db.append_message("s1", role="user", content="Hello")
db.append_message("s1", role="assistant", content="Hi")
db.append_message("s1", role="assistant", content="Wait", reasoning="Thinking...")
db.append_message("s1", role="tool", content="Tool Result")
db.set_session_title("s1", "Test Title")
export = db.export_session("s1")
md_output = db.format_session_as_markdown(export)
assert "# Session Export: s1" in md_output
assert "**Title**: Test Title" in md_output
assert "## User\n\nHello" in md_output
assert "## Assistant\n\nHi" in md_output
assert "## Assistant (Reasoning)\n\nThinking..." in md_output
assert "## Assistant\n\nWait" in md_output
assert "### Tool Result\n\n```\nTool Result\n```" in md_output
def test_export_nonexistent(self, db):
assert db.export_session("nope") is None