2026-03-23 08:40:55 +00:00
"""Built-in slash command handlers."""
from __future__ import annotations
import asyncio
import os
import sys
2026-05-16 01:14:11 +08:00
import time
2026-05-01 01:42:31 +08:00
from contextlib import suppress
2026-05-06 15:54:15 +00:00
from dataclasses import dataclass
2026-03-23 08:40:55 +00:00
from nanobot import __version__
from nanobot.bus.events import OutboundMessage
from nanobot.command.router import CommandContext , CommandRouter
from nanobot.utils.helpers import build_status_content
2026-04-03 00:44:17 +08:00
from nanobot.utils.restart import set_restart_notice_to_env
2026-03-23 08:40:55 +00:00
2026-05-06 15:54:15 +00:00
@dataclass ( frozen = True )
class BuiltinCommandSpec :
command : str
title : str
description : str
icon : str
arg_hint : str = ""
def as_dict ( self ) -> dict [ str , str ]:
return {
"command" : self . command ,
"title" : self . title ,
"description" : self . description ,
"icon" : self . icon ,
"arg_hint" : self . arg_hint ,
}
BUILTIN_COMMAND_SPECS : tuple [ BuiltinCommandSpec , ... ] = (
BuiltinCommandSpec (
"/new" ,
"New chat" ,
"Stop the current task and start a fresh conversation." ,
"square-pen" ,
),
BuiltinCommandSpec (
"/stop" ,
"Stop current task" ,
"Cancel the active agent turn for this chat." ,
"square" ,
),
BuiltinCommandSpec (
"/restart" ,
"Restart nanobot" ,
"Restart the bot process in place." ,
"rotate-cw" ,
),
BuiltinCommandSpec (
"/status" ,
"Show status" ,
"Display runtime, provider, and channel status." ,
"activity" ,
),
2026-05-12 07:55:01 +00:00
BuiltinCommandSpec (
"/model" ,
"Switch model preset" ,
"Show or switch the active model preset." ,
"brain" ,
"[preset]" ,
),
2026-05-06 15:54:15 +00:00
BuiltinCommandSpec (
"/history" ,
"Show conversation history" ,
"Print the last N persisted conversation messages." ,
"history" ,
"[n]" ,
),
2026-05-16 01:14:11 +08:00
BuiltinCommandSpec (
"/goal" ,
"Start long-running goal" ,
"Tell the agent to treat the request as a long-running goal." ,
"activity" ,
"<goal>" ,
),
2026-05-06 15:54:15 +00:00
BuiltinCommandSpec (
"/dream" ,
"Run Dream" ,
"Manually trigger memory consolidation." ,
"sparkles" ,
),
BuiltinCommandSpec (
"/dream-log" ,
"Show Dream log" ,
"Show what the last Dream consolidation changed." ,
"book-open" ,
),
BuiltinCommandSpec (
"/dream-restore" ,
"Restore memory" ,
"Revert memory to a previous Dream snapshot." ,
"undo-2" ,
),
2026-05-23 08:59:45 +08:00
BuiltinCommandSpec (
"/skill" ,
"List skills" ,
"List all enabled skills available to the agent." ,
"wrench" ,
),
2026-05-06 15:54:15 +00:00
BuiltinCommandSpec (
"/help" ,
"Show help" ,
"List available slash commands." ,
"circle-help" ,
),
2026-05-14 13:31:18 +08:00
BuiltinCommandSpec (
"/pairing" ,
"Manage pairing" ,
"List, approve, deny or revoke pairing requests." ,
"shield" ,
"[list|approve <code>|deny <code>|revoke <user_id>]" ,
),
2026-05-06 15:54:15 +00:00
)
def builtin_command_palette () -> list [ dict [ str , str ]]:
"""Return structured command metadata for UI command palettes."""
return [ spec . as_dict () for spec in BUILTIN_COMMAND_SPECS ]
2026-03-23 08:40:55 +00:00
async def cmd_stop ( ctx : CommandContext ) -> OutboundMessage :
"""Cancel all active tasks and subagents for the session."""
loop = ctx . loop
msg = ctx . msg
2026-05-28 16:39:48 +05:30
total = await loop . _cancel_active_tasks ( ctx . key )
2026-03-23 08:40:55 +00:00
content = f "Stopped { total } task(s)." if total else "No active task to stop."
2026-04-01 09:00:52 +03:00
return OutboundMessage (
channel = msg . channel , chat_id = msg . chat_id , content = content ,
metadata = dict ( msg . metadata or {})
)
2026-03-23 08:40:55 +00:00
async def cmd_restart ( ctx : CommandContext ) -> OutboundMessage :
"""Restart the process in-place via os.execv."""
msg = ctx . msg
2026-04-26 18:53:03 +00:00
set_restart_notice_to_env (
channel = msg . channel ,
chat_id = msg . chat_id ,
metadata = dict ( msg . metadata or {}),
)
2026-03-23 08:40:55 +00:00
async def _do_restart ():
await asyncio . sleep ( 1 )
os . execv ( sys . executable , [ sys . executable , "-m" , "nanobot" ] + sys . argv [ 1 :])
asyncio . create_task ( _do_restart ())
2026-04-01 09:00:52 +03:00
return OutboundMessage (
channel = msg . channel , chat_id = msg . chat_id , content = "Restarting..." ,
metadata = dict ( msg . metadata or {})
)
2026-03-23 08:40:55 +00:00
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 )
ctx_est = 0
2026-05-01 01:42:31 +08:00
with suppress ( Exception ):
2026-03-31 10:58:57 +08:00
ctx_est , _ = loop . consolidator . estimate_session_prompt_tokens ( session )
2026-03-23 08:40:55 +00:00
if ctx_est <= 0 :
ctx_est = loop . _last_usage . get ( "prompt_tokens" , 0 )
2026-04-27 10:02:17 +00:00
2026-04-06 07:00:02 +08:00
# Fetch web search provider usage (best-effort, never blocks the response)
search_usage_text : str | None = None
2026-05-01 01:42:31 +08:00
# Never let usage fetch break /status
with suppress ( Exception ):
2026-04-06 05:34:44 +00:00
from nanobot.utils.searchusage import fetch_search_usage
2026-04-06 18:47:38 +08:00
web_cfg = getattr ( loop , "web_config" , None )
2026-04-06 07:00:02 +08:00
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 ()
2026-04-14 22:25:43 +08:00
active_tasks = loop . _active_tasks . get ( ctx . key , [])
task_count = sum ( 1 for t in active_tasks if not t . done ())
2026-05-01 01:42:31 +08:00
with suppress ( Exception ):
2026-04-14 22:25:43 +08:00
task_count += loop . subagents . get_running_count_by_session ( ctx . key )
2026-03-23 08:40:55 +00:00
return OutboundMessage (
channel = ctx . msg . channel ,
chat_id = ctx . msg . chat_id ,
content = build_status_content (
version = __version__ , model = loop . model ,
start_time = loop . _start_time , last_usage = loop . _last_usage ,
context_window_tokens = loop . context_window_tokens ,
session_msg_count = len ( session . get_history ( max_messages = 0 )),
context_tokens_estimate = ctx_est ,
2026-04-06 07:00:02 +08:00
search_usage_text = search_usage_text ,
2026-04-14 22:25:43 +08:00
active_task_count = task_count ,
2026-04-16 14:37:01 +08:00
max_completion_tokens = getattr (
getattr ( loop . provider , "generation" , None ), "max_tokens" , 8192
),
2026-03-23 08:40:55 +00:00
),
2026-04-01 09:00:52 +03:00
metadata = { ** dict ( ctx . msg . metadata or {}), "render_as" : "text" },
2026-03-23 08:40:55 +00:00
)
async def cmd_new ( ctx : CommandContext ) -> OutboundMessage :
2026-04-21 21:28:58 +08:00
"""Stop active task and start a fresh session."""
2026-03-23 08:40:55 +00:00
loop = ctx . loop
2026-04-21 21:28:58 +08:00
await loop . _cancel_active_tasks ( ctx . key )
2026-03-23 08:40:55 +00:00
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 :
2026-03-31 10:58:57 +08:00
loop . _schedule_background ( loop . consolidator . archive ( snapshot ))
2026-03-23 08:40:55 +00:00
return OutboundMessage (
channel = ctx . msg . channel , chat_id = ctx . msg . chat_id ,
content = "New session started." ,
2026-04-01 09:00:52 +03:00
metadata = dict ( ctx . msg . metadata or {})
2026-03-23 08:40:55 +00:00
)
2026-05-12 07:55:01 +00:00
def _format_preset_names ( names : list [ str ]) -> str :
return ", " . join ( f "` { name } `" for name in names ) if names else "(none configured)"
2026-05-12 11:08:52 +00:00
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"
2026-05-12 11:28:56 +00:00
def _command_error_message ( exc : Exception ) -> str :
return str ( exc . args [ 0 ]) if isinstance ( exc , KeyError ) and exc . args else str ( exc )
2026-05-12 07:55:01 +00:00
def _model_command_status ( loop ) -> str :
2026-05-12 11:08:52 +00:00
names = _model_preset_names ( loop )
active = _active_model_preset_name ( loop )
2026-05-12 07:55:01 +00:00
return " \n " . join ([
"## Model" ,
f "- Current model: ` { loop . model } `" ,
2026-05-12 11:08:52 +00:00
f "- Current preset: ` { active } `" ,
2026-05-12 07:55:01 +00:00
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 ),
2026-05-12 09:05:24 +00:00
metadata = metadata ,
2026-05-12 07:55:01 +00:00
)
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 :
loop . set_model_preset ( name )
except ( KeyError , ValueError ) as exc :
2026-05-12 11:08:52 +00:00
names = _model_preset_names ( loop )
2026-05-12 07:55:01 +00:00
return OutboundMessage (
channel = ctx . msg . channel ,
chat_id = ctx . msg . chat_id ,
content = (
2026-05-12 11:28:56 +00:00
f "Could not switch model preset: { _command_error_message ( exc ) } \n\n "
2026-05-12 07:55:01 +00:00
f "Available presets: { _format_preset_names ( names ) } "
),
metadata = metadata ,
)
max_tokens = getattr ( getattr ( loop . provider , "generation" , None ), "max_tokens" , None )
lines = [
f "Switched model preset to ` { loop . model_preset } `." ,
f "- Model: ` { loop . model } `" ,
f "- Context window: { loop . 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 ),
2026-05-12 09:05:24 +00:00
metadata = metadata ,
2026-05-12 07:55:01 +00:00
)
2026-03-31 10:58:57 +08:00
async def cmd_dream ( ctx : CommandContext ) -> OutboundMessage :
"""Manually trigger a Dream consolidation run."""
2026-04-05 15:48:00 +00:00
import time
2026-03-31 10:58:57 +08:00
loop = ctx . loop
2026-04-05 15:48:00 +00:00
msg = ctx . msg
async def _run_dream ():
2026-06-02 22:46:47 +08:00
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
2026-04-05 15:48:00 +00:00
t0 = time . monotonic ()
try :
2026-06-02 22:46:47 +08:00
result = store . build_dream_prompt ()
if result is None :
await loop . bus . publish_outbound ( OutboundMessage (
channel = msg . channel , chat_id = msg . chat_id ,
content = "Dream: nothing to process." ,
))
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 (),
)
2026-04-05 15:48:00 +00:00
elapsed = time . monotonic () - t0
2026-06-02 22:46:47 +08:00
if MemoryStore . dream_run_completed ( resp ):
store . set_last_dream_cursor ( last_cursor )
2026-04-05 15:48:00 +00:00
content = f "Dream completed in { elapsed : .1f } s."
else :
2026-06-02 22:46:47 +08:00
content = (
f "Dream did not complete after { elapsed : .1f } s; "
"memory cursor was not advanced."
)
2026-04-05 15:48:00 +00:00
except Exception as e :
elapsed = time . monotonic () - t0
content = f "Dream failed after { elapsed : .1f } s: { e } "
2026-06-02 22:46:47 +08:00
finally :
if store . git . is_initialized ():
commit_msg = build_dream_commit_message ( "dream: manual run" , resp )
sha = store . git . auto_commit ( commit_msg )
if sha :
content += f " (commit { sha } )"
store . compact_history ()
prune_dream_sessions ( loop . sessions . sessions_dir )
2026-04-05 15:48:00 +00:00
await loop . bus . publish_outbound ( OutboundMessage (
channel = msg . channel , chat_id = msg . chat_id , content = content ,
))
asyncio . create_task ( _run_dream ())
2026-03-31 10:58:57 +08:00
return OutboundMessage (
2026-04-05 15:48:00 +00:00
channel = msg . channel , chat_id = msg . chat_id , content = "Dreaming..." ,
2026-03-31 10:58:57 +08:00
)
2026-04-04 08:41:46 +00:00
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 )
2026-03-31 10:58:57 +08:00
async def cmd_dream_log ( ctx : CommandContext ) -> OutboundMessage :
2026-04-02 18:39:57 +08:00
"""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 ():
2026-03-31 10:58:57 +08:00
if store . get_last_dream_cursor () == 0 :
2026-04-04 08:41:46 +00:00
msg = "Dream has not run yet. Run `/dream`, or wait for the next scheduled Dream cycle."
2026-03-31 10:58:57 +08:00
else :
2026-04-04 08:41:46 +00:00
msg = "Dream history is not available because memory versioning is not initialized."
2026-04-02 18:39:57 +08:00
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 :
2026-04-04 08:41:46 +00:00
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."
)
2026-04-02 18:39:57 +08:00
else :
commit , diff = result
2026-04-04 08:41:46 +00:00
content = _format_dream_log_content ( commit , diff , requested_sha = sha )
2026-03-31 10:58:57 +08:00
else :
2026-04-02 18:39:57 +08:00
# Default: show the latest commit's diff
2026-04-04 04:49:42 +00:00
commits = git . log ( max_entries = 1 )
result = git . show_commit_diff ( commits [ 0 ] . sha ) if commits else None
2026-04-02 18:39:57 +08:00
if result :
commit , diff = result
2026-04-04 08:41:46 +00:00
content = _format_dream_log_content ( commit , diff )
2026-04-02 18:39:57 +08:00
else :
2026-04-04 08:41:46 +00:00
content = "Dream memory has no saved versions yet."
2026-04-02 18:39:57 +08:00
2026-03-31 10:58:57 +08:00
return OutboundMessage (
2026-04-02 18:39:57 +08:00
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 ,
2026-04-04 08:41:46 +00:00
content = "Dream history is not available because memory versioning is not initialized." ,
2026-04-02 18:39:57 +08:00
)
args = ctx . args . strip ()
if not args :
# Show recent commits for the user to pick
commits = git . log ( max_entries = 10 )
if not commits :
2026-04-04 08:41:46 +00:00
content = "Dream memory has no saved versions to restore yet."
2026-04-02 18:39:57 +08:00
else :
2026-04-04 08:41:46 +00:00
content = _format_dream_restore_list ( commits )
2026-04-02 18:39:57 +08:00
else :
sha = args . split ()[ 0 ]
2026-04-04 08:41:46 +00:00
result = git . show_commit_diff ( sha )
changed_files = _format_changed_files ( result [ 1 ]) if result else "the tracked memory files"
2026-04-02 18:39:57 +08:00
new_sha = git . revert ( sha )
if new_sha :
2026-04-04 08:41:46 +00:00
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."
)
2026-04-02 18:39:57 +08:00
else :
2026-04-04 08:41:46 +00:00
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."
)
2026-04-02 18:39:57 +08:00
return OutboundMessage (
channel = ctx . msg . channel , chat_id = ctx . msg . chat_id ,
content = content , metadata = { "render_as" : "text" },
2026-03-31 10:58:57 +08:00
)
2026-04-26 19:11:13 -04:00
_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 )
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" },
)
2026-05-16 01:14:11 +08:00
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
Goal:
{goal}
"""
async def cmd_goal ( ctx : CommandContext ) -> OutboundMessage | None :
"""Rewrite /goal into a normal agent turn that nudges long_task use."""
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" },
)
ctx . msg . metadata = {
** dict ( ctx . msg . metadata or {}),
"original_command" : "/goal" ,
"original_content" : ctx . raw ,
"goal_started_at" : time . time (),
}
ctx . msg . content = _GOAL_PROMPT_TEMPLATE . format ( goal = goal )
return None
2026-05-14 13:31:18 +08:00
async def cmd_pairing ( ctx : CommandContext ) -> OutboundMessage :
"""List, approve, deny or revoke pairing requests."""
2026-05-15 13:42:41 +08:00
from nanobot.pairing import PAIRING_COMMAND_META_KEY , handle_pairing_command
2026-05-14 13:31:18 +08:00
reply = handle_pairing_command ( ctx . msg . channel , ctx . args )
return OutboundMessage (
channel = ctx . msg . channel ,
chat_id = ctx . msg . chat_id ,
content = reply ,
2026-05-15 13:42:41 +08:00
metadata = { PAIRING_COMMAND_META_KEY : True },
2026-05-14 13:31:18 +08:00
)
2026-05-23 08:59:45 +08:00
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 {}),
)
2026-03-23 08:40:55 +00:00
async def cmd_help ( ctx : CommandContext ) -> OutboundMessage :
"""Return available slash commands."""
2026-03-27 02:51:45 +01:00
return OutboundMessage (
channel = ctx . msg . channel ,
chat_id = ctx . msg . chat_id ,
content = build_help_text (),
2026-04-01 09:00:52 +03:00
metadata = { ** dict ( ctx . msg . metadata or {}), "render_as" : "text" },
2026-03-27 02:51:45 +01:00
)
def build_help_text () -> str :
"""Build canonical help text shared across channels."""
2026-05-06 15:54:15 +00:00
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 } " )
2026-03-27 02:51:45 +01:00
return " \n " . join ( lines )
2026-03-23 08:40:55 +00:00
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 )
2026-05-12 07:55:01 +00:00
router . exact ( "/model" , cmd_model )
router . prefix ( "/model " , cmd_model )
2026-04-26 19:11:13 -04:00
router . exact ( "/history" , cmd_history )
router . prefix ( "/history " , cmd_history )
2026-05-16 01:14:11 +08:00
router . exact ( "/goal" , cmd_goal )
router . prefix ( "/goal " , cmd_goal )
2026-03-31 10:58:57 +08:00
router . exact ( "/dream" , cmd_dream )
router . exact ( "/dream-log" , cmd_dream_log )
2026-04-02 18:39:57 +08:00
router . prefix ( "/dream-log " , cmd_dream_log )
router . exact ( "/dream-restore" , cmd_dream_restore )
router . prefix ( "/dream-restore " , cmd_dream_restore )
2026-05-23 08:59:45 +08:00
router . exact ( "/skill" , cmd_skill )
2026-03-23 08:40:55 +00:00
router . exact ( "/help" , cmd_help )
2026-05-14 13:31:18 +08:00
router . exact ( "/pairing" , cmd_pairing )
router . prefix ( "/pairing " , cmd_pairing )