"""Built-in slash command handlers.""" from __future__ import annotations import asyncio import os import subprocess import sys import time from contextlib import suppress from dataclasses import dataclass from typing import Literal from nanobot import __version__ from nanobot.agent.goal_permission import goal_mutation_permission from nanobot.bus.events import OutboundMessage from nanobot.command.router import CommandContext, CommandRouter from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env from nanobot.utils.workspace_prompts import initialize_workspace_prompt # WebUI protocol contract for how a slash command participates in turn state: # - side_channel: returns control text without starting or ending an agent turn. # - finalize_active_turn: side-channel command that also closes the active UI turn. # - stop_active_turn: cancels the active turn; WebUI may intercept exact submits. # - agent_turn: always enters the normal agent path. # - agent_turn_with_args: no args is side-channel usage; args enter the agent path. CommandLifecycle = Literal[ "side_channel", "finalize_active_turn", "stop_active_turn", "agent_turn", "agent_turn_with_args", ] @dataclass(frozen=True) class BuiltinCommandSpec: command: str title: str description: str icon: str arg_hint: str = "" lifecycle: CommandLifecycle = "side_channel" accepts_args: bool = False def as_dict(self) -> dict[str, str | bool]: return { "command": self.command, "title": self.title, "description": self.description, "icon": self.icon, "arg_hint": self.arg_hint, "lifecycle": self.lifecycle, "accepts_args": self.accepts_args, } BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( BuiltinCommandSpec( "/new", "New chat", "Reset this chat and start a fresh conversation.", "square-pen", lifecycle="finalize_active_turn", ), BuiltinCommandSpec( "/stop", "Stop current task", "Cancel the active agent turn for this chat.", "square", lifecycle="stop_active_turn", ), BuiltinCommandSpec( "/restart", "Restart nanobot", "Restart the bot process.", "rotate-cw", ), BuiltinCommandSpec( "/status", "Show status", "Display runtime, provider, and channel status.", "activity", ), BuiltinCommandSpec( "/model", "Switch model preset", "Show or switch the active model preset.", "brain", "[preset]", accepts_args=True, ), BuiltinCommandSpec( "/history", "Show conversation history", "Print the last N persisted conversation messages.", "history", "[n]", accepts_args=True, ), BuiltinCommandSpec( "/goal", "Start long-running goal", "Tell the agent to treat the request as a long-running goal.", "activity", "", lifecycle="agent_turn_with_args", accepts_args=True, ), BuiltinCommandSpec( "/trigger", "Create named local trigger", "Create a named CLI trigger bound to this chat session.", "zap", "", accepts_args=True, ), BuiltinCommandSpec( "/dream", "Run Dream", "Manually trigger memory consolidation.", "sparkles", ), BuiltinCommandSpec( "/dream-log", "Show Dream log", "Show what the last Dream consolidation changed.", "book-open", accepts_args=True, ), BuiltinCommandSpec( "/dream-restore", "Restore memory", "Revert memory to a previous Dream snapshot.", "undo-2", accepts_args=True, ), BuiltinCommandSpec( "/dream-prompt", "Dream memory", "Tell Dream how to organize this workspace's memory.", "file-text", "[init]", accepts_args=True, ), BuiltinCommandSpec( "/evaluator-prompt", "Heartbeat evaluator", "Customize the heartbeat notification gate prompt for this workspace.", "file-text", "[init]", accepts_args=True, ), BuiltinCommandSpec( "/skill", "List skills", "List all enabled skills available to the agent.", "wrench", ), BuiltinCommandSpec( "/help", "Show help", "List available slash commands.", "circle-help", ), BuiltinCommandSpec( "/pairing", "Manage pairing", "List, approve, deny or revoke pairing requests.", "shield", "[list|approve |deny |revoke ]", accepts_args=True, ), ) def builtin_command_palette() -> list[dict[str, str | bool]]: """Return structured command metadata for UI command palettes.""" return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] async def cmd_stop(ctx: CommandContext) -> OutboundMessage: """Cancel all active tasks and subagents for the session.""" loop = ctx.loop msg = ctx.msg total = await loop._cancel_active_tasks(ctx.key) # Also drain pending queue to prevent mid-turn injection deadlock pending = loop._pending_queues.pop(ctx.key, None) if pending is not None: while not pending.empty(): try: pending.get_nowait() total += 1 except Exception: break content = f"Stopped {total} task(s)." if total else "No active task to stop." return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=dict(msg.metadata or {}) ) async def cmd_restart(ctx: CommandContext) -> OutboundMessage: """Restart the process.""" msg = ctx.msg set_restart_notice_to_env( channel=msg.channel, chat_id=msg.chat_id, metadata=dict(msg.metadata or {}), ) async def _do_restart(): await asyncio.sleep(1) argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:] mode = getattr(ctx.loop, "restart_mode", "auto") or "auto" if mode == "auto": mode = "spawn" if sys.platform == "win32" else "exec" if mode == "exec": os.execv(sys.executable, argv) return if mode == "spawn": kwargs = {} if sys.platform == "win32": kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP subprocess.Popen(argv, **kwargs) os._exit(0) asyncio.create_task(_do_restart()) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="Restarting...", metadata=dict(msg.metadata or {}) ) async def cmd_status(ctx: CommandContext) -> OutboundMessage: """Build an outbound status message for a session.""" loop = ctx.loop session = ctx.session or loop.sessions.get_or_create(ctx.key) runtime = ctx.runtime or loop.llm_runtime() ctx_est = 0 with suppress(Exception): ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens( session, runtime=runtime, ) if ctx_est <= 0: ctx_est = loop._last_usage.get("prompt_tokens", 0) # Fetch web search provider usage (best-effort, never blocks the response) search_usage_text: str | None = None # Never let usage fetch break /status with suppress(Exception): from nanobot.utils.searchusage import fetch_search_usage web_cfg = getattr(loop, "web_config", None) search_cfg = getattr(web_cfg, "search", None) if web_cfg else None if search_cfg is not None: provider = getattr(search_cfg, "provider", "duckduckgo") api_key = getattr(search_cfg, "api_key", "") or None usage = await fetch_search_usage(provider=provider, api_key=api_key) search_usage_text = usage.format() active_tasks = loop._active_tasks.get(ctx.key, []) task_count = sum(1 for t in active_tasks if not t.done()) with suppress(Exception): task_count += loop.subagents.get_running_count_by_session(ctx.key) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=build_status_content( version=__version__, model=runtime.model, start_time=loop._start_time, last_usage=loop._last_usage, context_window_tokens=runtime.context_window_tokens, session_msg_count=len(session.get_history(max_messages=0)), context_tokens_estimate=ctx_est, search_usage_text=search_usage_text, active_task_count=task_count, max_completion_tokens=runtime.generation.max_tokens, ), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) async def cmd_new(ctx: CommandContext) -> OutboundMessage: """Stop active task and start a fresh session.""" loop = ctx.loop await loop._cancel_active_tasks(ctx.key) session = ctx.session or loop.sessions.get_or_create(ctx.key) snapshot = session.messages[session.last_consolidated:] session.clear() loop.sessions.save(session) loop.sessions.invalidate(session.key) if snapshot: runtime = ctx.runtime or loop.llm_runtime() loop._schedule_background( loop.consolidator.archive( snapshot, runtime=runtime, session_key=ctx.key, ) ) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="New session started.", metadata=dict(ctx.msg.metadata or {}) ) def _format_preset_names(names: list[str]) -> str: return ", ".join(f"`{name}`" for name in names) if names else "(none configured)" def _model_preset_names(loop) -> list[str]: names = set(loop.model_presets) names.add("default") return ["default", *sorted(name for name in names if name != "default")] def _active_model_preset_name(loop) -> str: return loop.model_preset or "default" def _command_error_message(exc: Exception) -> str: return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc) def _model_command_status(loop) -> str: names = _model_preset_names(loop) active = _active_model_preset_name(loop) return "\n".join([ "## Model", f"- Current model: `{loop.model}`", f"- Current preset: `{active}`", f"- Available presets: {_format_preset_names(names)}", ]) async def cmd_model(ctx: CommandContext) -> OutboundMessage: """Show or switch model presets.""" loop = ctx.loop args = ctx.args.strip() metadata = {**dict(ctx.msg.metadata or {}), "render_as": "text"} if not args: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=_model_command_status(loop), metadata=metadata, ) parts = args.split() if len(parts) != 1: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="Usage: `/model [preset]`", metadata=metadata, ) name = parts[0] try: runtime = loop.set_model_preset(name) except (KeyError, ValueError) as exc: names = _model_preset_names(loop) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=( f"Could not switch model preset: {_command_error_message(exc)}\n\n" f"Available presets: {_format_preset_names(names)}" ), metadata=metadata, ) max_tokens = runtime.generation.max_tokens lines = [ f"Switched model preset to `{runtime.model_preset}`.", f"- Model: `{runtime.model}`", f"- Context window: {runtime.context_window_tokens}", ] if max_tokens is not None: lines.append(f"- Max output tokens: {max_tokens}") return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="\n".join(lines), metadata=metadata, ) async def cmd_dream(ctx: CommandContext) -> OutboundMessage: """Manually trigger a Dream consolidation run.""" import time loop = ctx.loop msg = ctx.msg async def _run_dream(): async def _silent(*_args, **_kwargs): pass from nanobot.agent.memory import MemoryStore dream_session_key = MemoryStore.dream_session_key build_dream_commit_message = MemoryStore.build_dream_commit_message prune_dream_sessions = MemoryStore.prune_dream_sessions store = loop.context.memory content = "" resp = None diff_body = "" t0 = time.monotonic() try: result = store.build_dream_prompt() if result is None: await loop.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=_format_dream_no_input_message(), metadata={"render_as": "text"}, )) return prompt, last_cursor = result key = dream_session_key() resp = await loop.process_direct( prompt, session_key=key, ephemeral=True, tools=store.build_dream_tools(), on_progress=_silent, ) elapsed = time.monotonic() - t0 # Ground truth: the real file delta, not the LLM's self-report. diff_body = store.dream_content_diff() productive = bool(diff_body) or ( not store.git.is_initialized() and MemoryStore.dream_run_completed(resp) ) if productive: store.set_last_dream_cursor(last_cursor) content = f"Dream completed in {elapsed:.1f}s." elif MemoryStore.dream_run_completed(resp): content = f"Dream completed in {elapsed:.1f}s; no memory changes." else: content = ( f"Dream did not complete after {elapsed:.1f}s; " "memory cursor was not advanced." ) except Exception as e: elapsed = time.monotonic() - t0 content = f"Dream failed after {elapsed:.1f}s: {e}" finally: from nanobot.webui.token_usage import record_response_token_usage record_response_token_usage( resp, source="dream", timezone_name=getattr(loop.context, "timezone", None), ) if store.git.is_initialized(): commit_msg = build_dream_commit_message("dream: manual run", diff_body) sha = store.git.auto_commit(commit_msg) if sha: content += f" (commit {sha})" store.compact_history() prune_dream_sessions(loop.sessions.sessions_dir) await loop.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, )) asyncio.create_task(_run_dream()) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...", ) async def cmd_dream_prompt(ctx: CommandContext) -> OutboundMessage: """Show or set up the workspace Dream memory instructions.""" store = ctx.loop.context.memory path = store.dream_prompt_file display_path = path.relative_to(store.workspace).as_posix() args = ctx.args.strip().lower() if args == "init": if not initialize_workspace_prompt(path, store.default_dream_prompt()): content = ( f"Dream memory instructions already exist at `{display_path}`.\n\n" "Edit that file, or delete/empty it to return to nanobot's default." ) else: content = ( f"Created Dream memory instructions at `{display_path}`.\n\n" "Edit that file to teach Dream how to organize memory. " "This fully replaces nanobot's default Dream guide for this workspace. " "Delete or empty it to return to nanobot's default." ) elif args: content = "Usage: /dream-prompt [init]" elif store.has_dream_prompt_override(): content = ( "Dream memory instructions: custom for this workspace\n\n" f"- Path: `{display_path}`\n" "- Delete or empty this file to return to nanobot's default." ) else: content = ( "Dream memory instructions: nanobot default\n\n" f"- Editable file: `{display_path}`\n" "- Run `/dream-prompt init` to create an editable copy." ) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) async def cmd_evaluator_prompt(ctx: CommandContext) -> OutboundMessage: """Show or set up the workspace heartbeat evaluator prompt.""" from nanobot.utils.evaluator import ( default_evaluator_prompt, evaluator_prompt_file, has_evaluator_prompt_override, ) workspace = ctx.loop.context.memory.workspace path = evaluator_prompt_file(workspace) display_path = path.relative_to(workspace).as_posix() args = ctx.args.strip().lower() if args == "init": if not initialize_workspace_prompt(path, default_evaluator_prompt()): content = ( f"Heartbeat evaluator prompt already exists at `{display_path}`.\n\n" "Edit that file, or delete/empty it to return to nanobot's default." ) else: content = ( f"Created heartbeat evaluator prompt at `{display_path}`.\n\n" "Edit that file to control when the heartbeat notification gate speaks. " "It must still instruct the model to call the `evaluate_notification` tool, " "otherwise the gate fails closed and stays silent. " "Delete or empty it to return to nanobot's default." ) elif args: content = "Usage: /evaluator-prompt [init]" elif has_evaluator_prompt_override(workspace): content = ( "Heartbeat evaluator prompt: custom for this workspace\n\n" f"- Path: `{display_path}`\n" "- Delete or empty this file to return to nanobot's default." ) else: content = ( "Heartbeat evaluator prompt: nanobot default\n\n" f"- Editable file: `{display_path}`\n" "- Run `/evaluator-prompt init` to create an editable copy." ) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) def _format_dream_no_input_message() -> str: return "\n".join([ "Dream has no conversation history to process yet.", "", "Dream reads new entries from `memory/history.jsonl` after the current Dream cursor.", ( "Short chats only reach that file after token compaction or idle auto-compact, " "so a fresh or short WebUI chat may leave Dream with no input." ), "", "Next steps:", "- Enable `agents.defaults.idleCompactAfterMinutes` so completed chats become Dream input automatically.", "- Compact the current chat into memory once that manual action is available.", "- If you expected history to exist, check whether `memory/history.jsonl` has new entries after the Dream cursor.", "- Use `/dream-prompt` to see or change how Dream organizes memory.", ]) def _extract_changed_files(diff: str) -> list[str]: """Extract changed file paths from a unified diff.""" files: list[str] = [] seen: set[str] = set() for line in diff.splitlines(): if not line.startswith("diff --git "): continue parts = line.split() if len(parts) < 4: continue path = parts[3] if path.startswith("b/"): path = path[2:] if path in seen: continue seen.add(path) files.append(path) return files def _format_changed_files(diff: str) -> str: files = _extract_changed_files(diff) if not files: return "No tracked memory files changed." return ", ".join(f"`{path}`" for path in files) _DREAM_COMMIT_PREFIX = "dream:" def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str: files_line = _format_changed_files(diff) lines = [ "## Dream Update", "", "Here is the selected Dream memory change." if requested_sha else "Here is the latest Dream memory change.", "", f"- Commit: `{commit.sha}`", f"- Time: {commit.timestamp}", f"- Changed files: {files_line}", ] if diff: lines.extend([ "", f"Use `/dream-restore {commit.sha}` to undo this change.", "", "```diff", diff.rstrip(), "```", ]) else: lines.extend([ "", "Dream recorded this version, but there is no file diff to display.", ]) return "\n".join(lines) def _format_dream_restore_list(commits: list) -> str: lines = [ "## Dream Restore", "", "Choose a Dream memory version to restore. Latest first:", "", ] for c in commits: lines.append(f"- `{c.sha}` {c.timestamp} - {c.subject()}") lines.extend([ "", "Preview a version with `/dream-log ` before restoring it.", "Restore a version with `/dream-restore `.", ]) return "\n".join(lines) async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage: """Show what the last Dream changed. Default: diff of the latest Dream commit versus its parent. With /dream-log : diff of that specific commit. """ store = ctx.loop.consolidator.store git = store.git if not git.is_initialized(): if store.get_last_dream_cursor() == 0: msg = ( "Dream has not run yet. Run `/dream`, or wait for the next scheduled Dream cycle.\n\n" "Use `/dream-prompt` to see or change how Dream organizes memory." ) else: msg = "Dream history is not available because memory versioning is not initialized." return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=msg, metadata={"render_as": "text"}, ) args = ctx.args.strip() if args: # Show diff of a specific commit sha = args.split()[0] result = git.show_commit_diff(sha) if not result: content = ( f"Couldn't find Dream change `{sha}`.\n\n" "Use `/dream-restore` to list recent versions, " "or `/dream-log` to inspect the latest one." ) else: commit, diff = result content = _format_dream_log_content(commit, diff, requested_sha=sha) else: # Default: show the latest Dream commit's diff commits = git.log(max_entries=1, message_prefix=_DREAM_COMMIT_PREFIX) result = ( git.show_commit_diff( commits[0].sha, max_entries=1, message_prefix=_DREAM_COMMIT_PREFIX, ) if commits else None ) if result: commit, diff = result content = _format_dream_log_content(commit, diff) else: content = ( "Dream memory has no saved versions yet.\n\n" "Use `/dream-prompt` to see or change how Dream organizes memory." ) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, metadata={"render_as": "text"}, ) async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage: """Restore memory files from a previous dream commit. Usage: /dream-restore — list recent commits /dream-restore — revert a specific commit """ store = ctx.loop.consolidator.store git = store.git if not git.is_initialized(): return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="Dream history is not available because memory versioning is not initialized.", ) args = ctx.args.strip() if not args: # Show recent Dream commits for the user to pick commits = git.log(max_entries=10, message_prefix=_DREAM_COMMIT_PREFIX) if not commits: content = "Dream memory has no saved versions to restore yet." else: content = _format_dream_restore_list(commits) else: sha = args.split()[0] result = git.show_commit_diff(sha, message_prefix=_DREAM_COMMIT_PREFIX) if not result: content = ( f"Couldn't restore Dream change `{sha}`.\n\n" "Only Dream memory versions can be restored. " "Use `/dream-restore` to list recent versions." ) else: changed_files = _format_changed_files(result[1]) new_sha = git.revert(sha, message_prefix=_DREAM_COMMIT_PREFIX) if new_sha: content = ( f"Restored Dream memory to the state before `{sha}`.\n\n" f"- New safety commit: `{new_sha}`\n" f"- Restored files: {changed_files}\n\n" f"Use `/dream-log {new_sha}` to inspect the restore diff." ) else: content = ( f"Couldn't restore Dream change `{sha}`.\n\n" "It may be the first saved version with no earlier state to restore." ) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, metadata={"render_as": "text"}, ) _HISTORY_DEFAULT_COUNT = 10 _HISTORY_MAX_COUNT = 50 _HISTORY_MAX_CONTENT_CHARS = 200 def _format_history_message(msg: dict) -> str | None: """Format a single history message for display. Returns None to skip.""" role = msg.get("role") if role not in ("user", "assistant"): return None content = msg.get("content") or "" if isinstance(content, list): parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] content = " ".join(parts) content = str(content).strip() if not content: return None if len(content) > _HISTORY_MAX_CONTENT_CHARS: content = content[:_HISTORY_MAX_CONTENT_CHARS] + "…" label = "👤 You" if role == "user" else "🤖 Bot" return f"{label}: {content}" async def cmd_history(ctx: CommandContext) -> OutboundMessage: """Show the last N messages of the current session (default 10, max 50). Usage: /history [count] """ count = _HISTORY_DEFAULT_COUNT if ctx.args.strip(): try: count = max(1, min(int(ctx.args.strip()), _HISTORY_MAX_COUNT)) except ValueError: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="Usage: /history [count] — e.g. /history 5 (default: 10, max: 50)", metadata=dict(ctx.msg.metadata or {}), ) session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key) history = session.get_history(max_messages=0, include_runtime_context=False) visible = [_format_history_message(m) for m in history] visible = [m for m in visible if m is not None] recent = visible[-count:] if not recent: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="No conversation history yet.", metadata=dict(ctx.msg.metadata or {}), ) header = f"Last {len(recent)} message(s):\n" return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=header + "\n".join(recent), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None: """Mark this turn as an explicit sustained-goal request.""" goal = ctx.args.strip() if not goal: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="Usage: /goal ", metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) if ctx.session is None: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=( "A task is already running for this chat. " "Use `/stop` first, then send `/goal ` again." ), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) if not ctx.is_user_turn: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content="Goal mode can only be started by a user `/goal ` command.", metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) ctx.turn_scopes.append(goal_mutation_permission(True)) ctx.msg.metadata = { **dict(ctx.msg.metadata or {}), "original_command": "/goal", "original_content": ctx.raw, "goal_requested": True, "goal_started_at": time.time(), } ctx.msg.content = ctx.raw return None async def cmd_pairing(ctx: CommandContext) -> OutboundMessage: """List, approve, deny or revoke pairing requests.""" from nanobot.pairing import PAIRING_COMMAND_META_KEY, handle_pairing_command reply = handle_pairing_command(ctx.msg.channel, ctx.args) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=reply, metadata={PAIRING_COMMAND_META_KEY: True}, ) async def cmd_skill(ctx: CommandContext) -> OutboundMessage: """List all enabled skills (name and description only).""" loop = ctx.loop skills = loop.context.skills.list_skills(filter_unavailable=False) if not skills: content = "No skills available." else: lines = [f"Available skills ({len(skills)}):", ""] for entry in skills: desc = loop.context.skills._get_skill_description(entry["name"]) lines.append(f"- **{entry['name']}** — {desc}") content = "\n".join(lines) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, metadata=dict(ctx.msg.metadata or {}), ) async def cmd_trigger(ctx: CommandContext) -> OutboundMessage: """Create a local trigger bound to the current session.""" name = ctx.args.strip() if not name: return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=( "Usage: /trigger \n\n" "Create a named local trigger bound to this chat session." ), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) from nanobot.triggers.local_store import LocalTriggerStore loop = ctx.loop workspace = getattr(loop, "workspace", None) if workspace is None: workspace = getattr(getattr(loop, "context", None), "workspace", None) if workspace is None: raise RuntimeError("workspace unavailable for trigger creation") store = getattr(loop, "local_trigger_store", None) if store is None: store = LocalTriggerStore(workspace) from nanobot.session.keys import UNIFIED_SESSION_KEY session_key = ( ctx.msg.session_key if ctx.key == UNIFIED_SESSION_KEY else ctx.key ) trigger = store.create( name=name, channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, session_key=session_key, sender_id="trigger", origin_metadata=dict(ctx.msg.metadata or {}), ) command = f'nanobot trigger {trigger.id} "message"' return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=( f"Trigger created: {trigger.name}\n" f"ID: {trigger.id}\n\n" f"Command:\n{command}" ), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) async def cmd_help(ctx: CommandContext) -> OutboundMessage: """Return available slash commands.""" return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=build_help_text(), metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"}, ) def build_help_text() -> str: """Build canonical help text shared across channels.""" lines = ["🐈 nanobot commands:"] for spec in BUILTIN_COMMAND_SPECS: command = spec.command if spec.arg_hint: command = f"{command} {spec.arg_hint}" lines.append(f"{command} — {spec.description}") return "\n".join(lines) def register_builtin_commands(router: CommandRouter) -> None: """Register the default set of slash commands.""" router.priority("/stop", cmd_stop) router.priority("/restart", cmd_restart) router.priority("/status", cmd_status) router.exact("/new", cmd_new) router.exact("/status", cmd_status) router.exact("/model", cmd_model) router.prefix("/model ", cmd_model) router.exact("/history", cmd_history) router.prefix("/history ", cmd_history) router.exact("/goal", cmd_goal) router.prefix("/goal ", cmd_goal) router.exact("/trigger", cmd_trigger) router.prefix("/trigger ", cmd_trigger) router.exact("/dream", cmd_dream) router.exact("/dream-log", cmd_dream_log) router.prefix("/dream-log ", cmd_dream_log) router.exact("/dream-restore", cmd_dream_restore) router.prefix("/dream-restore ", cmd_dream_restore) router.exact("/dream-prompt", cmd_dream_prompt) router.prefix("/dream-prompt ", cmd_dream_prompt) router.exact("/evaluator-prompt", cmd_evaluator_prompt) router.prefix("/evaluator-prompt ", cmd_evaluator_prompt) router.exact("/skill", cmd_skill) router.exact("/help", cmd_help) router.exact("/pairing", cmd_pairing) router.prefix("/pairing ", cmd_pairing)