Merge remote-tracking branch 'origin/main' into codex/align-codex-provider-conventions-mainrepo

# Conflicts:
#	cron/scheduler.py
#	gateway/run.py
#	tools/delegate_tool.py
This commit is contained in:
George Pickett
2026-02-26 10:56:29 -08:00
61 changed files with 8482 additions and 244 deletions

107
cli.py
View File

@@ -49,16 +49,26 @@ import threading
import queue
# Load environment variables first
# Load .env from ~/.hermes/.env first, then project root as dev fallback
from dotenv import load_dotenv
from hermes_constants import OPENROUTER_BASE_URL
env_path = Path(__file__).parent / '.env'
if env_path.exists():
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
_user_env = _hermes_home / ".env"
_project_env = Path(__file__).parent / '.env'
if _user_env.exists():
try:
load_dotenv(dotenv_path=env_path, encoding="utf-8")
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(dotenv_path=env_path, encoding="latin-1")
load_dotenv(dotenv_path=_user_env, encoding="latin-1")
elif _project_env.exists():
try:
load_dotenv(dotenv_path=_project_env, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(dotenv_path=_project_env, encoding="latin-1")
# Point mini-swe-agent at ~/.hermes/ so it shares our config
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home))
# =============================================================================
# Configuration Loading
@@ -132,15 +142,6 @@ def load_cli_config() -> Dict[str, Any]:
else:
config_path = project_config_path
# Also load .env from ~/.hermes/.env if it exists
user_env_path = Path.home() / '.hermes' / '.env'
if user_env_path.exists():
from dotenv import load_dotenv
try:
load_dotenv(dotenv_path=user_env_path, override=True, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(dotenv_path=user_env_path, override=True, encoding="latin-1")
# Default configuration
defaults = {
"model": {
@@ -744,6 +745,7 @@ class HermesCLI:
max_turns: int = 60,
verbose: bool = False,
compact: bool = False,
resume: str = None,
):
"""
Initialize the Hermes CLI.
@@ -757,6 +759,7 @@ class HermesCLI:
max_turns: Maximum tool-calling iterations (default: 60)
verbose: Enable verbose logging
compact: Use compact display mode
resume: Session ID to resume (restores conversation history from SQLite)
"""
# Initialize Rich console
self.console = Console()
@@ -830,12 +833,16 @@ class HermesCLI:
# Conversation state
self.conversation_history: List[Dict[str, Any]] = []
self.session_start = datetime.now()
self._resumed = False
# Generate session ID with timestamp for display and logging
# Format: YYYYMMDD_HHMMSS_shortUUID (e.g., 20260201_143052_a1b2c3)
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
self.session_id = f"{timestamp_str}_{short_uuid}"
# Session ID: reuse existing one when resuming, otherwise generate fresh
if resume:
self.session_id = resume
self._resumed = True
else:
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
self.session_id = f"{timestamp_str}_{short_uuid}"
# History file for persistent input recall across sessions
self._history_file = Path.home() / ".hermes_history"
@@ -894,6 +901,7 @@ class HermesCLI:
def _init_agent(self) -> bool:
"""
Initialize the agent on first use.
When resuming a session, restores conversation history from SQLite.
Returns:
bool: True if successful, False otherwise
@@ -912,6 +920,34 @@ class HermesCLI:
except Exception as e:
logger.debug("SQLite session store not available: %s", e)
# If resuming, validate the session exists and load its history
if self._resumed and self._session_db:
session_meta = self._session_db.get_session(self.session_id)
if not session_meta:
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
return False
restored = self._session_db.get_messages_as_conversation(self.session_id)
if restored:
self.conversation_history = restored
msg_count = len([m for m in restored if m.get("role") == "user"])
_cprint(
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages){_RST}"
)
else:
_cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}")
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
(self.session_id,),
)
self._session_db._conn.commit()
except Exception:
pass
try:
self.agent = AIAgent(
model=self.model,
@@ -1909,6 +1945,32 @@ class HermesCLI:
print(f"Error: {e}")
return None
def _print_exit_summary(self):
"""Print session resume info on exit, similar to Claude Code."""
print()
msg_count = len(self.conversation_history)
if msg_count > 0:
user_msgs = len([m for m in self.conversation_history if m.get("role") == "user"])
tool_calls = len([m for m in self.conversation_history if m.get("role") == "tool" or m.get("tool_calls")])
elapsed = datetime.now() - self.session_start
hours, remainder = divmod(int(elapsed.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
duration_str = f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
duration_str = f"{minutes}m {seconds}s"
else:
duration_str = f"{seconds}s"
print(f"Resume this session with:")
print(f" hermes --resume {self.session_id}")
print()
print(f"Session: {self.session_id}")
print(f"Duration: {duration_str}")
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
else:
print("Goodbye! ⚕")
def run(self):
"""Run the interactive CLI loop with persistent input at bottom."""
self.show_banner()
@@ -2569,7 +2631,7 @@ class HermesCLI:
except Exception as e:
logger.debug("Could not close session in DB: %s", e)
_run_cleanup()
print("\nGoodbye! ⚕")
self._print_exit_summary()
# ============================================================================
@@ -2590,6 +2652,7 @@ def main(
list_tools: bool = False,
list_toolsets: bool = False,
gateway: bool = False,
resume: str = None,
):
"""
Hermes Agent CLI - Interactive AI Assistant
@@ -2607,12 +2670,14 @@ def main(
compact: Use compact display mode
list_tools: List available tools and exit
list_toolsets: List available toolsets and exit
resume: Resume a previous session by its ID (e.g., 20260225_143052_a1b2c3)
Examples:
python cli.py # Start interactive mode
python cli.py --toolsets web,terminal # Use specific toolsets
python cli.py -q "What is Python?" # Single query mode
python cli.py --list-tools # List tools and exit
python cli.py --resume 20260225_143052_a1b2c3 # Resume session
"""
# Signal to terminal_tool that we're in interactive mode
# This enables interactive sudo password prompts with timeout
@@ -2661,6 +2726,7 @@ def main(
max_turns=max_turns,
verbose=verbose,
compact=compact,
resume=resume,
)
# Handle list commands (don't init agent for these)
@@ -2682,6 +2748,7 @@ def main(
cli.show_banner()
cli.console.print(f"[bold blue]Query:[/] {query}")
cli.chat(query)
cli._print_exit_summary()
return
# Run interactive mode