2026-03-23 08:40:55 +00:00
|
|
|
"""Built-in slash command handlers."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|
|
|
|
"""Cancel all active tasks and subagents for the session."""
|
|
|
|
|
loop = ctx.loop
|
|
|
|
|
msg = ctx.msg
|
|
|
|
|
tasks = loop._active_tasks.pop(msg.session_key, [])
|
|
|
|
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
|
|
|
|
for t in tasks:
|
|
|
|
|
try:
|
|
|
|
|
await t
|
|
|
|
|
except (asyncio.CancelledError, Exception):
|
|
|
|
|
pass
|
|
|
|
|
sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
|
|
|
|
|
total = cancelled + sub_cancelled
|
|
|
|
|
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-03 00:44:17 +08:00
|
|
|
set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id)
|
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
|
|
|
|
|
try:
|
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
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
if ctx_est <= 0:
|
|
|
|
|
ctx_est = loop._last_usage.get("prompt_tokens", 0)
|
|
|
|
|
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-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:
|
|
|
|
|
"""Start a fresh session."""
|
|
|
|
|
loop = ctx.loop
|
|
|
|
|
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-03-31 10:58:57 +08:00
|
|
|
async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
|
|
|
|
"""Manually trigger a Dream consolidation run."""
|
|
|
|
|
loop = ctx.loop
|
|
|
|
|
try:
|
|
|
|
|
did_work = await loop.dream.run()
|
|
|
|
|
content = "Dream completed." if did_work else "Dream: nothing to process."
|
|
|
|
|
except Exception as e:
|
|
|
|
|
content = f"Dream failed: {e}"
|
|
|
|
|
return OutboundMessage(
|
|
|
|
|
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-02 18:39:57 +08:00
|
|
|
msg = "Dream has not run yet."
|
2026-03-31 10:58:57 +08:00
|
|
|
else:
|
2026-04-02 18:39:57 +08:00
|
|
|
msg = "Git not initialized for memory files."
|
|
|
|
|
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"Commit `{sha}` not found."
|
|
|
|
|
else:
|
|
|
|
|
commit, diff = result
|
|
|
|
|
content = commit.format(diff)
|
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
|
|
|
|
|
content = commit.format(diff)
|
|
|
|
|
else:
|
|
|
|
|
content = "No commits yet."
|
|
|
|
|
|
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,
|
|
|
|
|
content="Git not initialized for memory files.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 = "No commits found."
|
|
|
|
|
else:
|
|
|
|
|
lines = ["## Recent Dream Commits\n", "Use `/dream-restore <sha>` to revert a commit.\n"]
|
|
|
|
|
for c in commits:
|
|
|
|
|
lines.append(f"- `{c.sha}` {c.message.splitlines()[0]} ({c.timestamp})")
|
|
|
|
|
content = "\n".join(lines)
|
|
|
|
|
else:
|
|
|
|
|
sha = args.split()[0]
|
|
|
|
|
new_sha = git.revert(sha)
|
|
|
|
|
if new_sha:
|
|
|
|
|
content = f"Reverted commit `{sha}` → new commit `{new_sha}`."
|
|
|
|
|
else:
|
|
|
|
|
content = f"Failed to revert commit `{sha}`. Check if the SHA is correct."
|
|
|
|
|
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-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-03-23 08:40:55 +00:00
|
|
|
lines = [
|
|
|
|
|
"🐈 nanobot commands:",
|
|
|
|
|
"/new — Start a new conversation",
|
|
|
|
|
"/stop — Stop the current task",
|
|
|
|
|
"/restart — Restart the bot",
|
|
|
|
|
"/status — Show bot status",
|
2026-03-31 10:58:57 +08:00
|
|
|
"/dream — Manually trigger Dream consolidation",
|
2026-04-02 18:39:57 +08:00
|
|
|
"/dream-log — Show what the last Dream changed",
|
|
|
|
|
"/dream-restore — Revert memory to a previous state",
|
2026-03-23 08:40:55 +00:00
|
|
|
"/help — Show available commands",
|
|
|
|
|
]
|
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-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-03-23 08:40:55 +00:00
|
|
|
router.exact("/help", cmd_help)
|