Merge pull request #1279 from NousResearch/hermes/hermes-315847fd
refactor: salvage adapter and CLI cleanup from PR #939
This commit is contained in:
@@ -68,7 +68,7 @@ def _oneline(text: str) -> str:
|
|||||||
return " ".join(text.split())
|
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."""
|
"""Build a short preview of a tool call's primary argument for display."""
|
||||||
if not args:
|
if not args:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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)
|
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))
|
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(
|
async def send_voice(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -274,36 +296,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
metadata: Optional[Dict[str, Any]] = None,
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Send audio as a Discord file attachment."""
|
"""Send audio as a Discord file attachment."""
|
||||||
if not self._client:
|
|
||||||
return SendResult(success=False, error="Not connected")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import io
|
return await self._send_file_attachment(chat_id, audio_path, caption)
|
||||||
|
except FileNotFoundError:
|
||||||
channel = self._client.get_channel(int(chat_id))
|
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
|
||||||
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))
|
|
||||||
|
|
||||||
except Exception as e: # pragma: no cover - defensive logging
|
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)
|
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(
|
async def send_image_file(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -313,34 +313,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
metadata: Optional[Dict[str, Any]] = None,
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Send a local image file natively as a Discord file attachment."""
|
"""Send a local image file natively as a Discord file attachment."""
|
||||||
if not self._client:
|
|
||||||
return SendResult(success=False, error="Not connected")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import io
|
return await self._send_file_attachment(chat_id, image_path, caption)
|
||||||
|
except FileNotFoundError:
|
||||||
channel = self._client.get_channel(int(chat_id))
|
return SendResult(success=False, error=f"Image file not found: {image_path}")
|
||||||
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))
|
|
||||||
|
|
||||||
except Exception as e: # pragma: no cover - defensive logging
|
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)
|
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(
|
async def send_image(
|
||||||
self,
|
self,
|
||||||
@@ -528,7 +507,22 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
"""
|
"""
|
||||||
# Discord markdown is fairly standard, no special escaping needed
|
# Discord markdown is fairly standard, no special escaping needed
|
||||||
return content
|
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:
|
def _register_slash_commands(self) -> None:
|
||||||
"""Register Discord slash commands on the command tree."""
|
"""Register Discord slash commands on the command tree."""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
@@ -551,34 +545,16 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
@tree.command(name="new", description="Start a new conversation")
|
@tree.command(name="new", description="Start a new conversation")
|
||||||
async def slash_new(interaction: discord.Interaction):
|
async def slash_new(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/reset", "New conversation started~")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="reset", description="Reset your Hermes session")
|
@tree.command(name="reset", description="Reset your Hermes session")
|
||||||
async def slash_reset(interaction: discord.Interaction):
|
async def slash_reset(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/reset", "Session reset~")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="model", description="Show or change the model")
|
@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.")
|
@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 = ""):
|
async def slash_model(interaction: discord.Interaction, name: str = ""):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, f"/model {name}".strip())
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="reasoning", description="Show or change reasoning effort")
|
@tree.command(name="reasoning", description="Show or change reasoning effort")
|
||||||
@discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
|
@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")
|
@tree.command(name="personality", description="Set a personality")
|
||||||
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
|
||||||
async def slash_personality(interaction: discord.Interaction, name: str = ""):
|
async def slash_personality(interaction: discord.Interaction, name: str = ""):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, f"/personality {name}".strip())
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="retry", description="Retry your last message")
|
@tree.command(name="retry", description="Retry your last message")
|
||||||
async def slash_retry(interaction: discord.Interaction):
|
async def slash_retry(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/retry", "Retrying~")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="undo", description="Remove the last exchange")
|
@tree.command(name="undo", description="Remove the last exchange")
|
||||||
async def slash_undo(interaction: discord.Interaction):
|
async def slash_undo(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/undo")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="status", description="Show Hermes session status")
|
@tree.command(name="status", description="Show Hermes session status")
|
||||||
async def slash_status(interaction: discord.Interaction):
|
async def slash_status(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/status", "Status sent~")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="sethome", description="Set this chat as the home channel")
|
@tree.command(name="sethome", description="Set this chat as the home channel")
|
||||||
async def slash_sethome(interaction: discord.Interaction):
|
async def slash_sethome(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/sethome")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="stop", description="Stop the running Hermes agent")
|
@tree.command(name="stop", description="Stop the running Hermes agent")
|
||||||
async def slash_stop(interaction: discord.Interaction):
|
async def slash_stop(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/stop", "Stop requested~")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="compress", description="Compress conversation context")
|
@tree.command(name="compress", description="Compress conversation context")
|
||||||
async def slash_compress(interaction: discord.Interaction):
|
async def slash_compress(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/compress")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="title", description="Set or show the session title")
|
@tree.command(name="title", description="Set or show the session title")
|
||||||
@discord.app_commands.describe(name="Session title. Leave empty to show current.")
|
@discord.app_commands.describe(name="Session title. Leave empty to show current.")
|
||||||
async def slash_title(interaction: discord.Interaction, name: str = ""):
|
async def slash_title(interaction: discord.Interaction, name: str = ""):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, f"/title {name}".strip())
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="resume", description="Resume a previously-named session")
|
@tree.command(name="resume", description="Resume a previously-named session")
|
||||||
@discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
|
@discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
|
||||||
async def slash_resume(interaction: discord.Interaction, name: str = ""):
|
async def slash_resume(interaction: discord.Interaction, name: str = ""):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, f"/resume {name}".strip())
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="usage", description="Show token usage for this session")
|
@tree.command(name="usage", description="Show token usage for this session")
|
||||||
async def slash_usage(interaction: discord.Interaction):
|
async def slash_usage(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/usage")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="provider", description="Show available providers")
|
@tree.command(name="provider", description="Show available providers")
|
||||||
async def slash_provider(interaction: discord.Interaction):
|
async def slash_provider(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/provider")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="help", description="Show available commands")
|
@tree.command(name="help", description="Show available commands")
|
||||||
async def slash_help(interaction: discord.Interaction):
|
async def slash_help(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/help")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="insights", description="Show usage insights and analytics")
|
@tree.command(name="insights", description="Show usage insights and analytics")
|
||||||
@discord.app_commands.describe(days="Number of days to analyze (default: 7)")
|
@discord.app_commands.describe(days="Number of days to analyze (default: 7)")
|
||||||
async def slash_insights(interaction: discord.Interaction, days: int = 7):
|
async def slash_insights(interaction: discord.Interaction, days: int = 7):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, f"/insights {days}")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="reload-mcp", description="Reload MCP servers from config")
|
@tree.command(name="reload-mcp", description="Reload MCP servers from config")
|
||||||
async def slash_reload_mcp(interaction: discord.Interaction):
|
async def slash_reload_mcp(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/reload-mcp")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
@tree.command(name="update", description="Update Hermes Agent to the latest version")
|
||||||
async def slash_update(interaction: discord.Interaction):
|
async def slash_update(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await self._run_simple_slash(interaction, "/update", "Update initiated~")
|
||||||
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)
|
|
||||||
|
|
||||||
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
|
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
|
||||||
@discord.app_commands.describe(
|
@discord.app_commands.describe(
|
||||||
|
|||||||
@@ -260,6 +260,30 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
return metadata["thread_ts"]
|
return metadata["thread_ts"]
|
||||||
return reply_to
|
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 -----
|
# ----- Markdown → mrkdwn conversion -----
|
||||||
|
|
||||||
def format_message(self, content: str) -> str:
|
def format_message(self, content: str) -> str:
|
||||||
@@ -417,23 +441,10 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
metadata: Optional[Dict[str, Any]] = None,
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Send a local image file to Slack by uploading it."""
|
"""Send a local image file to Slack by uploading it."""
|
||||||
if not self._app:
|
|
||||||
return SendResult(success=False, error="Not connected")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import os
|
return await self._upload_file(chat_id, image_path, caption, reply_to, metadata)
|
||||||
if not os.path.exists(image_path):
|
except FileNotFoundError:
|
||||||
return SendResult(success=False, error=f"Image file not found: {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)
|
|
||||||
|
|
||||||
except Exception as e: # pragma: no cover - defensive logging
|
except Exception as e: # pragma: no cover - defensive logging
|
||||||
logger.error(
|
logger.error(
|
||||||
"[%s] Failed to send local Slack image %s: %s",
|
"[%s] Failed to send local Slack image %s: %s",
|
||||||
@@ -497,19 +508,10 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
metadata: Optional[Dict[str, Any]] = None,
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
"""Send an audio file to Slack."""
|
"""Send an audio file to Slack."""
|
||||||
if not self._app:
|
|
||||||
return SendResult(success=False, error="Not connected")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self._app.client.files_upload_v2(
|
return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata)
|
||||||
channel=chat_id,
|
except FileNotFoundError:
|
||||||
file=audio_path,
|
return SendResult(success=False, error=f"Audio file not found: {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)
|
|
||||||
|
|
||||||
except Exception as e: # pragma: no cover - defensive logging
|
except Exception as e: # pragma: no cover - defensive logging
|
||||||
logger.error(
|
logger.error(
|
||||||
"[Slack] Failed to send audio file %s: %s",
|
"[Slack] Failed to send audio file %s: %s",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
|
|||||||
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
|
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time as _time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from hermes_cli import __version__, __release_date__
|
from hermes_cli import __version__, __release_date__
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
@@ -76,6 +78,24 @@ from hermes_constants import OPENROUTER_BASE_URL
|
|||||||
logger = logging.getLogger(__name__)
|
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:
|
def _has_any_provider_configured() -> bool:
|
||||||
"""Check if at least one inference provider is usable."""
|
"""Check if at least one inference provider is usable."""
|
||||||
from hermes_cli.config import get_env_path, get_hermes_home
|
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 curses-based picker first
|
||||||
try:
|
try:
|
||||||
import curses
|
import curses
|
||||||
import time as _time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
result_holder = [None]
|
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):
|
def _format_row(s, max_x):
|
||||||
"""Format a session row for display."""
|
"""Format a session row for display."""
|
||||||
title = (s.get("title") or "").strip()
|
title = (s.get("title") or "").strip()
|
||||||
@@ -352,26 +353,6 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback: numbered list (Windows without curses, etc.)
|
# 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")
|
print("\n Browse sessions (enter number to resume, q to cancel)\n")
|
||||||
for i, s in enumerate(sessions):
|
for i, s in enumerate(sessions):
|
||||||
title = (s.get("title") or "").strip()
|
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"]
|
label = title or preview or s["id"]
|
||||||
if len(label) > 50:
|
if len(label) > 50:
|
||||||
label = label[:47] + "..."
|
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]
|
src = s.get("source", "")[:6]
|
||||||
print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}")
|
print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}")
|
||||||
|
|
||||||
@@ -2846,30 +2827,6 @@ For more help on a command:
|
|||||||
if not sessions:
|
if not sessions:
|
||||||
print("No sessions found.")
|
print("No sessions found.")
|
||||||
return
|
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)
|
has_titles = any(s.get("title") for s in sessions)
|
||||||
if has_titles:
|
if has_titles:
|
||||||
print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
||||||
|
|||||||
@@ -267,8 +267,6 @@ class SessionDB:
|
|||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
|
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
|
||||||
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
|
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
|
||||||
# normalized to spaces by the whitespace collapsing step below
|
# 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
|
Strips any existing " #N" suffix to find the base name, then finds
|
||||||
the highest existing number and increments.
|
the highest existing number and increments.
|
||||||
"""
|
"""
|
||||||
import re
|
|
||||||
# Strip existing #N suffix to find the true base
|
# Strip existing #N suffix to find the true base
|
||||||
match = re.match(r'^(.*?) #(\d+)$', base_title)
|
match = re.match(r'^(.*?) #(\d+)$', base_title)
|
||||||
if match:
|
if match:
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ class TestScreenshotCleanup:
|
|||||||
def test_cleanup_removes_old_screenshots(self, tmp_path):
|
def test_cleanup_removes_old_screenshots(self, tmp_path):
|
||||||
"""_cleanup_old_screenshots should remove files older than max_age_hours."""
|
"""_cleanup_old_screenshots should remove files older than max_age_hours."""
|
||||||
import time
|
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
|
# Create a "fresh" file
|
||||||
fresh = tmp_path / "browser_screenshot_fresh.png"
|
fresh = tmp_path / "browser_screenshot_fresh.png"
|
||||||
@@ -314,10 +316,32 @@ class TestScreenshotCleanup:
|
|||||||
assert fresh.exists(), "Fresh screenshot should not be removed"
|
assert fresh.exists(), "Fresh screenshot should not be removed"
|
||||||
assert not old.exists(), "Old screenshot should 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):
|
def test_cleanup_ignores_non_screenshot_files(self, tmp_path):
|
||||||
"""Only files matching browser_screenshot_*.png should be cleaned."""
|
"""Only files matching browser_screenshot_*.png should be cleaned."""
|
||||||
import time
|
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 = tmp_path / "important_data.txt"
|
||||||
other_file.write_bytes(b"keep me")
|
other_file.write_bytes(b"keep me")
|
||||||
@@ -330,11 +354,13 @@ class TestScreenshotCleanup:
|
|||||||
|
|
||||||
def test_cleanup_handles_empty_dir(self, tmp_path):
|
def test_cleanup_handles_empty_dir(self, tmp_path):
|
||||||
"""Cleanup should not fail on empty directory."""
|
"""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
|
_cleanup_old_screenshots(tmp_path, max_age_hours=24) # Should not raise
|
||||||
|
|
||||||
def test_cleanup_handles_nonexistent_dir(self):
|
def test_cleanup_handles_nonexistent_dir(self):
|
||||||
"""Cleanup should not fail if directory doesn't exist."""
|
"""Cleanup should not fail if directory doesn't exist."""
|
||||||
from pathlib import Path
|
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
|
_cleanup_old_screenshots(Path("/nonexistent/dir"), max_age_hours=24) # Should not raise
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ from agent.auxiliary_client import call_llm
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -846,7 +852,6 @@ def _run_browser_command(
|
|||||||
|
|
||||||
browser_env = {**os.environ}
|
browser_env = {**os.environ}
|
||||||
# Ensure PATH includes standard dirs (systemd services may have minimal PATH)
|
# 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(":"):
|
if "/usr/bin" not in browser_env.get("PATH", "").split(":"):
|
||||||
browser_env["PATH"] = f"{browser_env.get('PATH', '')}:{_SANE_PATH}"
|
browser_env["PATH"] = f"{browser_env.get('PATH', '')}:{_SANE_PATH}"
|
||||||
browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir
|
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):
|
def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24):
|
||||||
"""Remove browser screenshots older than max_age_hours to prevent disk bloat."""
|
"""Remove browser screenshots older than max_age_hours to prevent disk bloat.
|
||||||
import time
|
|
||||||
|
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:
|
try:
|
||||||
cutoff = time.time() - (max_age_hours * 3600)
|
cutoff = time.time() - (max_age_hours * 3600)
|
||||||
for f in screenshots_dir.glob("browser_screenshot_*.png"):
|
for f in screenshots_dir.glob("browser_screenshot_*.png"):
|
||||||
|
|||||||
Reference in New Issue
Block a user