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
2 changes: 1 addition & 1 deletion agent/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def get_skin_tool_prefix() -> str:
# Tool preview (one-line summary of a tool call's primary argument)
# =========================================================================

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
Expand Down
249 changes: 67 additions & 182 deletions gateway/platforms/discord.py

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,31 +189,42 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:
"""Slack doesn't have a direct typing indicator API for bots."""
pass

async def send_image_file(
async def _upload_file(
self,
chat_id: str,
image_path: str,
file_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a local image file to Slack by uploading it."""
"""Upload a local file to Slack (shared by send_image_file and send_voice)."""
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=reply_to,
)
return SendResult(success=True, raw_response=result)
# Validate file exists before attempting upload
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=reply_to,
)
return SendResult(success=True, raw_response=result)

async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
) -> SendResult:
"""Send a local image file to Slack by uploading it."""
try:
return await self._upload_file(chat_id, image_path, caption, reply_to)
except FileNotFoundError:
return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e:
print(f"[{self.name}] Failed to send local image: {e}")
return await super().send_image_file(chat_id, image_path, caption, reply_to)
Expand Down Expand Up @@ -260,19 +271,8 @@ async def send_voice(
reply_to: Optional[str] = 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=reply_to,
)
return SendResult(success=True, raw_response=result)

return await self._upload_file(chat_id, audio_path, caption, reply_to)
except Exception as e:
return SendResult(success=False, error=str(e))

Expand Down
14 changes: 5 additions & 9 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,6 @@ async def send_voice(
return SendResult(success=False, error="Not connected")

try:
import os
if not os.path.exists(audio_path):
return SendResult(success=False, error=f"Audio file not found: {audio_path}")

with open(audio_path, "rb") as audio_file:
# .ogg files -> send as voice (round playable bubble)
if audio_path.endswith(".ogg") or audio_path.endswith(".opus"):
Expand All @@ -338,6 +334,8 @@ async def send_voice(
message_thread_id=int(_audio_thread) if _audio_thread else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except FileNotFoundError:
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
except Exception as e:
logger.error(
"[%s] Failed to send Telegram voice/audio, falling back to base adapter: %s",
Expand All @@ -358,12 +356,8 @@ async def send_image_file(
"""Send a local image file natively as a Telegram photo."""
if not self._bot:
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}")

with open(image_path, "rb") as image_file:
msg = await self._bot.send_photo(
chat_id=int(chat_id),
Expand All @@ -372,6 +366,8 @@ async def send_image_file(
reply_to_message_id=int(reply_to) if reply_to else None,
)
return SendResult(success=True, message_id=str(msg.message_id))
except FileNotFoundError:
return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e:
logger.error(
"[%s] Failed to send Telegram local image, falling back to base adapter: %s",
Expand Down
85 changes: 22 additions & 63 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,34 @@
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")

import logging
import time as _time
from datetime import datetime

from hermes_cli import __version__
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"
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 _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
Expand Down Expand Up @@ -121,28 +142,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()
Expand Down Expand Up @@ -333,34 +335,14 @@ def _curses_browse(stdscr):
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()
preview = (s.get("preview") or "").strip()
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}")

Expand Down Expand Up @@ -2397,29 +2379,6 @@ def cmd_sessions(args):
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:
Expand Down
3 changes: 0 additions & 3 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,6 @@ def sanitize_title(title: Optional[str]) -> Optional[str]:
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
Expand Down Expand Up @@ -373,7 +371,6 @@ def get_next_title_in_lineage(self, base_title: str) -> str:
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:
Expand Down
17 changes: 14 additions & 3 deletions tools/browser_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@

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"

# ============================================================================
# Configuration
# ============================================================================
Expand Down Expand Up @@ -869,7 +872,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
Expand Down Expand Up @@ -1606,9 +1608,18 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
return json.dumps(error_info, ensure_ascii=False)


_last_screenshot_cleanup = 0.0

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 to avoid per-call directory scans.
"""
global _last_screenshot_cleanup
now = time.time()
if now - _last_screenshot_cleanup < 3600:
return
_last_screenshot_cleanup = now
try:
cutoff = time.time() - (max_age_hours * 3600)
for f in screenshots_dir.glob("browser_screenshot_*.png"):
Expand Down