diff --git a/cli.py b/cli.py index 9b572fbea0..4dd7864808 100644 --- a/cli.py +++ b/cli.py @@ -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 `` 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 `` - 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": diff --git a/contributors/emails/alexgong7@outlook.com b/contributors/emails/alexgong7@outlook.com new file mode 100644 index 0000000000..1178e54df0 --- /dev/null +++ b/contributors/emails/alexgong7@outlook.com @@ -0,0 +1 @@ +ag9920 diff --git a/gateway/run.py b/gateway/run.py index ec5ff549dc..855f287667 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index ea8a6a7aa0..338e7f1a2f 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -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: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 773bac24d8..8b246f6248 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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: diff --git a/hermes_cli/session_export.py b/hermes_cli/session_export.py index 031c21034e..59c52cf44d 100644 --- a/hermes_cli/session_export.py +++ b/hermes_cli/session_export.py @@ -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}" diff --git a/hermes_state_portability.py b/hermes_state_portability.py index 17ea9ab507..decf8d3d8a 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -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. diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index eda0316483..dc0b6e9b59 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -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] diff --git a/tests/hermes_cli/test_session_save.py b/tests/hermes_cli/test_session_save.py new file mode 100644 index 0000000000..e0c0e65b54 --- /dev/null +++ b/tests/hermes_cli/test_session_save.py @@ -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("None" 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" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index e6d9dbeda1..f7aca6465d 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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