返回 JoyAI-Echo
builtin.py
1 """Built-in slash command handlers."""
2
3 from __future__ import annotations
4
5 import asyncio
6 import os
7 import sys
8
9 from nanobot import __version__
10 from nanobot.bus.events import OutboundMessage
11 from nanobot.command.router import CommandContext, CommandRouter
12 from nanobot.utils.helpers import build_status_content
13 from nanobot.utils.restart import set_restart_notice_to_env
14
15
16 async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
17 """Cancel all active tasks and subagents for the session."""
18 loop = ctx.loop
19 msg = ctx.msg
20 total = await loop._cancel_active_tasks(msg.session_key)
21 content = f"Stopped {total} task(s)." if total else "No active task to stop."
22 return OutboundMessage(
23 channel=msg.channel, chat_id=msg.chat_id, content=content,
24 metadata=dict(msg.metadata or {})
25 )
26
27
28 async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
29 """Restart the process in-place via os.execv."""
30 msg = ctx.msg
31 set_restart_notice_to_env(channel=msg.channel, chat_id=msg.chat_id)
32
33 async def _do_restart():
34 await asyncio.sleep(1)
35 os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
36
37 asyncio.create_task(_do_restart())
38 return OutboundMessage(
39 channel=msg.channel, chat_id=msg.chat_id, content="Restarting...",
40 metadata=dict(msg.metadata or {})
41 )
42
43
44 async def cmd_status(ctx: CommandContext) -> OutboundMessage:
45 """Build an outbound status message for a session."""
46 loop = ctx.loop
47 session = ctx.session or loop.sessions.get_or_create(ctx.key)
48 ctx_est = 0
49 try:
50 ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(session)
51 except Exception:
52 pass
53 if ctx_est <= 0:
54 ctx_est = loop._last_usage.get("prompt_tokens", 0)
55
56 # Fetch web search provider usage (best-effort, never blocks the response)
57 search_usage_text: str | None = None
58 try:
59 from nanobot.utils.searchusage import fetch_search_usage
60 web_cfg = getattr(loop, "web_config", None)
61 search_cfg = getattr(web_cfg, "search", None) if web_cfg else None
62 if search_cfg is not None:
63 provider = getattr(search_cfg, "provider", "duckduckgo")
64 api_key = getattr(search_cfg, "api_key", "") or None
65 usage = await fetch_search_usage(provider=provider, api_key=api_key)
66 search_usage_text = usage.format()
67 except Exception:
68 pass # Never let usage fetch break /status
69 active_tasks = loop._active_tasks.get(ctx.key, [])
70 task_count = sum(1 for t in active_tasks if not t.done())
71 try:
72 task_count += loop.subagents.get_running_count_by_session(ctx.key)
73 except Exception:
74 pass
75 return OutboundMessage(
76 channel=ctx.msg.channel,
77 chat_id=ctx.msg.chat_id,
78 content=build_status_content(
79 version=__version__, model=loop.model,
80 start_time=loop._start_time, last_usage=loop._last_usage,
81 context_window_tokens=loop.context_window_tokens,
82 session_msg_count=len(session.get_history(max_messages=0)),
83 context_tokens_estimate=ctx_est,
84 search_usage_text=search_usage_text,
85 active_task_count=task_count,
86 max_completion_tokens=getattr(
87 getattr(loop.provider, "generation", None), "max_tokens", 8192
88 ),
89 ),
90 metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
91 )
92
93
94 async def cmd_new(ctx: CommandContext) -> OutboundMessage:
95 """Stop active task and start a fresh session."""
96 loop = ctx.loop
97 await loop._cancel_active_tasks(ctx.key)
98 session = ctx.session or loop.sessions.get_or_create(ctx.key)
99 snapshot = session.messages[session.last_consolidated:]
100 session.clear()
101 loop.sessions.save(session)
102 loop.sessions.invalidate(session.key)
103 if snapshot:
104 loop._schedule_background(loop.consolidator.archive(snapshot))
105 return OutboundMessage(
106 channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
107 content="New session started.",
108 metadata=dict(ctx.msg.metadata or {})
109 )
110
111
112 async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
113 """Manually trigger a Dream consolidation run."""
114 import time
115
116 loop = ctx.loop
117 msg = ctx.msg
118
119 async def _run_dream():
120 t0 = time.monotonic()
121 try:
122 did_work = await loop.dream.run()
123 elapsed = time.monotonic() - t0
124 if did_work:
125 content = f"Dream completed in {elapsed:.1f}s."
126 else:
127 content = "Dream: nothing to process."
128 except Exception as e:
129 elapsed = time.monotonic() - t0
130 content = f"Dream failed after {elapsed:.1f}s: {e}"
131 await loop.bus.publish_outbound(OutboundMessage(
132 channel=msg.channel, chat_id=msg.chat_id, content=content,
133 ))
134
135 asyncio.create_task(_run_dream())
136 return OutboundMessage(
137 channel=msg.channel, chat_id=msg.chat_id, content="Dreaming...",
138 )
139
140
141 def _extract_changed_files(diff: str) -> list[str]:
142 """Extract changed file paths from a unified diff."""
143 files: list[str] = []
144 seen: set[str] = set()
145 for line in diff.splitlines():
146 if not line.startswith("diff --git "):
147 continue
148 parts = line.split()
149 if len(parts) < 4:
150 continue
151 path = parts[3]
152 if path.startswith("b/"):
153 path = path[2:]
154 if path in seen:
155 continue
156 seen.add(path)
157 files.append(path)
158 return files
159
160
161 def _format_changed_files(diff: str) -> str:
162 files = _extract_changed_files(diff)
163 if not files:
164 return "No tracked memory files changed."
165 return ", ".join(f"`{path}`" for path in files)
166
167
168 def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str:
169 files_line = _format_changed_files(diff)
170 lines = [
171 "## Dream Update",
172 "",
173 "Here is the selected Dream memory change." if requested_sha else "Here is the latest Dream memory change.",
174 "",
175 f"- Commit: `{commit.sha}`",
176 f"- Time: {commit.timestamp}",
177 f"- Changed files: {files_line}",
178 ]
179 if diff:
180 lines.extend([
181 "",
182 f"Use `/dream-restore {commit.sha}` to undo this change.",
183 "",
184 "```diff",
185 diff.rstrip(),
186 "```",
187 ])
188 else:
189 lines.extend([
190 "",
191 "Dream recorded this version, but there is no file diff to display.",
192 ])
193 return "\n".join(lines)
194
195
196 def _format_dream_restore_list(commits: list) -> str:
197 lines = [
198 "## Dream Restore",
199 "",
200 "Choose a Dream memory version to restore. Latest first:",
201 "",
202 ]
203 for c in commits:
204 lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}")
205 lines.extend([
206 "",
207 "Preview a version with `/dream-log <sha>` before restoring it.",
208 "Restore a version with `/dream-restore <sha>`.",
209 ])
210 return "\n".join(lines)
211
212
213 async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage:
214 """Show what the last Dream changed.
215
216 Default: diff of the latest commit (HEAD~1 vs HEAD).
217 With /dream-log <sha>: diff of that specific commit.
218 """
219 store = ctx.loop.consolidator.store
220 git = store.git
221
222 if not git.is_initialized():
223 if store.get_last_dream_cursor() == 0:
224 msg = "Dream has not run yet. Run `/dream`, or wait for the next scheduled Dream cycle."
225 else:
226 msg = "Dream history is not available because memory versioning is not initialized."
227 return OutboundMessage(
228 channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
229 content=msg, metadata={"render_as": "text"},
230 )
231
232 args = ctx.args.strip()
233
234 if args:
235 # Show diff of a specific commit
236 sha = args.split()[0]
237 result = git.show_commit_diff(sha)
238 if not result:
239 content = (
240 f"Couldn't find Dream change `{sha}`.\n\n"
241 "Use `/dream-restore` to list recent versions, "
242 "or `/dream-log` to inspect the latest one."
243 )
244 else:
245 commit, diff = result
246 content = _format_dream_log_content(commit, diff, requested_sha=sha)
247 else:
248 # Default: show the latest commit's diff
249 commits = git.log(max_entries=1)
250 result = git.show_commit_diff(commits[0].sha) if commits else None
251 if result:
252 commit, diff = result
253 content = _format_dream_log_content(commit, diff)
254 else:
255 content = "Dream memory has no saved versions yet."
256
257 return OutboundMessage(
258 channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
259 content=content, metadata={"render_as": "text"},
260 )
261
262
263 async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage:
264 """Restore memory files from a previous dream commit.
265
266 Usage:
267 /dream-restore — list recent commits
268 /dream-restore <sha> — revert a specific commit
269 """
270 store = ctx.loop.consolidator.store
271 git = store.git
272 if not git.is_initialized():
273 return OutboundMessage(
274 channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
275 content="Dream history is not available because memory versioning is not initialized.",
276 )
277
278 args = ctx.args.strip()
279 if not args:
280 # Show recent commits for the user to pick
281 commits = git.log(max_entries=10)
282 if not commits:
283 content = "Dream memory has no saved versions to restore yet."
284 else:
285 content = _format_dream_restore_list(commits)
286 else:
287 sha = args.split()[0]
288 result = git.show_commit_diff(sha)
289 changed_files = _format_changed_files(result[1]) if result else "the tracked memory files"
290 new_sha = git.revert(sha)
291 if new_sha:
292 content = (
293 f"Restored Dream memory to the state before `{sha}`.\n\n"
294 f"- New safety commit: `{new_sha}`\n"
295 f"- Restored files: {changed_files}\n\n"
296 f"Use `/dream-log {new_sha}` to inspect the restore diff."
297 )
298 else:
299 content = (
300 f"Couldn't restore Dream change `{sha}`.\n\n"
301 "It may not exist, or it may be the first saved version with no earlier state to restore."
302 )
303 return OutboundMessage(
304 channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
305 content=content, metadata={"render_as": "text"},
306 )
307
308
309 async def cmd_help(ctx: CommandContext) -> OutboundMessage:
310 """Return available slash commands."""
311 return OutboundMessage(
312 channel=ctx.msg.channel,
313 chat_id=ctx.msg.chat_id,
314 content=build_help_text(),
315 metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
316 )
317
318
319 def build_help_text() -> str:
320 """Build canonical help text shared across channels."""
321 lines = [
322 "🐈 nanobot commands:",
323 "/new — Stop current task and start a new conversation",
324 "/stop — Stop the current task",
325 "/restart — Restart the bot",
326 "/status — Show bot status",
327 "/dream — Manually trigger Dream consolidation",
328 "/dream-log — Show what the last Dream changed",
329 "/dream-restore — Revert memory to a previous state",
330 "/help — Show available commands",
331 ]
332 return "\n".join(lines)
333
334
335 def register_builtin_commands(router: CommandRouter) -> None:
336 """Register the default set of slash commands."""
337 router.priority("/stop", cmd_stop)
338 router.priority("/restart", cmd_restart)
339 router.priority("/status", cmd_status)
340 router.exact("/new", cmd_new)
341 router.exact("/status", cmd_status)
342 router.exact("/dream", cmd_dream)
343 router.exact("/dream-log", cmd_dream_log)
344 router.prefix("/dream-log ", cmd_dream_log)
345 router.exact("/dream-restore", cmd_dream_restore)
346 router.prefix("/dream-restore ", cmd_dream_restore)
347 router.exact("/help", cmd_help)
348
348 lines PYTHON