diff --git a/cli.py b/cli.py index 488d505b70b..704478e5245 100644 --- a/cli.py +++ b/cli.py @@ -5484,6 +5484,88 @@ class HermesCLI: else: print("(^_^)v New session started!") + def _handle_handoff_command(self, cmd_original: str) -> None: + """Handle /handoff — hand off current session to a messaging platform.""" + from hermes_state import format_session_db_unavailable + + parts = cmd_original.split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + _cprint(" Usage: /handoff ") + _cprint(" Supported: telegram, discord, slack, whatsapp, signal, matrix") + _cprint(" The session will become available on that platform's home channel.") + return + + platform = parts[1].strip().lower() + supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"} + if platform not in supported: + _cprint(f" Unknown platform '{platform}'. Supported: {', '.join(sorted(supported))}") + return + + # Ensure session is in the DB + if not self._session_db: + from hermes_state import SessionDB + self._session_db = SessionDB() + + if not self._session_db: + _cprint(f" {format_session_db_unavailable()}") + return + + # Make sure the session has a title + session_title = "" + try: + session_meta = self._session_db.get_session(self.session_id) + if session_meta: + session_title = session_meta.get("title") or "" + except Exception: + pass + + if not session_title: + # Auto-title from conversation if not set + if hasattr(self, "agent") and self.agent and self.conversation_history: + last_user_msgs = [m for m in self.conversation_history[-6:] if m.get("role") == "user"] + if last_user_msgs: + title = last_user_msgs[0].get("content", "")[:60] + title = title.replace("\n", " ").strip() + if title: + session_title = title + self._session_db.set_session_title(self.session_id, title) + + if not session_title: + session_title = "untitled session" + + # Mark session for handoff + ok = self._session_db.set_handoff_pending(self.session_id, platform) + if not ok: + _cprint(f" Session is already pending handoff or not found.") + return + + _cprint(f" Session '{session_title}' queued for handoff to {platform}.") + _cprint(f" The session will resume when the next message arrives on the {platform} home channel.") + + # Also try to send a notification via send_message + try: + summary_lines = ["Handoff from CLI", f"Session: {session_title}"] + if hasattr(self, "agent") and self.agent: + last_msgs = self.conversation_history[-4:] if self.conversation_history else [] + for msg in last_msgs: + role = msg.get("role", "") + content = str(msg.get("content", ""))[:120] + if content.strip(): + summary_lines.append(f"[{role}] {content}") + summary = "\n".join(summary_lines) + + from tools.send_message_tool import send_message_tool + result_json = send_message_tool({"target": platform, "message": summary}) + import json + result = json.loads(result_json) + if result.get("success"): + _cprint(f" Notification sent to {platform} home channel.") + else: + err = result.get("error", "unknown error") + _cprint(f" Could not send notification to {platform}: {err}") + except Exception as e: + _cprint(f" Could not send notification: {e}") + def _handle_resume_command(self, cmd_original: str) -> None: """Handle /resume — switch to a previous session mid-conversation.""" parts = cmd_original.split(None, 1) @@ -6910,6 +6992,8 @@ class HermesCLI: else: from hermes_state import format_session_db_unavailable _cprint(f" {format_session_db_unavailable()}") + elif canonical == "handoff": + self._handle_handoff_command(cmd_original) elif canonical == "new": parts = cmd_original.split(maxsplit=1) title = parts[1].strip() if len(parts) > 1 else None diff --git a/gateway/run.py b/gateway/run.py index c95c09da6a6..a63dcc91400 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6648,6 +6648,46 @@ class GatewayRunner: # Build the context prompt to inject context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii) + + # Check for pending CLI handoff + if _is_new_session and self._session_db: + try: + platform_key = source.platform.value if source.platform else "" + handoff = self._session_db.find_pending_handoff(platform_key) + if handoff: + cli_session_id = handoff["id"] + cli_messages = self._session_db.get_messages(cli_session_id) + if cli_messages: + # Cap to last 200 messages to avoid context blowup + cli_messages = cli_messages[-200:] + transcript = [] + for msg in cli_messages: + role = msg.get("role", "unknown") + content = str(msg.get("content") or "") + if content.strip(): + label = {"user": "User", "assistant": "Assistant", + "system": "System", "tool": "Tool"}.get(role, role.title()) + transcript.append(f"{label}: {content}") + if transcript: + handoff_title = handoff.get("title") or "untitled" + handoff_context = ( + f"[Handoff from CLI session '{handoff_title}'. " + f"Continue the conversation below where it left off.]" + ) + context_prompt = ( + handoff_context + + "\n\n--- Previous conversation ---\n" + + "\n\n".join(transcript) + + "\n--- End of previous conversation ---\n\n" + + context_prompt + ) + self._session_db.clear_handoff_pending(cli_session_id) + logger.info( + "Handoff: CLI session %s handed off to %s chat %s", + cli_session_id, platform_key, source.chat_id, + ) + except Exception: + logger.debug("Handoff check failed", exc_info=True) # If the previous session expired and was auto-reset, prepend a notice # so the agent knows this is a fresh conversation (not an intentional /reset). diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 5889e3d222a..3f543bba647 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -79,6 +79,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("undo", "Remove the last user/assistant exchange", "Session"), CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"), + CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session", + args_hint="", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), CommandDef("compress", "Manually compress conversation context", "Session", diff --git a/hermes_state.py b/hermes_state.py index 913563f69b8..c435ef4cd64 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -215,6 +215,8 @@ CREATE TABLE IF NOT EXISTS sessions ( pricing_version TEXT, title TEXT, api_call_count INTEGER DEFAULT 0, + handoff_pending INTEGER DEFAULT 0, + handoff_platform TEXT, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); @@ -2861,3 +2863,46 @@ class SessionDB: return result + # ── Handoff (cross-platform session transfer) ────────────────────────── + + def set_handoff_pending(self, session_id: str, platform: str) -> bool: + """Mark a session as pending handoff to the given platform. + + Returns True if the session was found and updated. + """ + def _do(conn): + cur = conn.execute( + "UPDATE sessions SET handoff_pending = 1, handoff_platform = ? " + "WHERE id = ? AND handoff_pending = 0", + (platform, session_id), + ) + return cur.rowcount > 0 + return self._execute_write(_do) + + def find_pending_handoff(self, platform: str) -> Optional[Dict[str, Any]]: + """Find the most recent session pending handoff for a platform. + + Returns the session dict or None. + """ + try: + cur = self._conn.execute( + "SELECT * FROM sessions " + "WHERE handoff_pending = 1 AND handoff_platform = ? " + "ORDER BY started_at DESC LIMIT 1", + (platform,), + ) + row = cur.fetchone() + return dict(row) if row else None + except Exception: + return None + + def clear_handoff_pending(self, session_id: str) -> None: + """Clear the handoff_pending flag on a session.""" + def _do(conn): + conn.execute( + "UPDATE sessions SET handoff_pending = 0, handoff_platform = NULL " + "WHERE id = ?", + (session_id,), + ) + self._execute_write(_do) + diff --git a/tests/hermes_cli/test_session_handoff.py b/tests/hermes_cli/test_session_handoff.py new file mode 100644 index 00000000000..6172da1e45a --- /dev/null +++ b/tests/hermes_cli/test_session_handoff.py @@ -0,0 +1,123 @@ +"""Tests for session handoff (CLI to gateway platform).""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + +from hermes_state import SessionDB + + +class TestHandoffDB: + """Test the handoff columns and helper methods on SessionDB.""" + + @pytest.fixture + def db(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + db = SessionDB(db_path=home / "state.db") + yield db + + def _make_session(self, db, session_id, source="cli", title=None): + """Insert a session row directly for testing.""" + def _do(conn): + conn.execute( + "INSERT OR IGNORE INTO sessions (id, source, title, started_at) " + "VALUES (?, ?, ?, ?)", + (session_id, source, title, time.time()), + ) + db._execute_write(_do) + + def test_handoff_columns_exist(self, db): + """Verify handoff columns are in the sessions table after init.""" + db._conn.execute("SELECT handoff_pending, handoff_platform FROM sessions LIMIT 0") + + def test_set_handoff_pending(self, db): + """Mark a session for handoff.""" + session_id = "test-session-001" + self._make_session(db, session_id) + ok = db.set_handoff_pending(session_id, "telegram") + assert ok is True + + session = db.get_session(session_id) + assert session["handoff_pending"] == 1 + assert session["handoff_platform"] == "telegram" + + def test_set_handoff_pending_no_double_mark(self, db): + """Re-marking an already-pending session returns False.""" + session_id = "test-session-002" + self._make_session(db, session_id) + ok1 = db.set_handoff_pending(session_id, "telegram") + assert ok1 is True + ok2 = db.set_handoff_pending(session_id, "discord") + assert ok2 is False # already pending + + def test_find_pending_handoff(self, db): + """Find a session pending handoff for a given platform.""" + sid = "test-session-003" + self._make_session(db, sid) + db.set_handoff_pending(sid, "telegram") + + handoff = db.find_pending_handoff("telegram") + assert handoff is not None + assert handoff["id"] == sid + + # Should not find for other platforms + assert db.find_pending_handoff("discord") is None + + def test_clear_handoff_pending(self, db): + """Clear the handoff flag.""" + sid = "test-session-004" + self._make_session(db, sid) + db.set_handoff_pending(sid, "telegram") + db.clear_handoff_pending(sid) + + session = db.get_session(sid) + assert session["handoff_pending"] == 0 + + def test_full_handoff_flow(self, db): + """End-to-end: mark → find → load messages → clear.""" + sid = "test-session-005" + self._make_session(db, sid, title="my session") + db.append_message(sid, "user", "Hello") + db.append_message(sid, "assistant", "Hi there!") + + # CLI side: mark for handoff + ok = db.set_handoff_pending(sid, "telegram") + assert ok is True + + # Gateway side: find pending handoff + handoff = db.find_pending_handoff("telegram") + assert handoff is not None + assert handoff["id"] == sid + assert handoff["title"] == "my session" + + # Load messages for context + messages = db.get_messages(sid) + assert len(messages) == 2 + assert messages[0]["role"] == "user" + assert messages[1]["role"] == "assistant" + + # Clear after injecting + db.clear_handoff_pending(sid) + assert db.find_pending_handoff("telegram") is None + + +class TestHandoffCommand: + """Test the CLI /handoff command handler.""" + + def test_command_registered(self): + from hermes_cli.commands import resolve_command + cmd = resolve_command("handoff") + assert cmd is not None + assert cmd.name == "handoff" + assert cmd.category == "Session" + + def test_invalid_platform(self): + """Test that unknown platforms are rejected.""" + supported = {"telegram", "discord", "slack", "whatsapp", "signal", "matrix"} + assert "telegram" in supported + assert "foo" not in supported