From 23ad35b7fa6b9f06c924a4baa0727f7c8fa98000 Mon Sep 17 00:00:00 2001 From: ag9920 Date: Thu, 9 Apr 2026 10:11:15 +0800 Subject: [PATCH] feat: Add /export command for session export (Markdown/JSON) to CLI and Gateway --- cli.py | 41 ++++++++++++++++++++ gateway/run.py | 3 ++ gateway/slash_commands.py | 64 +++++++++++++++++++++++++++++++ hermes_cli/commands.py | 2 + hermes_state_portability.py | 36 +++++++++++++++++ tests/hermes_cli/test_commands.py | 6 +++ tests/test_hermes_state.py | 21 ++++++++++ 7 files changed, 173 insertions(+) diff --git a/cli.py b/cli.py index 38fa410060..9b572fbea0 100644 --- a/cli.py +++ b/cli.py @@ -9342,6 +9342,45 @@ 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 + + parts = cmd.split() + format_type = "markdown" + filename = f"session_{self.session_id}.md" + + 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 + + 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 + + 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/. @@ -11129,6 +11168,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._handle_branch_command(cmd_original) elif canonical == "save": self.save_conversation() + elif canonical == "export": + self._handle_export_command(cmd_original) elif canonical == "cron": self._handle_cron_command(cmd_original) elif canonical == "suggestions": diff --git a/gateway/run.py b/gateway/run.py index f28a40eb61..ec5ff549dc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16912,6 +16912,9 @@ 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 == "retry": return await self._handle_retry_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 97e65bb231..ea8a6a7aa0 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -4531,6 +4531,70 @@ 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.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + session_id = session_entry.session_id + + 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) + + try: + 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)) + + 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 + ) + return "Export complete." + else: + return "Platform adapter not found to send the document." + except Exception as e: + return f"Error exporting session: {e}" + finally: + try: + os.remove(temp_path) + os.rmdir(temp_dir) + except Exception: + pass + async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 21c2ec9a9f..773bac24d8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -156,6 +156,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ 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("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",)), diff --git a/hermes_state_portability.py b/hermes_state_portability.py index decf8d3d8a..17ea9ab507 100644 --- a/hermes_state_portability.py +++ b/hermes_state_portability.py @@ -291,6 +291,42 @@ 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 580de148bc..eda0316483 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -47,6 +47,12 @@ def _completions(completer: SlashCommandCompleter, text: str): class TestCommandRegistry: + def test_export_command_registered(self): + cmd = resolve_command("export") + assert cmd is not None + assert cmd.name == "export" + assert cmd.args_hint == "[format] [filename]" + def test_no_duplicate_canonical_names(self): names = [cmd.name for cmd in COMMAND_REGISTRY] assert len(names) == len(set(names)), f"Duplicate names: {[n for n in names if names.count(n) > 1]}" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 933e395ece..e6d9dbeda1 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1056,6 +1056,27 @@ 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