diff --git a/cli.py b/cli.py index c4fcdacab1..7eeb15425c 100755 --- a/cli.py +++ b/cli.py @@ -560,6 +560,7 @@ COMMANDS = { "/personality": "Set a predefined personality", "/clear": "Clear screen and reset conversation (fresh start)", "/history": "Show conversation history", + "/new": "Start a new conversation (reset history)", "/reset": "Reset conversation only (keep screen)", "/retry": "Retry the last message (resend to agent)", "/undo": "Remove the last user/assistant exchange", @@ -1399,7 +1400,7 @@ class HermesCLI: print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") elif cmd_lower == "/history": self.show_history() - elif cmd_lower == "/reset": + elif cmd_lower in ("/reset", "/new"): self.reset_conversation() elif cmd_lower.startswith("/model"): # Use original case so model names like "Anthropic/Claude-Opus-4" are preserved diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index d3861798c8..5dac3599f3 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -366,6 +366,16 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: pass + @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: + pass + @tree.command(name="reset", description="Reset your Hermes session") async def slash_reset(interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) @@ -376,6 +386,48 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: pass + @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: + pass + + @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: + pass + + @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: + pass + + @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: + pass + @tree.command(name="status", description="Show Hermes session status") async def slash_status(interaction: discord.Interaction): await interaction.response.defer(ephemeral=True) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index a0bd83c876..865d579799 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -327,13 +327,19 @@ class SlackAdapter(BasePlatformAdapter): user_id = command.get("user_id", "") channel_id = command.get("channel_id", "") - # Map common slash subcommands to gateway commands - if text in ("new", "reset"): - text = "/reset" - elif text == "status": - text = "/status" - elif text == "stop": - text = "/stop" + # Map subcommands to gateway commands + subcommand_map = { + "new": "/reset", "reset": "/reset", + "status": "/status", "stop": "/stop", + "help": "/help", + "model": "/model", "personality": "/personality", + "retry": "/retry", "undo": "/undo", + } + first_word = text.split()[0] if text else "" + if first_word in subcommand_map: + # Preserve arguments after the subcommand + rest = text[len(first_word):].strip() + text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] elif text: pass # Treat as a regular question else: diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 7e0ff1b287..db497b7bd2 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -101,6 +101,23 @@ class TelegramAdapter(BasePlatformAdapter): await self._app.start() await self._app.updater.start_polling(allowed_updates=Update.ALL_TYPES) + # Register bot commands so Telegram shows a hint menu when users type / + try: + from telegram import BotCommand + await self._bot.set_my_commands([ + BotCommand("new", "Start a new conversation"), + BotCommand("reset", "Reset conversation history"), + BotCommand("model", "Show or change the model"), + BotCommand("personality", "Set a personality"), + BotCommand("retry", "Retry your last message"), + BotCommand("undo", "Remove the last exchange"), + BotCommand("status", "Show session info"), + BotCommand("stop", "Stop the running agent"), + BotCommand("help", "Show available commands"), + ]) + except Exception as e: + print(f"[{self.name}] Could not register command menu: {e}") + self._running = True print(f"[{self.name}] Connected and polling for updates") return True diff --git a/gateway/run.py b/gateway/run.py index e0af6026aa..bacdb29962 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -318,12 +318,27 @@ class GatewayRunner: if command in ["new", "reset"]: return await self._handle_reset_command(event) + if command == "help": + return await self._handle_help_command(event) + if command == "status": return await self._handle_status_command(event) if command == "stop": return await self._handle_stop_command(event) + if command == "model": + return await self._handle_model_command(event) + + if command == "personality": + return await self._handle_personality_command(event) + + if command == "retry": + return await self._handle_retry_command(event) + + if command == "undo": + return await self._handle_undo_command(event) + # Check for pending exec approval responses session_key_preview = f"agent:main:{source.platform.value}:{source.chat_type}:{source.chat_id}" if source.chat_type != "dm" else f"agent:main:{source.platform.value}:dm" if session_key_preview in self._pending_approvals: @@ -587,6 +602,124 @@ class GatewayRunner: else: return "No active task to stop." + async def _handle_help_command(self, event: MessageEvent) -> str: + """Handle /help command - list available commands.""" + return ( + "📖 **Hermes Commands**\n" + "\n" + "`/new` — Start a new conversation\n" + "`/reset` — Reset conversation history\n" + "`/status` — Show session info\n" + "`/stop` — Interrupt the running agent\n" + "`/model [name]` — Show or change the model\n" + "`/personality [name]` — Set a personality\n" + "`/retry` — Retry your last message\n" + "`/undo` — Remove the last exchange\n" + "`/help` — Show this message" + ) + + async def _handle_model_command(self, event: MessageEvent) -> str: + """Handle /model command - show or change the current model.""" + args = event.get_command_args().strip() + current = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") + + if not args: + return f"🤖 **Current model:** `{current}`\n\nTo change: `/model provider/model-name`" + + os.environ["HERMES_MODEL"] = args + return f"🤖 Model changed to `{args}`\n_(takes effect on next message)_" + + async def _handle_personality_command(self, event: MessageEvent) -> str: + """Handle /personality command - list or set a personality.""" + args = event.get_command_args().strip().lower() + + try: + import yaml + config_path = Path.home() / '.hermes' / 'config.yaml' + if config_path.exists(): + with open(config_path, 'r') as f: + config = yaml.safe_load(f) or {} + personalities = config.get("agent", {}).get("personalities", {}) + else: + personalities = {} + except Exception: + personalities = {} + + if not personalities: + return "No personalities configured in `~/.hermes/config.yaml`" + + if not args: + lines = ["🎭 **Available Personalities**\n"] + for name, prompt in personalities.items(): + preview = prompt[:50] + "..." if len(prompt) > 50 else prompt + lines.append(f"• `{name}` — {preview}") + lines.append(f"\nUsage: `/personality `") + return "\n".join(lines) + + if args in personalities: + os.environ["HERMES_PERSONALITY"] = personalities[args] + return f"🎭 Personality set to **{args}**\n_(takes effect on next message)_" + + available = ", ".join(f"`{n}`" for n in personalities.keys()) + return f"Unknown personality: `{args}`\n\nAvailable: {available}" + + async def _handle_retry_command(self, event: MessageEvent) -> str: + """Handle /retry command - re-send the last user message.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(session_entry.session_id) + + # Find the last user message + last_user_msg = None + last_user_idx = None + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "user": + last_user_msg = history[i].get("content", "") + last_user_idx = i + break + + if not last_user_msg: + return "No previous message to retry." + + # Truncate history to before the last user message + truncated = history[:last_user_idx] + session_entry.conversation_history = truncated + + # Re-send by creating a fake text event with the old message + retry_event = MessageEvent( + text=last_user_msg, + message_type=MessageType.TEXT, + source=source, + raw_message=event.raw_message, + ) + + # Let the normal message handler process it + await self._handle_message(retry_event) + return None # Response sent through normal flow + + async def _handle_undo_command(self, event: MessageEvent) -> str: + """Handle /undo command - remove the last user/assistant exchange.""" + source = event.source + session_entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(session_entry.session_id) + + # Find the last user message and remove everything from it onward + last_user_idx = None + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "user": + last_user_idx = i + break + + if last_user_idx is None: + return "Nothing to undo." + + removed_msg = history[last_user_idx].get("content", "") + removed_count = len(history) - last_user_idx + session_entry.conversation_history = history[:last_user_idx] + + preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg + return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\"" + def _set_session_env(self, context: SessionContext) -> None: """Set environment variables for the current session.""" os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value