Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 57 additions & 43 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@
)
from hermes_cli.banner import _format_context_length


def _print_ascii_banner_fallback(console, model: str, session_id: str | None) -> None:
"""Fallback banner for terminals that cannot render the default Rich output."""
stream = getattr(sys, "__stdout__", None) or sys.stdout
stream.write("Hermes Agent\n")
stream.write(f"Model: {model}\n")
if session_id:
stream.write(f"Session: {session_id}\n")
stream.write("Banner rendering fell back to ASCII because this terminal encoding does not support the default banner.\n")
stream.flush()

_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")


Expand Down Expand Up @@ -1962,50 +1973,53 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No

def show_banner(self):
"""Display the welcome banner in Claude Code style."""
self.console.clear()
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
self.console.print(
f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}"
)
try:
self.console.clear()
if self.preloaded_skills and not self._startup_skills_line_shown:
skills_label = ", ".join(self.preloaded_skills)
self.console.print(
f"[bold {_accent_hex()}]Activated skills:[/] {skills_label}"
)
self.console.print()
self._startup_skills_line_shown = True

# Auto-compact for narrow terminals — the full banner with caduceus
# + tool list needs ~80 columns minimum to render without wrapping.
term_width = shutil.get_terminal_size().columns
use_compact = self.compact or term_width < 80

if use_compact:
self.console.print(_build_compact_banner())
self._show_status()
else:
# Get tools for display
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)

# Get terminal working directory (where commands will execute)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())

# Get context length for display
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
ctx_len = self.agent.context_compressor.context_length

# Build and display the banner
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
)

# Show tool availability warnings if any tools are disabled
self._show_tool_availability_warnings()

self.console.print()
self._startup_skills_line_shown = True

# Auto-compact for narrow terminals — the full banner with caduceus
# + tool list needs ~80 columns minimum to render without wrapping.
term_width = shutil.get_terminal_size().columns
use_compact = self.compact or term_width < 80

if use_compact:
self.console.print(_build_compact_banner())
self._show_status()
else:
# Get tools for display
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)

# Get terminal working directory (where commands will execute)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())

# Get context length for display
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
ctx_len = self.agent.context_compressor.context_length

# Build and display the banner
build_welcome_banner(
console=self.console,
model=self.model,
cwd=cwd,
tools=tools,
enabled_toolsets=self.enabled_toolsets,
session_id=self.session_id,
context_length=ctx_len,
)

# Show tool availability warnings if any tools are disabled
self._show_tool_availability_warnings()

self.console.print()
except UnicodeEncodeError:
_print_ascii_banner_fallback(self.console, self.model, self.session_id)

def _preload_resumed_session(self) -> bool:
"""Load a resumed session's history from the DB early (before first chat).
Expand Down
106 changes: 103 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# per-message AIAgent instances.
self._honcho_managers: Dict[str, Any] = {}
self._honcho_configs: Dict[str, Any] = {}
self._retaindb_managers: Dict[str, Any] = {}
self._retaindb_configs: Dict[str, Any] = {}

# Ensure tirith security scanner is available (downloads if needed)
try:
Expand Down Expand Up @@ -426,6 +428,49 @@ def _get_or_create_gateway_honcho(self, session_key: str):
logger.debug("Gateway Honcho init failed for %s: %s", session_key, e)
return None, None

def _build_retaindb_identity(self, source, session_id: str) -> dict:
"""Build RetainDB runtime identity hints from the gateway session source."""
return {
"platform": source.platform.value if getattr(source, "platform", None) else "",
"platform_user_id": getattr(source, "user_id", None),
"user_name": getattr(source, "user_name", None),
"chat_id": getattr(source, "chat_id", None),
"peer_name": getattr(source, "user_name", None),
"session_id": session_id,
}

def _get_or_create_gateway_retaindb(self, session_key: str, identity: Optional[dict] = None):
"""Return a persistent RetainDB manager/config pair for this gateway session."""
if not hasattr(self, "_retaindb_managers"):
self._retaindb_managers = {}
if not hasattr(self, "_retaindb_configs"):
self._retaindb_configs = {}

if session_key in self._retaindb_managers:
manager = self._retaindb_managers[session_key]
if identity and hasattr(manager, "set_runtime_identity"):
manager.set_runtime_identity(identity)
return manager, self._retaindb_configs.get(session_key)

try:
from retaindb_integration.client import RetainDBClientConfig
from retaindb_integration.session import RetainDBSessionManager

rcfg = RetainDBClientConfig.from_global_config()
if not rcfg.should_activate():
return None, rcfg

manager = RetainDBSessionManager(
config=rcfg,
runtime_identity=identity or {},
)
self._retaindb_managers[session_key] = manager
self._retaindb_configs[session_key] = rcfg
return manager, rcfg
except Exception as e:
logger.debug("Gateway RetainDB init failed for %s: %s", session_key, e)
return None, None

def _shutdown_gateway_honcho(self, session_key: str) -> None:
"""Flush and close the persistent Honcho manager for a gateway session."""
managers = getattr(self, "_honcho_managers", None)
Expand All @@ -442,13 +487,37 @@ def _shutdown_gateway_honcho(self, session_key: str) -> None:
except Exception as e:
logger.debug("Gateway Honcho shutdown failed for %s: %s", session_key, e)

def _shutdown_gateway_retaindb(self, session_key: str) -> None:
"""Flush and close the persistent RetainDB manager for a gateway session."""
managers = getattr(self, "_retaindb_managers", None)
configs = getattr(self, "_retaindb_configs", None)
if managers is None or configs is None:
return

manager = managers.pop(session_key, None)
configs.pop(session_key, None)
if not manager:
return
try:
manager.shutdown()
except Exception as e:
logger.debug("Gateway RetainDB shutdown failed for %s: %s", session_key, e)

def _shutdown_all_gateway_honcho(self) -> None:
"""Flush and close all persistent Honcho managers."""
managers = getattr(self, "_honcho_managers", None)
if not managers:
return
for session_key in list(managers.keys()):
self._shutdown_gateway_honcho(session_key)

def _shutdown_all_gateway_retaindb(self) -> None:
"""Flush and close all persistent RetainDB managers."""
managers = getattr(self, "_retaindb_managers", None)
if not managers:
return
for session_key in list(managers.keys()):
self._shutdown_gateway_retaindb(session_key)

# -- Setup skill availability ----------------------------------------

Expand Down Expand Up @@ -515,6 +584,7 @@ def _flush_memories_for_session(
self,
old_session_id: str,
honcho_session_key: Optional[str] = None,
source: Optional[SessionSource] = None,
):
"""Prompt the agent to save memories/skills before context is lost.

Expand All @@ -536,6 +606,17 @@ def _flush_memories_for_session(
# active provider is openai-codex.
model = _resolve_gateway_model()

retaindb_identity = (
self._build_retaindb_identity(source, old_session_id) if source else None
)
retaindb_manager = None
retaindb_config = None
if honcho_session_key:
retaindb_manager, retaindb_config = self._get_or_create_gateway_retaindb(
honcho_session_key,
retaindb_identity,
)

tmp_agent = AIAgent(
**runtime_kwargs,
model=model,
Expand All @@ -544,6 +625,10 @@ def _flush_memories_for_session(
enabled_toolsets=["memory", "skills"],
session_id=old_session_id,
honcho_session_key=honcho_session_key,
retaindb_session_key=old_session_id,
retaindb_manager=retaindb_manager,
retaindb_config=retaindb_config,
retaindb_identity=retaindb_identity,
)

# Build conversation history from transcript
Expand Down Expand Up @@ -587,6 +672,7 @@ async def _async_flush_memories(
self,
old_session_id: str,
honcho_session_key: Optional[str] = None,
source: Optional[SessionSource] = None,
):
"""Run the sync memory flush in a thread pool so it won't block the event loop."""
loop = asyncio.get_event_loop()
Expand All @@ -595,6 +681,7 @@ async def _async_flush_memories(
self._flush_memories_for_session,
old_session_id,
honcho_session_key,
source,
)

@property
Expand Down Expand Up @@ -1055,8 +1142,9 @@ async def _session_expiry_watcher(self, interval: int = 300):
entry.session_id, key,
)
try:
await self._async_flush_memories(entry.session_id, key)
await self._async_flush_memories(entry.session_id, key, getattr(entry, "origin", None))
self._shutdown_gateway_honcho(key)
self._shutdown_gateway_retaindb(key)
self.session_store._pre_flushed_sessions.add(entry.session_id)
except Exception as e:
logger.debug("Proactive memory flush failed for %s: %s", entry.session_id, e)
Expand Down Expand Up @@ -1098,6 +1186,7 @@ async def stop(self) -> None:
self._pending_messages.clear()
self._pending_approvals.clear()
self._shutdown_all_gateway_honcho()
self._shutdown_all_gateway_retaindb()
self._shutdown_event.set()

from gateway.status import remove_pid_file, write_runtime_status
Expand Down Expand Up @@ -2479,12 +2568,13 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
old_entry = self.session_store._entries.get(session_key)
if old_entry:
asyncio.create_task(
self._async_flush_memories(old_entry.session_id, session_key)
self._async_flush_memories(old_entry.session_id, session_key, getattr(old_entry, "origin", None))
)
except Exception as e:
logger.debug("Gateway memory flush on reset failed: %s", e)

self._shutdown_gateway_honcho(session_key)
self._shutdown_gateway_retaindb(session_key)
self._evict_cached_agent(session_key)

# Reset the session
Expand Down Expand Up @@ -3891,12 +3981,13 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
# Flush memories for current session before switching
try:
asyncio.create_task(
self._async_flush_memories(current_entry.session_id, session_key)
self._async_flush_memories(current_entry.session_id, session_key, getattr(current_entry, "origin", None))
)
except Exception as e:
logger.debug("Memory flush on resume failed: %s", e)

self._shutdown_gateway_honcho(session_key)
self._shutdown_gateway_retaindb(session_key)

# Clear any running agent for this session key
if session_key in self._running_agents:
Expand Down Expand Up @@ -4957,6 +5048,11 @@ def run_sync():

pr = self._provider_routing
honcho_manager, honcho_config = self._get_or_create_gateway_honcho(session_key)
retaindb_identity = self._build_retaindb_identity(source, session_id)
retaindb_manager, retaindb_config = self._get_or_create_gateway_retaindb(
session_key,
retaindb_identity,
)
reasoning_config = self._load_reasoning_config()
self._reasoning_config = reasoning_config
# Set up streaming consumer if enabled
Expand Down Expand Up @@ -5032,6 +5128,10 @@ def run_sync():
honcho_session_key=session_key,
honcho_manager=honcho_manager,
honcho_config=honcho_config,
retaindb_session_key=session_id,
retaindb_manager=retaindb_manager,
retaindb_config=retaindb_config,
retaindb_identity=retaindb_identity,
session_db=self._session_db,
fallback_model=self._fallback_model,
)
Expand Down
5 changes: 2 additions & 3 deletions hermes
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@ Usage: ./hermes [options]
"""

if __name__ == "__main__":
from cli import main
import fire
fire.Fire(main)
from hermes_cli.main import main
main()
30 changes: 30 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,23 @@ def ensure_hermes_home():
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
"honcho": {},

# Native RetainDB memory integration.
# RetainDB adds optional cross-session recall on top of Hermes local memory.
"retaindb": {
"enabled": True,
"base_url": "https://api.retaindb.com",
"project": "",
"memory_mode": "hybrid",
"recall_mode": "hybrid",
"write_frequency": "async",
"context_tokens": 1200,
"prefetch_timeout_ms": 1500,
"flush_batch_size": 50,
"disable_tool_exposure": False,
"debug_recall_trace": False,
"agent_id": "hermes",
},

# IANA timezone (e.g. "Asia/Kolkata", "America/New_York").
# Empty string means use server-local time.
"timezone": "",
Expand Down Expand Up @@ -676,6 +693,19 @@ def ensure_hermes_home():
"prompt": "Honcho base URL (e.g. http://localhost:8000)",
"category": "tool",
},
"RETAINDB_API_KEY": {
"description": "RetainDB API key for native Hermes deep memory",
"prompt": "RetainDB API key",
"url": "https://retaindb.com/dashboard",
"tools": ["retaindb_context"],
"password": True,
"category": "tool",
},
"RETAINDB_BASE_URL": {
"description": "Base URL for self-hosted RetainDB instances",
"prompt": "RetainDB base URL (leave empty for cloud)",
"category": "tool",
},

# ── Messaging platforms ──
"TELEGRAM_BOT_TOKEN": {
Expand Down
Loading