diff --git a/cli.py b/cli.py index 8a1d7caa8..bb5c94db6 100644 --- a/cli.py +++ b/cli.py @@ -2949,6 +2949,82 @@ class HermesCLI: if not silent: print("(^_^)v New session started!") + def _handle_resume_command(self, cmd_original: str) -> None: + """Handle /resume — switch to a previous session mid-conversation.""" + parts = cmd_original.split(None, 1) + target = parts[1].strip() if len(parts) > 1 else "" + + if not target: + _cprint(" Usage: /resume ") + _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.") + return + + if not self._session_db: + _cprint(" Session database not available.") + return + + # Resolve title or ID + from hermes_cli.main import _resolve_session_by_name_or_id + resolved = _resolve_session_by_name_or_id(target) + target_id = resolved or target + + session_meta = self._session_db.get_session(target_id) + if not session_meta: + _cprint(f" Session not found: {target}") + _cprint(" Use /history or `hermes sessions list` to see available sessions.") + return + + if target_id == self.session_id: + _cprint(" Already on that session.") + return + + # End current session + try: + self._session_db.end_session(self.session_id, "resumed_other") + except Exception: + pass + + # Switch to the target session + self.session_id = target_id + self._resumed = True + self._pending_title = None + + # Load conversation history + restored = self._session_db.get_messages_as_conversation(target_id) + self.conversation_history = restored or [] + + # Re-open the target session so it's not marked as ended + try: + self._session_db.reopen_session(target_id) + except Exception: + pass + + # Sync the agent if already initialised + if self.agent: + self.agent.session_id = target_id + self.agent.reset_session_state() + if hasattr(self.agent, "_last_flushed_db_idx"): + self.agent._last_flushed_db_idx = len(self.conversation_history) + if hasattr(self.agent, "_todo_store"): + try: + from tools.todo_tool import TodoStore + self.agent._todo_store = TodoStore() + except Exception: + pass + if hasattr(self.agent, "_invalidate_system_prompt"): + self.agent._invalidate_system_prompt() + + title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else "" + msg_count = len([m for m in self.conversation_history if m.get("role") == "user"]) + if self.conversation_history: + _cprint( + f" ↻ Resumed session {target_id}{title_part}" + f" ({msg_count} user message{'s' if msg_count != 1 else ''}," + f" {len(self.conversation_history)} total)" + ) + else: + _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") + def reset_conversation(self): """Reset the conversation by starting a new session.""" self.new_session() @@ -3667,6 +3743,8 @@ class HermesCLI: _cprint(" Session database not available.") elif canonical == "new": self.new_session() + elif canonical == "resume": + self._handle_resume_command(cmd_original) elif canonical == "provider": self._show_model_and_providers() elif canonical == "prompt": diff --git a/hermes_state.py b/hermes_state.py index d3088fce6..31ed12190 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -284,6 +284,15 @@ class SessionDB: ) self._conn.commit() + def reopen_session(self, session_id: str) -> None: + """Clear ended_at/end_reason so a session can be resumed.""" + with self._lock: + self._conn.execute( + "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", + (session_id,), + ) + self._conn.commit() + def update_system_prompt(self, session_id: str, system_prompt: str) -> None: """Store the full assembled system prompt snapshot.""" with self._lock: diff --git a/run_agent.py b/run_agent.py index 7810a8ce1..67624e60e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2055,6 +2055,23 @@ class AIAgent: msg["content"] = self._clean_session_content(msg["content"]) cleaned.append(msg) + # Guard: never overwrite a larger session log with fewer messages. + # This protects against data loss when --resume loads a session whose + # messages weren't fully written to SQLite — the resumed agent starts + # with partial history and would otherwise clobber the full JSON log. + if self.session_log_file.exists(): + try: + existing = json.loads(self.session_log_file.read_text(encoding="utf-8")) + existing_count = existing.get("message_count", len(existing.get("messages", []))) + if existing_count > len(cleaned): + logging.debug( + "Skipping session log overwrite: existing has %d messages, current has %d", + existing_count, len(cleaned), + ) + return + except Exception: + pass # corrupted existing file — allow the overwrite + entry = { "session_id": self.session_id, "model": self.model,