Merge remote-tracking branch 'origin/main' into hermes/hermes-3702edad

This commit is contained in:
teknium1 2026-03-14 03:42:06 -07:00
commit 2b7c80b18e
18 changed files with 884 additions and 340 deletions

View file

@ -68,7 +68,7 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str:
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None:
"""Build a short preview of a tool call's primary argument for display."""
if not args:
return None

225
cli.py
View file

@ -404,8 +404,10 @@ except Exception:
from rich import box as rich_box
from rich.console import Console
from rich.markup import escape as _escape
from rich.panel import Panel
from rich.table import Table
from rich.text import Text as _RichText
import fire
@ -696,6 +698,24 @@ _BOLD = "\033[1m"
_DIM = "\033[2m"
_RST = "\033[0m"
def _accent_hex() -> str:
"""Return the active skin accent color for legacy CLI output lines."""
try:
from hermes_cli.skin_engine import get_active_skin
return get_active_skin().get_color("ui_accent", "#FFBF00")
except Exception:
return "#FFBF00"
def _rich_text_from_ansi(text: str) -> _RichText:
"""Safely render assistant/tool output that may contain ANSI escapes.
Using Rich Text.from_ansi preserves literal bracketed text like
``[not markup]`` while still interpreting real ANSI color codes.
"""
return _RichText.from_ansi(text or "")
def _cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's native renderer.
@ -718,7 +738,12 @@ class ChatConsole:
def __init__(self):
from io import StringIO
self._buffer = StringIO()
self._inner = Console(file=self._buffer, force_terminal=True, highlight=False)
self._inner = Console(
file=self._buffer,
force_terminal=True,
color_system="truecolor",
highlight=False,
)
def print(self, *args, **kwargs):
self._buffer.seek(0)
@ -1472,13 +1497,16 @@ class HermesCLI:
title_part = ""
if session_meta.get("title"):
title_part = f" \"{session_meta['title']}\""
_cprint(
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages){_RST}"
ChatConsole().print(
f"[bold {_accent_hex()}]↻ Resumed session[/] "
f"[bold]{_escape(self.session_id)}[/]"
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
)
else:
_cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}")
ChatConsole().print(
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
)
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
@ -1738,6 +1766,19 @@ class HermesCLI:
from rich.panel import Panel
from rich.text import Text
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
_history_text_c = _skin.get_color("banner_text", "#FFF8DC")
_session_label_c = _skin.get_color("session_label", "#DAA520")
_session_border_c = _skin.get_color("session_border", "#8B8682")
_assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
except Exception:
_history_text_c = "#FFF8DC"
_session_label_c = "#DAA520"
_session_border_c = "#8B8682"
_assistant_label_c = "#8FBC8F"
lines = Text()
if skipped:
lines.append(
@ -1747,14 +1788,14 @@ class HermesCLI:
for i, (role, text) in enumerate(entries):
if role == "user":
lines.append(" ● You: ", style="dim bold #DAA520")
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
# Show first line inline, indent rest
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="dim")
else:
lines.append(" ◆ Hermes: ", style="dim bold #8FBC8F")
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
@ -1764,9 +1805,10 @@ class HermesCLI:
panel = Panel(
lines,
title="[dim #DAA520]Previous Conversation[/]",
border_style="dim #8B8682",
title=f"[dim {_session_label_c}]Previous Conversation[/]",
border_style=f"dim {_session_border_c}",
padding=(0, 1),
style=_history_text_c,
)
self.console.print(panel)
@ -1976,19 +2018,30 @@ class HermesCLI:
"""Display help information with categorized commands."""
from hermes_cli.commands import COMMANDS_BY_CATEGORY
_cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}")
_cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}")
_cprint(f"{_BOLD}+{'-' * 55}+{_RST}")
try:
from hermes_cli.skin_engine import get_active_help_header
header = get_active_help_header("(^_^)? Available Commands")
except Exception:
header = "(^_^)? Available Commands"
header = (header or "").strip() or "(^_^)? Available Commands"
inner_width = 55
if len(header) > inner_width:
header = header[:inner_width]
_cprint(f"\n{_BOLD}+{'-' * inner_width}+{_RST}")
_cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}")
_cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}")
for category, commands in COMMANDS_BY_CATEGORY.items():
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
for cmd, desc in commands.items():
_cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}")
ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
if _skill_commands:
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
for cmd, info in sorted(_skill_commands.items()):
_cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}")
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
@ -2981,8 +3034,7 @@ class HermesCLI:
)
output = result.stdout.strip() or result.stderr.strip()
if output:
from rich.text import Text as _RichText
self.console.print(_RichText.from_ansi(output))
self.console.print(_rich_text_from_ansi(output))
else:
self.console.print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
@ -3076,27 +3128,29 @@ class HermesCLI:
# Display result in the CLI (thread-safe via patch_stdout)
print()
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
_cprint(f" ✅ Background task #{task_num} complete")
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
if response:
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
except Exception:
label = "⚕ Hermes"
_resp_color = "#CD7F32"
_resp_text = "#FFF8DC"
from rich.text import Text as _RichText
_chat_console = ChatConsole()
_chat_console.print(Panel(
_RichText.from_ansi(response),
title=f"[bold]{label} (background #{task_num})[/bold]",
_rich_text_from_ansi(response),
title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
title_align="left",
border_style=_resp_color,
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 2),
))
@ -3156,6 +3210,8 @@ class HermesCLI:
else:
print(f" Skin set to: {new_skin}")
print(" Note: banner colors will update on next session start.")
if self._apply_tui_skin_style():
print(" Prompt + TUI colors updated.")
def _toggle_verbose(self):
"""Cycle tool progress mode: off → new → all → verbose → off."""
@ -3689,8 +3745,8 @@ class HermesCLI:
# Add user message to history
self.conversation_history.append({"role": "user", "content": message})
_cprint(f"{_GOLD}{'─' * 40}{_RST}")
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
print(flush=True)
try:
@ -3803,17 +3859,19 @@ class HermesCLI:
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
except Exception:
label = "⚕ Hermes"
_resp_color = "#CD7F32"
_resp_text = "#FFF8DC"
from rich.text import Text as _RichText
_chat_console = ChatConsole()
_chat_console.print(Panel(
_RichText.from_ansi(response),
title=f"[bold]{label}[/bold]",
_rich_text_from_ansi(response),
title=f"[{_resp_color} bold]{label}[/]",
title_align="left",
border_style=_resp_color,
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 2),
))
@ -3869,7 +3927,80 @@ class HermesCLI:
print(f"Duration: {duration_str}")
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
else:
print("Goodbye! ⚕")
try:
from hermes_cli.skin_engine import get_active_goodbye
goodbye = get_active_goodbye("Goodbye! ⚕")
except Exception:
goodbye = "Goodbye! ⚕"
print(goodbye)
def _get_tui_prompt_symbols(self) -> tuple[str, str]:
"""Return ``(normal_prompt, state_suffix)`` for the active skin.
``normal_prompt`` is the full ``branding.prompt_symbol``.
``state_suffix`` is what special states (sudo/secret/approval/agent)
should render after their leading icon.
"""
try:
from hermes_cli.skin_engine import get_active_prompt_symbol
symbol = get_active_prompt_symbol("❯ ")
except Exception:
symbol = "❯ "
symbol = (symbol or "❯ ").rstrip() + " "
stripped = symbol.rstrip()
if not stripped:
return "❯ ", "❯ "
parts = stripped.split()
candidate = parts[-1] if parts else ""
arrow_chars = ("❯", ">", "$", "#", "›", "»", "→")
if any(ch in candidate for ch in arrow_chars):
return symbol, candidate.rstrip() + " "
# Icon-only custom prompts should still remain visible in special states.
return symbol, symbol
def _get_tui_prompt_fragments(self):
"""Return the prompt_toolkit fragments for the current interactive state."""
symbol, state_suffix = self._get_tui_prompt_symbols()
if self._sudo_state:
return [("class:sudo-prompt", f"🔐 {state_suffix}")]
if self._secret_state:
return [("class:sudo-prompt", f"🔑 {state_suffix}")]
if self._approval_state:
return [("class:prompt-working", f"⚠ {state_suffix}")]
if self._clarify_freetext:
return [("class:clarify-selected", f"✎ {state_suffix}")]
if self._clarify_state:
return [("class:prompt-working", f"? {state_suffix}")]
if self._command_running:
return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")]
if self._agent_running:
return [("class:prompt-working", f"⚕ {state_suffix}")]
return [("class:prompt", symbol)]
def _get_tui_prompt_text(self) -> str:
"""Return the visible prompt text for width calculations."""
return "".join(text for _, text in self._get_tui_prompt_fragments())
def _build_tui_style_dict(self) -> dict[str, str]:
"""Layer the active skin's prompt_toolkit colors over the base TUI style."""
style_dict = dict(getattr(self, "_tui_style_base", {}) or {})
try:
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
style_dict.update(get_prompt_toolkit_style_overrides())
except Exception:
pass
return style_dict
def _apply_tui_skin_style(self) -> bool:
"""Refresh prompt_toolkit styling for a running interactive TUI."""
if not getattr(self, "_app", None) or not getattr(self, "_tui_style_base", None):
return False
self._app.style = PTStyle.from_dict(self._build_tui_style_dict())
self._invalidate(min_interval=0.0)
return True
def run(self):
"""Run the interactive CLI loop with persistent input at bottom."""
@ -4241,21 +4372,7 @@ class HermesCLI:
cli_ref = self
def get_prompt():
if cli_ref._sudo_state:
return [('class:sudo-prompt', '🔐 ❯ ')]
if cli_ref._secret_state:
return [('class:sudo-prompt', '🔑 ❯ ')]
if cli_ref._approval_state:
return [('class:prompt-working', '⚠ ❯ ')]
if cli_ref._clarify_freetext:
return [('class:clarify-selected', '✎ ❯ ')]
if cli_ref._clarify_state:
return [('class:prompt-working', '? ❯ ')]
if cli_ref._command_running:
return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} ❯ ")]
if cli_ref._agent_running:
return [('class:prompt-working', '⚕ ❯ ')]
return [('class:prompt', '❯ ')]
return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
input_area = TextArea(
@ -4272,11 +4389,11 @@ class HermesCLI:
# Dynamic height: accounts for both explicit newlines AND visual
# wrapping of long lines so the input area always fits its content.
# The prompt characters ("❯ " etc.) consume ~4 columns.
def _input_height():
try:
doc = input_area.buffer.document
available_width = shutil.get_terminal_size().columns - 4 # subtract prompt width
prompt_width = max(2, len(self._get_tui_prompt_text()))
available_width = shutil.get_terminal_size().columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
@ -4717,7 +4834,7 @@ class HermesCLI:
)
# Style for the application
style = PTStyle.from_dict({
self._tui_style_base = {
'input-area': '#FFF8DC',
'placeholder': '#555555 italic',
'prompt': '#FFF8DC',
@ -4752,7 +4869,8 @@ class HermesCLI:
'approval-cmd': '#AAAAAA italic',
'approval-choice': '#AAAAAA',
'approval-selected': '#FFD700 bold',
})
}
style = PTStyle.from_dict(self._build_tui_style_dict())
# Create the application
app = Application(
@ -4815,20 +4933,25 @@ class HermesCLI:
full_text = paste_path.read_text(encoding="utf-8")
line_count = full_text.count('\n') + 1
print()
_cprint(f"{_GOLD}●{_RST} {_BOLD}[Pasted text: {line_count} lines]{_RST}")
ChatConsole().print(
f"[bold {_accent_hex()}]●[/] [bold]{_escape(f'[Pasted text: {line_count} lines]')}[/]"
)
user_input = full_text
else:
print()
_cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}")
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
else:
if '\n' in user_input:
first_line = user_input.split('\n')[0]
line_count = user_input.count('\n') + 1
print()
_cprint(f"{_GOLD}●{_RST} {_BOLD}{first_line}{_RST} {_DIM}(+{line_count - 1} lines){_RST}")
ChatConsole().print(
f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] "
f"[dim](+{line_count - 1} lines)[/]"
)
else:
print()
_cprint(f"{_GOLD}●{_RST} {_BOLD}{user_input}{_RST}")
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
# Show image attachment count
if submit_images:

View file

@ -265,6 +265,28 @@ class DiscordAdapter(BasePlatformAdapter):
logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True)
return SendResult(success=False, error=str(e))
async def _send_file_attachment(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
) -> SendResult:
"""Send a local file as a Discord attachment."""
if not self._client:
return SendResult(success=False, error="Not connected")
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
filename = os.path.basename(file_path)
with open(file_path, "rb") as fh:
file = discord.File(fh, filename=filename)
msg = await channel.send(content=caption if caption else None, file=file)
return SendResult(success=True, message_id=str(msg.id))
async def send_voice(
self,
chat_id: str,
@ -274,36 +296,14 @@ class DiscordAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send audio as a Discord file attachment."""
if not self._client:
return SendResult(success=False, error="Not connected")
try:
import io
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
if not os.path.exists(audio_path):
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
# Determine filename from path
filename = os.path.basename(audio_path)
with open(audio_path, "rb") as f:
file = discord.File(io.BytesIO(f.read()), filename=filename)
msg = await channel.send(
content=caption if caption else None,
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
return await self._send_file_attachment(chat_id, audio_path, caption)
except FileNotFoundError:
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True)
return await super().send_voice(chat_id, audio_path, caption, reply_to)
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
async def send_image_file(
self,
chat_id: str,
@ -313,34 +313,13 @@ class DiscordAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a local image file natively as a Discord file attachment."""
if not self._client:
return SendResult(success=False, error="Not connected")
try:
import io
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
if not os.path.exists(image_path):
return SendResult(success=False, error=f"Image file not found: {image_path}")
filename = os.path.basename(image_path)
with open(image_path, "rb") as f:
file = discord.File(io.BytesIO(f.read()), filename=filename)
msg = await channel.send(
content=caption if caption else None,
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
return await self._send_file_attachment(chat_id, image_path, caption)
except FileNotFoundError:
return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send local image, falling back to base adapter: %s", self.name, e, exc_info=True)
return await super().send_image_file(chat_id, image_path, caption, reply_to)
return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
async def send_image(
self,
@ -528,7 +507,22 @@ class DiscordAdapter(BasePlatformAdapter):
"""
# Discord markdown is fairly standard, no special escaping needed
return content
async def _run_simple_slash(
self,
interaction: discord.Interaction,
command_text: str,
followup_msg: str = "Done~",
) -> None:
"""Common handler for simple slash commands that dispatch a command string."""
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, command_text)
await self.handle_message(event)
try:
await interaction.followup.send(followup_msg, ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
def _register_slash_commands(self) -> None:
"""Register Discord slash commands on the command tree."""
if not self._client:
@ -551,34 +545,16 @@ class DiscordAdapter(BasePlatformAdapter):
@tree.command(name="new", description="Start a new conversation")
async def slash_new(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/reset")
await self.handle_message(event)
try:
await interaction.followup.send("New conversation started~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/reset", "New conversation started~")
@tree.command(name="reset", description="Reset your Hermes session")
async def slash_reset(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/reset")
await self.handle_message(event)
try:
await interaction.followup.send("Session reset~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/reset", "Session reset~")
@tree.command(name="model", description="Show or change the model")
@discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.")
async def slash_model(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/model {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/model {name}".strip())
@tree.command(name="reasoning", description="Show or change reasoning effort")
@discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
@ -594,156 +570,66 @@ class DiscordAdapter(BasePlatformAdapter):
@tree.command(name="personality", description="Set a personality")
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
async def slash_personality(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/personality {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/personality {name}".strip())
@tree.command(name="retry", description="Retry your last message")
async def slash_retry(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/retry")
await self.handle_message(event)
try:
await interaction.followup.send("Retrying~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/retry", "Retrying~")
@tree.command(name="undo", description="Remove the last exchange")
async def slash_undo(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/undo")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/undo")
@tree.command(name="status", description="Show Hermes session status")
async def slash_status(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/status")
await self.handle_message(event)
try:
await interaction.followup.send("Status sent~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/status", "Status sent~")
@tree.command(name="sethome", description="Set this chat as the home channel")
async def slash_sethome(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/sethome")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/sethome")
@tree.command(name="stop", description="Stop the running Hermes agent")
async def slash_stop(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/stop")
await self.handle_message(event)
try:
await interaction.followup.send("Stop requested~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/stop", "Stop requested~")
@tree.command(name="compress", description="Compress conversation context")
async def slash_compress(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/compress")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/compress")
@tree.command(name="title", description="Set or show the session title")
@discord.app_commands.describe(name="Session title. Leave empty to show current.")
async def slash_title(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/title {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/title {name}".strip())
@tree.command(name="resume", description="Resume a previously-named session")
@discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
async def slash_resume(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/resume {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/resume {name}".strip())
@tree.command(name="usage", description="Show token usage for this session")
async def slash_usage(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/usage")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/usage")
@tree.command(name="provider", description="Show available providers")
async def slash_provider(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/provider")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/provider")
@tree.command(name="help", description="Show available commands")
async def slash_help(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/help")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/help")
@tree.command(name="insights", description="Show usage insights and analytics")
@discord.app_commands.describe(days="Number of days to analyze (default: 7)")
async def slash_insights(interaction: discord.Interaction, days: int = 7):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/insights {days}")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/insights {days}")
@tree.command(name="reload-mcp", description="Reload MCP servers from config")
async def slash_reload_mcp(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/reload-mcp")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/reload-mcp")
@tree.command(name="update", description="Update Hermes Agent to the latest version")
async def slash_update(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/update")
await self.handle_message(event)
try:
await interaction.followup.send("Update initiated~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/update", "Update initiated~")
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
@discord.app_commands.describe(

View file

@ -260,6 +260,30 @@ class SlackAdapter(BasePlatformAdapter):
return metadata["thread_ts"]
return reply_to
async def _upload_file(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file to Slack."""
if not self._app:
return SendResult(success=False, error="Not connected")
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
result = await self._app.client.files_upload_v2(
channel=chat_id,
file=file_path,
filename=os.path.basename(file_path),
initial_comment=caption or "",
thread_ts=self._resolve_thread_ts(reply_to, metadata),
)
return SendResult(success=True, raw_response=result)
# ----- Markdown → mrkdwn conversion -----
def format_message(self, content: str) -> str:
@ -417,23 +441,10 @@ class SlackAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a local image file to Slack by uploading it."""
if not self._app:
return SendResult(success=False, error="Not connected")
try:
import os
if not os.path.exists(image_path):
return SendResult(success=False, error=f"Image file not found: {image_path}")
result = await self._app.client.files_upload_v2(
channel=chat_id,
file=image_path,
filename=os.path.basename(image_path),
initial_comment=caption or "",
thread_ts=self._resolve_thread_ts(reply_to, metadata),
)
return SendResult(success=True, raw_response=result)
return await self._upload_file(chat_id, image_path, caption, reply_to, metadata)
except FileNotFoundError:
return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error(
"[%s] Failed to send local Slack image %s: %s",
@ -497,19 +508,10 @@ class SlackAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an audio file to Slack."""
if not self._app:
return SendResult(success=False, error="Not connected")
try:
result = await self._app.client.files_upload_v2(
channel=chat_id,
file=audio_path,
filename=os.path.basename(audio_path),
initial_comment=caption or "",
thread_ts=self._resolve_thread_ts(reply_to, metadata),
)
return SendResult(success=True, raw_response=result)
return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata)
except FileNotFoundError:
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error(
"[Slack] Failed to send audio file %s: %s",

View file

@ -69,6 +69,8 @@ os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
import logging
import time as _time
from datetime import datetime
from hermes_cli import __version__, __release_date__
from hermes_constants import OPENROUTER_BASE_URL
@ -76,6 +78,24 @@ from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
def _relative_time(ts) -> str:
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
if delta < 3600:
return f"{int(delta / 60)}m ago"
if delta < 86400:
return f"{int(delta / 3600)}h ago"
if delta < 172800:
return "yesterday"
if delta < 604800:
return f"{int(delta / 86400)}d ago"
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def _has_any_provider_configured() -> bool:
"""Check if at least one inference provider is usable."""
from hermes_cli.config import get_env_path, get_hermes_home
@ -140,28 +160,9 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
# Try curses-based picker first
try:
import curses
import time as _time
from datetime import datetime
result_holder = [None]
def _relative_time(ts):
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
elif delta < 3600:
return f"{int(delta / 60)}m ago"
elif delta < 86400:
return f"{int(delta / 3600)}h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
return f"{int(delta / 86400)}d ago"
else:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def _format_row(s, max_x):
"""Format a session row for display."""
title = (s.get("title") or "").strip()
@ -352,26 +353,6 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
pass
# Fallback: numbered list (Windows without curses, etc.)
import time as _time
from datetime import datetime
def _relative_time_fb(ts):
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
elif delta < 3600:
return f"{int(delta / 60)}m ago"
elif delta < 86400:
return f"{int(delta / 3600)}h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
return f"{int(delta / 86400)}d ago"
else:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
print("\n Browse sessions (enter number to resume, q to cancel)\n")
for i, s in enumerate(sessions):
title = (s.get("title") or "").strip()
@ -379,7 +360,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
label = title or preview or s["id"]
if len(label) > 50:
label = label[:47] + "..."
last_active = _relative_time_fb(s.get("last_active"))
last_active = _relative_time(s.get("last_active"))
src = s.get("source", "")[:6]
print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}")
@ -2846,30 +2827,6 @@ For more help on a command:
if not sessions:
print("No sessions found.")
return
from datetime import datetime
import time as _time
def _relative_time(ts):
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
elif delta < 3600:
mins = int(delta / 60)
return f"{mins}m ago"
elif delta < 86400:
hours = int(delta / 3600)
return f"{hours}h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
days = int(delta / 86400)
return f"{days}d ago"
else:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
has_titles = any(s.get("title") for s in sessions)
if has_titles:
print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")

View file

@ -697,6 +697,12 @@ def setup_model_provider(config: dict):
active_oauth = get_active_provider()
existing_custom = get_env_value("OPENAI_BASE_URL")
model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {}
current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None
if current_config_provider == "auto":
current_config_provider = None
current_config_base_url = str(model_cfg.get("base_url") or "").strip()
# Detect credentials from other CLI tools
detected_creds = detect_external_credentials()
if detected_creds:
@ -709,10 +715,23 @@ def setup_model_provider(config: dict):
print()
# Detect if any provider is already configured
has_any_provider = bool(active_oauth or existing_custom or existing_or)
has_any_provider = bool(
current_config_provider or active_oauth or existing_custom or existing_or
)
# Build "keep current" label
if active_oauth and active_oauth in PROVIDER_REGISTRY:
if current_config_provider == "custom":
custom_label = current_config_base_url or existing_custom
keep_label = (
f"Keep current (Custom: {custom_label})"
if custom_label
else "Keep current (Custom)"
)
elif current_config_provider == "openrouter":
keep_label = "Keep current (OpenRouter)"
elif current_config_provider and current_config_provider in PROVIDER_REGISTRY:
keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})"
elif active_oauth and active_oauth in PROVIDER_REGISTRY:
keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})"
elif existing_custom:
keep_label = f"Keep current (Custom: {existing_custom})"
@ -1215,6 +1234,17 @@ def setup_model_provider(config: dict):
_set_model_provider(config, "anthropic")
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
# Normalize "keep current" to an explicit provider so downstream logic
# doesn't fall back to the generic OpenRouter/static-model path.
if selected_provider is None:
if current_config_provider:
selected_provider = current_config_provider
elif active_oauth and active_oauth in PROVIDER_REGISTRY:
selected_provider = active_oauth
elif existing_custom:
selected_provider = "custom"
elif existing_or:
selected_provider = "openrouter"
# ── OpenRouter API Key for tools (if not already set) ──
# Tools (vision, web, MoA) use OpenRouter independently of the main provider.

View file

@ -628,3 +628,88 @@ def init_skin_from_config(config: dict) -> None:
set_active_skin(skin_name.strip())
else:
set_active_skin("default")
# =============================================================================
# Convenience helpers for CLI modules
# =============================================================================
def get_active_prompt_symbol(fallback: str = "❯ ") -> str:
"""Get the interactive prompt symbol from the active skin."""
try:
return get_active_skin().get_branding("prompt_symbol", fallback)
except Exception:
return fallback
def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str:
"""Get the /help header from the active skin."""
try:
return get_active_skin().get_branding("help_header", fallback)
except Exception:
return fallback
def get_active_goodbye(fallback: str = "Goodbye! ⚕") -> str:
"""Get the goodbye line from the active skin."""
try:
return get_active_skin().get_branding("goodbye", fallback)
except Exception:
return fallback
def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
"""Return prompt_toolkit style overrides derived from the active skin.
These are layered on top of the CLI's base TUI style so /skin can refresh
the live prompt_toolkit UI immediately without rebuilding the app.
"""
try:
skin = get_active_skin()
except Exception:
return {}
prompt = skin.get_color("prompt", "#FFF8DC")
input_rule = skin.get_color("input_rule", "#CD7F32")
title = skin.get_color("banner_title", "#FFD700")
text = skin.get_color("banner_text", prompt)
dim = skin.get_color("banner_dim", "#555555")
label = skin.get_color("ui_label", title)
warn = skin.get_color("ui_warn", "#FF8C00")
error = skin.get_color("ui_error", "#FF6B6B")
return {
"input-area": prompt,
"placeholder": f"{dim} italic",
"prompt": prompt,
"prompt-working": f"{dim} italic",
"hint": f"{dim} italic",
"input-rule": input_rule,
"image-badge": f"{label} bold",
"completion-menu": f"bg:#1a1a2e {text}",
"completion-menu.completion": f"bg:#1a1a2e {text}",
"completion-menu.completion.current": f"bg:#333355 {title}",
"completion-menu.meta.completion": f"bg:#1a1a2e {dim}",
"completion-menu.meta.completion.current": f"bg:#333355 {label}",
"clarify-border": input_rule,
"clarify-title": f"{title} bold",
"clarify-question": f"{text} bold",
"clarify-choice": dim,
"clarify-selected": f"{title} bold",
"clarify-active-other": f"{title} italic",
"clarify-countdown": input_rule,
"sudo-prompt": f"{error} bold",
"sudo-border": input_rule,
"sudo-title": f"{error} bold",
"sudo-text": text,
"approval-border": input_rule,
"approval-title": f"{warn} bold",
"approval-desc": f"{text} bold",
"approval-cmd": f"{dim} italic",
"approval-choice": dim,
"approval-selected": f"{title} bold",
}

View file

@ -267,8 +267,6 @@ class SessionDB:
if not title:
return None
import re
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
# normalized to spaces by the whitespace collapsing step below
@ -373,7 +371,6 @@ class SessionDB:
Strips any existing " #N" suffix to find the base name, then finds
the highest existing number and increments.
"""
import re
# Strip existing #N suffix to find the true base
match = re.match(r'^(.*?) #(\d+)$', base_title)
if match:

View file

@ -100,6 +100,7 @@ from agent.trajectory import (
convert_scratchpad_to_think, has_incomplete_scratchpad,
save_trajectory as _save_trajectory_to_file,
)
from utils import atomic_json_write
HONCHO_TOOL_NAMES = {
"honcho_context",
@ -1398,8 +1399,12 @@ class AIAgent:
"messages": cleaned,
}
with open(self.session_log_file, "w", encoding="utf-8") as f:
json.dump(entry, f, indent=2, ensure_ascii=False, default=str)
atomic_json_write(
self.session_log_file,
entry,
indent=2,
default=str,
)
except Exception as e:
if self.verbose_logging:

View file

@ -1,5 +1,6 @@
"""Shared fixtures for the hermes-agent test suite."""
import asyncio
import os
import signal
import sys
@ -59,6 +60,39 @@ def mock_config():
def _timeout_handler(signum, frame):
raise TimeoutError("Test exceeded 30 second timeout")
@pytest.fixture(autouse=True)
def _ensure_current_event_loop(request):
"""Provide a default event loop for sync tests that call get_event_loop().
Python 3.11+ no longer guarantees a current loop for plain synchronous tests.
A number of gateway tests still use asyncio.get_event_loop().run_until_complete(...).
Ensure they always have a usable loop without interfering with pytest-asyncio's
own loop management for @pytest.mark.asyncio tests.
"""
if request.node.get_closest_marker("asyncio") is not None:
yield
return
try:
loop = asyncio.get_event_loop_policy().get_event_loop()
except RuntimeError:
loop = None
created = loop is None or loop.is_closed()
if created:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
yield
finally:
if created and loop is not None:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
@pytest.fixture(autouse=True)
def _enforce_test_timeout():
"""Kill any individual test that takes longer than 30 seconds."""

View file

@ -297,7 +297,9 @@ class TestScreenshotCleanup:
def test_cleanup_removes_old_screenshots(self, tmp_path):
"""_cleanup_old_screenshots should remove files older than max_age_hours."""
import time
from tools.browser_tool import _cleanup_old_screenshots
from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir
_last_screenshot_cleanup_by_dir.clear()
# Create a "fresh" file
fresh = tmp_path / "browser_screenshot_fresh.png"
@ -314,10 +316,32 @@ class TestScreenshotCleanup:
assert fresh.exists(), "Fresh screenshot should not be removed"
assert not old.exists(), "Old screenshot should be removed"
def test_cleanup_is_throttled_per_directory(self, tmp_path):
import time
from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir
_last_screenshot_cleanup_by_dir.clear()
old = tmp_path / "browser_screenshot_old.png"
old.write_bytes(b"old")
old_time = time.time() - (25 * 3600)
os.utime(str(old), (old_time, old_time))
_cleanup_old_screenshots(tmp_path, max_age_hours=24)
assert not old.exists()
old.write_bytes(b"old-again")
os.utime(str(old), (old_time, old_time))
_cleanup_old_screenshots(tmp_path, max_age_hours=24)
assert old.exists(), "Repeated cleanup should be skipped while throttled"
def test_cleanup_ignores_non_screenshot_files(self, tmp_path):
"""Only files matching browser_screenshot_*.png should be cleaned."""
import time
from tools.browser_tool import _cleanup_old_screenshots
from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir
_last_screenshot_cleanup_by_dir.clear()
other_file = tmp_path / "important_data.txt"
other_file.write_bytes(b"keep me")
@ -330,11 +354,13 @@ class TestScreenshotCleanup:
def test_cleanup_handles_empty_dir(self, tmp_path):
"""Cleanup should not fail on empty directory."""
from tools.browser_tool import _cleanup_old_screenshots
from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir
_last_screenshot_cleanup_by_dir.clear()
_cleanup_old_screenshots(tmp_path, max_age_hours=24) # Should not raise
def test_cleanup_handles_nonexistent_dir(self):
"""Cleanup should not fail if directory doesn't exist."""
from pathlib import Path
from tools.browser_tool import _cleanup_old_screenshots
from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir
_last_screenshot_cleanup_by_dir.clear()
_cleanup_old_screenshots(Path("/nonexistent/dir"), max_age_hours=24) # Should not raise

View file

@ -0,0 +1,165 @@
"""Regression tests for interactive setup provider/model persistence."""
from __future__ import annotations
from hermes_cli.config import load_config, save_config, save_env_value
from hermes_cli.setup import setup_model_provider
def _read_env(home):
env_path = home / ".env"
data = {}
if not env_path.exists():
return data
for line in env_path.read_text().splitlines():
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
data[k] = v
return data
def _clear_provider_env(monkeypatch):
for key in (
"HERMES_INFERENCE_PROVIDER",
"OPENAI_BASE_URL",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"GLM_API_KEY",
"KIMI_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"ANTHROPIC_TOKEN",
"ANTHROPIC_API_KEY",
):
monkeypatch.delenv(key, raising=False)
def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch):
"""Keep-current custom should not fall through to the generic model menu."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
config = load_config()
config["model"] = {
"default": "custom/model",
"provider": "custom",
"base_url": "https://example.invalid/v1",
}
save_config(config)
calls = {"count": 0}
def fake_prompt_choice(_question, choices, default=0):
calls["count"] += 1
if calls["count"] == 1:
assert choices[-1] == "Keep current (Custom: https://example.invalid/v1)"
return len(choices) - 1
raise AssertionError("Model menu should not appear for keep-current custom")
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
setup_model_provider(config)
save_config(config)
reloaded = load_config()
assert reloaded["model"]["provider"] == "custom"
assert reloaded["model"]["default"] == "custom/model"
assert reloaded["model"]["base_url"] == "https://example.invalid/v1"
assert calls["count"] == 1
def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch):
"""Keep-current should respect config-backed providers, not fall back to OpenRouter."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
config = load_config()
config["model"] = {
"default": "claude-opus-4-6",
"provider": "anthropic",
}
save_config(config)
captured = {"provider_choices": None, "model_choices": None}
calls = {"count": 0}
def fake_prompt_choice(_question, choices, default=0):
calls["count"] += 1
if calls["count"] == 1:
captured["provider_choices"] = list(choices)
assert choices[-1] == "Keep current (Anthropic)"
return len(choices) - 1
if calls["count"] == 2:
captured["model_choices"] = list(choices)
return len(choices) - 1 # keep current model
raise AssertionError("Unexpected extra prompt_choice call")
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: [])
setup_model_provider(config)
save_config(config)
assert captured["provider_choices"] is not None
assert captured["model_choices"] is not None
assert captured["model_choices"][0] == "claude-opus-4-6"
assert "anthropic/claude-opus-4.6 (recommended)" not in captured["model_choices"]
assert calls["count"] == 2
def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch):
"""Switching from custom to Codex should clear custom endpoint overrides."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1")
save_env_value("OPENAI_API_KEY", "sk-custom")
save_env_value("OPENROUTER_API_KEY", "sk-or")
config = load_config()
config["model"] = {
"default": "custom/model",
"provider": "custom",
"base_url": "https://example.invalid/v1",
}
save_config(config)
picks = iter([1, 0])
monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: next(picks))
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "codex-access-token",
},
)
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda **kwargs: ["openai/gpt-5.3-codex", "openai/gpt-5-codex-mini"],
)
setup_model_provider(config)
save_config(config)
env = _read_env(tmp_path)
reloaded = load_config()
assert env.get("OPENAI_BASE_URL") == ""
assert env.get("OPENAI_API_KEY") == ""
assert reloaded["model"]["provider"] == "openai-codex"
assert reloaded["model"]["default"] == "openai/gpt-5.3-codex"
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"

View file

@ -60,6 +60,9 @@ class TestBuiltinSkins:
assert skin.name == "ares"
assert skin.tool_prefix == "╎"
assert skin.get_color("banner_border") == "#9F1C1C"
assert skin.get_color("response_border") == "#C7A96B"
assert skin.get_color("session_label") == "#C7A96B"
assert skin.get_color("session_border") == "#6E584B"
assert skin.get_branding("agent_name") == "Ares Agent"
def test_ares_has_spinner_customization(self):
@ -230,3 +233,82 @@ class TestDisplayIntegration:
from agent.display import get_cute_tool_message
msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5)
assert msg.startswith("┊")
class TestCliBrandingHelpers:
def test_active_prompt_symbol_default(self):
from hermes_cli.skin_engine import get_active_prompt_symbol
assert get_active_prompt_symbol() == "❯ "
def test_active_prompt_symbol_ares(self):
from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol
set_active_skin("ares")
assert get_active_prompt_symbol() == "⚔ ❯ "
def test_active_help_header_ares(self):
from hermes_cli.skin_engine import set_active_skin, get_active_help_header
set_active_skin("ares")
assert get_active_help_header() == "(⚔) Available Commands"
def test_active_goodbye_ares(self):
from hermes_cli.skin_engine import set_active_skin, get_active_goodbye
set_active_skin("ares")
assert get_active_goodbye() == "Farewell, warrior! ⚔"
def test_prompt_toolkit_style_overrides_cover_tui_classes(self):
from hermes_cli.skin_engine import set_active_skin, get_prompt_toolkit_style_overrides
set_active_skin("ares")
overrides = get_prompt_toolkit_style_overrides()
required = {
"input-area",
"placeholder",
"prompt",
"prompt-working",
"hint",
"input-rule",
"image-badge",
"completion-menu",
"completion-menu.completion",
"completion-menu.completion.current",
"completion-menu.meta.completion",
"completion-menu.meta.completion.current",
"clarify-border",
"clarify-title",
"clarify-question",
"clarify-choice",
"clarify-selected",
"clarify-active-other",
"clarify-countdown",
"sudo-prompt",
"sudo-border",
"sudo-title",
"sudo-text",
"approval-border",
"approval-title",
"approval-desc",
"approval-cmd",
"approval-choice",
"approval-selected",
}
assert required.issubset(overrides.keys())
def test_prompt_toolkit_style_overrides_use_skin_colors(self):
from hermes_cli.skin_engine import (
set_active_skin,
get_active_skin,
get_prompt_toolkit_style_overrides,
)
set_active_skin("ares")
skin = get_active_skin()
overrides = get_prompt_toolkit_style_overrides()
assert overrides["prompt"] == skin.get_color("prompt")
assert overrides["input-rule"] == skin.get_color("input_rule")
assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold"
assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold"
assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold"

View file

@ -97,6 +97,17 @@ class TestAtomicJsonWrite:
text = target.read_text()
assert ' "a"' in text # 4-space indent
def test_accepts_json_dump_default_hook(self, tmp_path):
class CustomValue:
def __str__(self):
return "custom-value"
target = tmp_path / "custom_default.json"
atomic_json_write(target, {"value": CustomValue()}, default=str)
result = json.loads(target.read_text(encoding="utf-8"))
assert result == {"value": "custom-value"}
def test_unicode_content(self, tmp_path):
target = tmp_path / "unicode.json"
data = {"emoji": "🎉", "japanese": "日本語"}

View file

@ -0,0 +1,95 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI, _rich_text_from_ansi
from hermes_cli.skin_engine import get_active_skin, set_active_skin
def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI)
cli._sudo_state = None
cli._secret_state = None
cli._approval_state = None
cli._clarify_state = None
cli._clarify_freetext = False
cli._command_running = False
cli._agent_running = False
cli._command_spinner_frame = lambda: "⟳"
cli._tui_style_base = {
"prompt": "#fff",
"input-area": "#fff",
"input-rule": "#aaa",
"prompt-working": "#888 italic",
}
cli._app = SimpleNamespace(style=None)
cli._invalidate = MagicMock()
return cli
class TestCliSkinPromptIntegration:
def test_default_prompt_fragments_use_default_symbol(self):
cli = _make_cli_stub()
set_active_skin("default")
assert cli._get_tui_prompt_fragments() == [("class:prompt", "❯ ")]
def test_ares_prompt_fragments_use_skin_symbol(self):
cli = _make_cli_stub()
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ❯ ")]
def test_secret_prompt_fragments_preserve_secret_state(self):
cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()}
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()}
with patch("hermes_cli.skin_engine.get_active_prompt_symbol", return_value="⚔ "):
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
def test_build_tui_style_dict_uses_skin_overrides(self):
cli = _make_cli_stub()
set_active_skin("ares")
skin = get_active_skin()
style_dict = cli._build_tui_style_dict()
assert style_dict["prompt"] == skin.get_color("prompt")
assert style_dict["input-rule"] == skin.get_color("input_rule")
assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic"
assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold"
def test_apply_tui_skin_style_updates_running_app(self):
cli = _make_cli_stub()
set_active_skin("ares")
assert cli._apply_tui_skin_style() is True
assert cli._app.style is not None
cli._invalidate.assert_called_once_with(min_interval=0.0)
def test_handle_skin_command_refreshes_live_tui(self, capsys):
cli = _make_cli_stub()
with patch("cli.save_config_value", return_value=True):
cli._handle_skin_command("/skin ares")
output = capsys.readouterr().out
assert "Skin set to: ares (saved)" in output
assert "Prompt + TUI colors updated." in output
assert cli._app.style is not None
class TestAnsiRichTextHelper:
def test_preserves_literal_brackets(self):
text = _rich_text_from_ansi("[notatag] literal")
assert text.plain == "[notatag] literal"
def test_strips_ansi_but_keeps_plain_text(self):
text = _rich_text_from_ansi("\x1b[31mred\x1b[0m")
assert text.plain == "red"

View file

@ -1928,6 +1928,24 @@ class TestSafeWriter:
assert inner.getvalue() == "test"
class TestSaveSessionLogAtomicWrite:
def test_uses_shared_atomic_json_helper(self, agent, tmp_path):
agent.session_log_file = tmp_path / "session.json"
messages = [{"role": "user", "content": "hello"}]
with patch("run_agent.atomic_json_write", create=True) as mock_atomic_write:
agent._save_session_log(messages)
mock_atomic_write.assert_called_once()
call_args = mock_atomic_write.call_args
assert call_args.args[0] == agent.session_log_file
payload = call_args.args[1]
assert payload["session_id"] == agent.session_id
assert payload["messages"] == messages
assert call_args.kwargs["indent"] == 2
assert call_args.kwargs["default"] is str
# ===================================================================
# Anthropic adapter integration fixes
# ===================================================================

View file

@ -67,6 +67,12 @@ from agent.auxiliary_client import call_llm
logger = logging.getLogger(__name__)
# Standard PATH entries for environments with minimal PATH (e.g. systemd services)
_SANE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Throttle screenshot cleanup to avoid repeated full directory scans.
_last_screenshot_cleanup_by_dir: dict[str, float] = {}
# ============================================================================
# Configuration
# ============================================================================
@ -846,7 +852,6 @@ def _run_browser_command(
browser_env = {**os.environ}
# Ensure PATH includes standard dirs (systemd services may have minimal PATH)
_SANE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
if "/usr/bin" not in browser_env.get("PATH", "").split(":"):
browser_env["PATH"] = f"{browser_env.get('PATH', '')}:{_SANE_PATH}"
browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir
@ -1578,8 +1583,17 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24):
"""Remove browser screenshots older than max_age_hours to prevent disk bloat."""
import time
"""Remove browser screenshots older than max_age_hours to prevent disk bloat.
Throttled to run at most once per hour per directory to avoid repeated
scans on screenshot-heavy workflows.
"""
key = str(screenshots_dir)
now = time.time()
if now - _last_screenshot_cleanup_by_dir.get(key, 0.0) < 3600:
return
_last_screenshot_cleanup_by_dir[key] = now
try:
cutoff = time.time() - (max_age_hours * 3600)
for f in screenshots_dir.glob("browser_screenshot_*.png"):

View file

@ -9,17 +9,25 @@ from typing import Any, Union
import yaml
def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) -> None:
def atomic_json_write(
path: Union[str, Path],
data: Any,
*,
indent: int = 2,
**dump_kwargs: Any,
) -> None:
"""Write JSON data to a file atomically.
Uses temp file + fsync + os.replace to ensure the target file is never
left in a partially-written state. If the process crashes mid-write,
left in a partially-written state. If the process crashes mid-write,
the previous version of the file remains intact.
Args:
path: Target file path (will be created or overwritten).
data: JSON-serializable data to write.
indent: JSON indentation (default 2).
**dump_kwargs: Additional keyword args forwarded to json.dump(), such
as default=str for non-native types.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
@ -31,7 +39,13 @@ def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) ->
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent, ensure_ascii=False)
json.dump(
data,
f,
indent=indent,
ensure_ascii=False,
**dump_kwargs,
)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)