Files
hkuds--nanobot/nanobot/command/builtin.py
T
wehub-resource-sync ba1d0b91a4
Test Suite / webui (push) Failing after 1s
Test Suite / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:06:36 +08:00

944 lines
32 KiB
Python

"""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
# 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",
"<goal>",
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",
"<name>",
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(
"/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 <code>|deny <code>|revoke <user_id>]",
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":
try:
prompt_exists_with_content = path.exists() and (
not path.is_file() or bool(path.read_text(encoding="utf-8").strip())
)
except OSError:
prompt_exists_with_content = True
if prompt_exists_with_content:
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:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(store.default_dream_prompt() + "\n", encoding="utf-8")
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"},
)
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)
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.message.splitlines()[0]}")
lines.extend([
"",
"Preview a version with `/dream-log <sha>` before restoring it.",
"Restore a version with `/dream-restore <sha>`.",
])
return "\n".join(lines)
async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage:
"""Show what the last Dream changed.
Default: diff of the latest commit (HEAD~1 vs HEAD).
With /dream-log <sha>: 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 commit's diff
commits = git.log(max_entries=1)
result = git.show_commit_diff(commits[0].sha) 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 <sha> — 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 commits for the user to pick
commits = git.log(max_entries=10)
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)
changed_files = _format_changed_files(result[1]) if result else "the tracked memory files"
new_sha = git.revert(sha)
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 not exist, or 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 <long-running task description>",
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 <long-running task description>` 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 <task>` 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 <name>\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("/skill", cmd_skill)
router.exact("/help", cmd_help)
router.exact("/pairing", cmd_pairing)
router.prefix("/pairing ", cmd_pairing)