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:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user