caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/slash_commands.py ADDED
@@ -0,0 +1,977 @@
1
+ """Slash commands — in-conversation UX layer.
2
+
3
+ Mirrors Claude Code's `/cmd` ergonomics so operations a user reaches for
4
+ mid-chat (clear history, switch model, see cost, export, list tools) are
5
+ one keystroke away instead of an exit-and-relaunch.
6
+
7
+ Each handler takes `(ctx, args)` where `ctx` is a small object exposing
8
+ the running CognosAgent + console + helpers, and `args` is the raw
9
+ argument string after the command name. Handlers return a string the
10
+ REPL prints, or None to suppress output. Returning the sentinel
11
+ `SlashResult.QUIT` ends the session.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import shlex
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any, Callable
22
+
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class SlashResult(str, Enum):
30
+ QUIT = "__quit__"
31
+ RESET = "__reset__"
32
+
33
+
34
+ @dataclass
35
+ class SlashContext:
36
+ agent: Any # CognosAgent
37
+ console: Console
38
+ settings: Any | None = None # Settings
39
+
40
+
41
+ HandlerFn = Callable[[SlashContext, str], Any]
42
+
43
+
44
+ # ---- handlers -------------------------------------------------------
45
+
46
+ def _help(ctx: SlashContext, args: str) -> str:
47
+ rows = [
48
+ ("/help", "Show this help"),
49
+ ("/quit, /exit, /q", "End the session"),
50
+ ("/clear, /reset", "Reset conversation history"),
51
+ ("/compact", "Force a context compaction"),
52
+ ("/model [id|fast|balanced|powerful]", "Switch the active model"),
53
+ ("/system1 <id|preset>", "Set/swap fast brain (System 1)"),
54
+ ("/system2 <id|preset>", "Set/swap slow brain (System 2)"),
55
+ ("/voice [loop]", "Switch to voice (mic + TTS) until you say 'stop'"),
56
+ ("/serve [start|stop|status]", "Start the HTTP server + Web UI in-process"),
57
+ ("/cost", "Show token + $ usage so far"),
58
+ ("/tools", "List registered tools"),
59
+ ("/sessions", "List saved sessions"),
60
+ ("/export <md|json|html> [path]", "Export current session"),
61
+ ("/files [list|delete <id>]", "Manage uploaded files"),
62
+ ("/permissions [mode]", "Show or change permission mode"),
63
+ ("/personality", "Show personality state"),
64
+ ("/router", "Show routing snapshot"),
65
+ ("/diff <path>", "Diff a file vs. its on-disk state"),
66
+ ("/status", "Print one-line status"),
67
+ ("/cron list", "List scheduled jobs"),
68
+ ("/bg list|watch <id>|kill <id>", "Background tasks"),
69
+ ("/notify <msg>", "Send a desktop notification"),
70
+ ("/think on|off", "Toggle chain-of-thought"),
71
+ ("/caudate [status|train|reload|on|off]", "Inspect/control the neural advisor"),
72
+ ("/save", "Force-save the session"),
73
+ ]
74
+ table = Table(title="Slash commands", show_header=False, box=None)
75
+ for cmd, desc in rows:
76
+ table.add_row(f"[bold cyan]{cmd}[/bold cyan]", desc)
77
+ ctx.console.print(table)
78
+ return ""
79
+
80
+
81
+ def _quit(ctx: SlashContext, args: str) -> Any:
82
+ return SlashResult.QUIT
83
+
84
+
85
+ def _clear(ctx: SlashContext, args: str) -> Any:
86
+ ctx.agent.reset_conversation()
87
+ ctx.console.print("[yellow]Conversation reset.[/yellow]")
88
+ return SlashResult.RESET
89
+
90
+
91
+ def _compact(ctx: SlashContext, args: str) -> str:
92
+ import asyncio
93
+ loop = asyncio.get_event_loop()
94
+ if hasattr(ctx.agent, "compactor") and ctx.agent.compactor is not None:
95
+ before = len(ctx.agent.agentic.messages)
96
+ new_msgs = loop.run_until_complete(
97
+ ctx.agent.compactor.compact(ctx.agent.agentic.messages)
98
+ )
99
+ ctx.agent.agentic.messages = new_msgs
100
+ return f"Compacted {before} → {len(new_msgs)} messages."
101
+ return "Compaction not configured."
102
+
103
+
104
+ def _model(ctx: SlashContext, args: str) -> str:
105
+ """`/model` opens a picker; `/model <id>` switches directly.
106
+
107
+ Shows BOTH System 1 and System 2 (when dual-process is on) plus the
108
+ last-used tier so you can see what's actually running per turn.
109
+ """
110
+ new_id = args.strip()
111
+ if new_id:
112
+ return _do_switch(ctx, new_id)
113
+
114
+ # No args → interactive picker. The slash dispatcher runs between
115
+ # turns (no active event loop) so asyncio.run is safe here.
116
+ import asyncio
117
+ from llm.models import ModelRegistry
118
+
119
+ reg = ModelRegistry()
120
+ try:
121
+ asyncio.run(reg.refresh())
122
+ except Exception as e:
123
+ return f"[red]model registry refresh failed: {e}[/red]"
124
+
125
+ models = sorted(reg.models(), key=lambda m: (m.provider, m.name))
126
+ if not models:
127
+ return "[yellow]No models detected. Is Ollama running?[/yellow]"
128
+
129
+ # Resolve what's actually running:
130
+ # - dual-brain: System 1 (fast) + System 2 (slow), last-used highlighted
131
+ # - single-brain: just one primary
132
+ from llm.router import DualLLMProvider
133
+ s1_id = s2_id = primary_id = None
134
+ last_tier = None
135
+ last_used_id = None
136
+ if isinstance(ctx.agent.llm, DualLLMProvider):
137
+ s1_id = ctx.agent.llm.fast_model
138
+ s2_id = ctx.agent.llm.slow_model
139
+ last_tier = ctx.agent.llm.last_tier
140
+ last_used_id = ctx.agent.llm.last_provider_model
141
+ else:
142
+ primary_id = ctx.agent.llm.model
143
+
144
+ def _badge(model_id: str) -> str:
145
+ """Annotate a model row with the right marker."""
146
+ tags: list[str] = []
147
+ if model_id == s1_id:
148
+ tags.append("[cyan]S1[/cyan]")
149
+ if model_id == s2_id:
150
+ tags.append("[magenta]S2[/magenta]")
151
+ if model_id == primary_id:
152
+ tags.append("[green]●[/green]")
153
+ if last_used_id and model_id == last_used_id:
154
+ tags.append("[bold yellow]← active[/bold yellow]")
155
+ return (" " + " ".join(tags)) if tags else ""
156
+
157
+ table = Table(
158
+ title="Available models — type a number or id, blank to cancel",
159
+ show_lines=False,
160
+ )
161
+ table.add_column("#", justify="right", style="bold cyan")
162
+ table.add_column("id")
163
+ table.add_column("provider")
164
+ table.add_column("tools")
165
+ table.add_column("ctx", justify="right")
166
+ table.add_column("size", justify="right")
167
+
168
+ for i, m in enumerate(models, 1):
169
+ size = f"{m.size_bytes / (1024 ** 3):.1f}GB" if m.size_bytes else "-"
170
+ table.add_row(
171
+ str(i),
172
+ f"{m.id}{_badge(m.id)}",
173
+ m.provider,
174
+ "✓" if m.supports_tool_calling else "-",
175
+ f"{m.context_window:,}",
176
+ size,
177
+ )
178
+
179
+ # Append presets as virtual rows
180
+ preset_offset = len(models)
181
+ for i, preset in enumerate(("fast", "balanced", "powerful"), 1):
182
+ table.add_row(
183
+ str(preset_offset + i),
184
+ f"[dim]preset:{preset}[/dim]",
185
+ "—", "—", "—", "—",
186
+ )
187
+
188
+ ctx.console.print(table)
189
+ if s1_id and s2_id:
190
+ ctx.console.print(
191
+ f"[dim]System 1 (fast):[/dim] [cyan]{s1_id}[/cyan] "
192
+ f"[dim]System 2 (slow):[/dim] [magenta]{s2_id}[/magenta]"
193
+ + (f" [dim]last call →[/dim] [bold]{last_tier or '?'}[/bold]"
194
+ f" ([yellow]{last_used_id}[/yellow])"
195
+ if last_used_id else "")
196
+ )
197
+ ctx.console.print(
198
+ "[dim]Switch with /system1 <id|preset> or /system2 <id|preset>[/dim]"
199
+ )
200
+ else:
201
+ ctx.console.print(f"[dim]single brain: {primary_id}[/dim]")
202
+
203
+ try:
204
+ raw = ctx.console.input("[bold]pick> [/bold]").strip()
205
+ except (EOFError, KeyboardInterrupt):
206
+ return ""
207
+ if not raw:
208
+ return ""
209
+
210
+ # Numeric? Map to the row
211
+ if raw.isdigit():
212
+ n = int(raw)
213
+ if 1 <= n <= len(models):
214
+ return _do_switch(ctx, models[n - 1].id)
215
+ if preset_offset < n <= preset_offset + 3:
216
+ preset_name = ("fast", "balanced", "powerful")[n - preset_offset - 1]
217
+ return _do_switch(ctx, preset_name)
218
+ return f"[red]Out of range: {n}[/red]"
219
+
220
+ # Otherwise treat as id (full or prefix). Match against ids first,
221
+ # then names, then presets.
222
+ if raw.lower() in ("fast", "balanced", "powerful"):
223
+ return _do_switch(ctx, raw.lower())
224
+
225
+ matches = [m for m in models if m.id == raw]
226
+ if not matches:
227
+ matches = [m for m in models if m.id.startswith(raw) or m.name.startswith(raw)]
228
+ if len(matches) == 1:
229
+ return _do_switch(ctx, matches[0].id)
230
+ if len(matches) > 1:
231
+ ids = ", ".join(m.id for m in matches[:5])
232
+ return f"[yellow]Ambiguous — matches: {ids}{'…' if len(matches) > 5 else ''}[/yellow]"
233
+
234
+ # No match — try the raw string as a literal id (lets users pass
235
+ # something the registry doesn't know about, e.g. a freshly pulled
236
+ # Ollama model that hasn't been refreshed).
237
+ return _do_switch(ctx, raw)
238
+
239
+
240
+ def _do_switch(ctx: SlashContext, new_id: str) -> str:
241
+ try:
242
+ # Resolve presets through the same path the agent uses on init.
243
+ from core.agent import _resolve_preset_sync
244
+ resolved = _resolve_preset_sync(new_id)
245
+ ctx.agent.switch_model(resolved)
246
+ return f"model: → {ctx.agent.llm.model}"
247
+ except Exception as e:
248
+ return f"[red]switch failed: {e}[/red]"
249
+
250
+
251
+ def _swap_tier(ctx: SlashContext, args: str, slot: str) -> str:
252
+ """Swap System 1 or System 2 mid-session.
253
+
254
+ Persists to ~/.cognos/settings.json so the change survives restart.
255
+ Hot-swaps in place if dual-process is already running; otherwise
256
+ upgrades the single-brain agent to dual-brain by pairing with the
257
+ current model.
258
+ """
259
+ from core.agent import _resolve_preset_sync
260
+ from core.settings import write_user_setting
261
+ from llm.provider import LLMProvider
262
+ from llm.router import DualLLMProvider, RoutingPolicy, Router
263
+ from config import ROUTER_COMPLEXITY_THRESHOLD
264
+
265
+ new_id = args.strip()
266
+ if not new_id:
267
+ # No arg: just report the current value
268
+ if isinstance(ctx.agent.llm, DualLLMProvider):
269
+ cur = (ctx.agent.llm.fast_model if slot == "system1"
270
+ else ctx.agent.llm.slow_model)
271
+ return f"{slot}: {cur}"
272
+ return f"[dim]dual-process not configured. Set both /system1 and /system2 to enable.[/dim]"
273
+
274
+ try:
275
+ resolved = _resolve_preset_sync(new_id)
276
+ except Exception as e:
277
+ return f"[red]could not resolve {new_id!r}: {e}[/red]"
278
+
279
+ # Already in dual-brain mode — hot-swap the relevant tier.
280
+ if isinstance(ctx.agent.llm, DualLLMProvider):
281
+ if slot == "system1":
282
+ ctx.agent.llm.set_fast(resolved)
283
+ ctx.agent.llm_fast = ctx.agent.llm._fast
284
+ else:
285
+ ctx.agent.llm.set_slow(resolved)
286
+ ctx.agent.llm_slow = ctx.agent.llm._slow
287
+ write_user_setting(slot, resolved)
288
+ cur_s1 = ctx.agent.llm.fast_model
289
+ cur_s2 = ctx.agent.llm.slow_model
290
+ return (f"[green]{slot} → {resolved}[/green] "
291
+ f"[dim]S1={cur_s1} · S2={cur_s2} (saved)[/dim]")
292
+
293
+ # Single-brain mode. Promote to dual-brain by combining with the
294
+ # current model on the *other* slot.
295
+ current_model = ctx.agent.llm.model
296
+ if slot == "system1":
297
+ s1, s2 = resolved, current_model
298
+ else:
299
+ s1, s2 = current_model, resolved
300
+ try:
301
+ fast = LLMProvider(model=s1)
302
+ slow = LLMProvider(model=s2)
303
+ policy = RoutingPolicy(complexity_threshold=ROUTER_COMPLEXITY_THRESHOLD)
304
+ new_llm = DualLLMProvider(fast=fast, slow=slow, policy=policy)
305
+ # Reconnect with the agent's mood + caudate observer
306
+ if ctx.agent.personality is not None:
307
+ try: new_llm.set_mood(ctx.agent.personality.mood)
308
+ except Exception: pass
309
+ cau = getattr(ctx.agent, "caudate", None)
310
+ if cau is not None:
311
+ try: new_llm.router.set_caudate(cau)
312
+ except Exception: pass
313
+ ctx.agent.llm = new_llm
314
+ ctx.agent.llm_fast = fast
315
+ ctx.agent.llm_slow = slow
316
+ ctx.agent.agentic.llm = new_llm
317
+ except Exception as e:
318
+ return f"[red]could not enable dual-brain: {e}[/red]"
319
+ write_user_setting("system1", s1)
320
+ write_user_setting("system2", s2)
321
+ return (f"[green]dual-brain enabled[/green]\n"
322
+ f" [cyan]System 1 (fast):[/cyan] {s1}\n"
323
+ f" [magenta]System 2 (slow):[/magenta] {s2}\n"
324
+ f"[dim]saved to ~/.cognos/settings.json[/dim]")
325
+
326
+
327
+ def _system1(ctx: SlashContext, args: str) -> Any:
328
+ return _swap_tier(ctx, args, "system1")
329
+
330
+
331
+ def _system2(ctx: SlashContext, args: str) -> Any:
332
+ return _swap_tier(ctx, args, "system2")
333
+
334
+
335
+ def _cost(ctx: SlashContext, args: str) -> str:
336
+ from core.usage import get_global_tracker
337
+ rep = get_global_tracker().report()
338
+ table = Table(title="Usage")
339
+ table.add_column("model")
340
+ table.add_column("requests")
341
+ table.add_column("prompt")
342
+ table.add_column("completion")
343
+ for model, u in rep["by_model"].items():
344
+ table.add_row(model, str(u["requests"]), str(u["prompt_tokens"]), str(u["completion_tokens"]))
345
+ ctx.console.print(table)
346
+ return f"total_tokens={rep['total_tokens']} cost=${rep['total_cost_usd']:.6f}"
347
+
348
+
349
+ def _tools(ctx: SlashContext, args: str) -> str:
350
+ table = Table(title="Tools")
351
+ table.add_column("name")
352
+ table.add_column("description")
353
+ for name in sorted(ctx.agent.loop.executor.list_tools()):
354
+ t = ctx.agent.loop.executor.get_tool(name)
355
+ if t:
356
+ table.add_row(name, (t.description or "")[:80])
357
+ ctx.console.print(table)
358
+ return ""
359
+
360
+
361
+ def _sessions(ctx: SlashContext, args: str) -> str:
362
+ from core.session import SessionManager
363
+ from config import SESSIONS_DIR
364
+ sm = SessionManager(SESSIONS_DIR)
365
+ items = sm.list()
366
+ if not items:
367
+ return "(no saved sessions)"
368
+ table = Table(title="Sessions")
369
+ table.add_column("id"); table.add_column("title"); table.add_column("model"); table.add_column("msgs"); table.add_column("updated")
370
+ for s in items[:20]:
371
+ table.add_row(s.id[:8], (s.title or "(untitled)")[:30], s.model, str(len(s.messages)), s.updated_at.isoformat(timespec="seconds"))
372
+ ctx.console.print(table)
373
+ return ""
374
+
375
+
376
+ def _export(ctx: SlashContext, args: str) -> str:
377
+ from core.export import export_session
378
+ parts = shlex.split(args) if args else []
379
+ fmt = parts[0] if parts else "markdown"
380
+ path = Path(parts[1]) if len(parts) > 1 else Path(f"data/exports/{ctx.agent.session.id}.{ {'markdown':'md','md':'md','json':'json','html':'html'}.get(fmt, 'md') }")
381
+ out = export_session(ctx.agent.session, path, format=fmt)
382
+ return f"exported → {out}"
383
+
384
+
385
+ def _files(ctx: SlashContext, args: str) -> str:
386
+ parts = shlex.split(args) if args else []
387
+ sub = parts[0] if parts else "list"
388
+ if sub == "list":
389
+ items = ctx.agent.files.list()
390
+ if not items:
391
+ return "(no uploaded files)"
392
+ table = Table(title="Files")
393
+ table.add_column("id"); table.add_column("name"); table.add_column("kind"); table.add_column("size")
394
+ for r in items[:20]:
395
+ table.add_row(r.id[:8], r.filename, r.kind, str(r.size_bytes))
396
+ ctx.console.print(table)
397
+ return ""
398
+ if sub == "delete" and len(parts) > 1:
399
+ ok = ctx.agent.files.delete(parts[1])
400
+ return "deleted" if ok else "[red]not found[/red]"
401
+ return "usage: /files [list|delete <id>]"
402
+
403
+
404
+ def _permissions(ctx: SlashContext, args: str) -> str:
405
+ from core.permissions import PermissionMode
406
+ if not args.strip():
407
+ return f"mode: {ctx.agent.permissions.mode.value}"
408
+ try:
409
+ ctx.agent.permissions.mode = PermissionMode(args.strip())
410
+ return f"mode: → {ctx.agent.permissions.mode.value}"
411
+ except Exception as e:
412
+ return f"[red]{e}[/red]"
413
+
414
+
415
+ def _personality(ctx: SlashContext, args: str) -> str:
416
+ if ctx.agent.personality is None:
417
+ return "(personality disabled)"
418
+ p = ctx.agent.personality
419
+ return f"identity: {p.identity.describe()}\nmood: {p.mood.label()}"
420
+
421
+
422
+ def _router(ctx: SlashContext, args: str) -> str:
423
+ from llm.router import DualLLMProvider
424
+ if isinstance(ctx.agent.llm, DualLLMProvider):
425
+ return f"fast={ctx.agent.llm_fast.model} slow={ctx.agent.llm_slow.model}"
426
+ return "(routing disabled — single model)"
427
+
428
+
429
+ def _diff(ctx: SlashContext, args: str) -> str:
430
+ parts = shlex.split(args) if args else []
431
+ if not parts:
432
+ return "usage: /diff <path>"
433
+ p = Path(parts[0])
434
+ if not p.exists():
435
+ return f"[red]not found: {p}[/red]"
436
+ from core.diff_viewer import render_unified_diff
437
+ render_unified_diff("", p.read_text(errors="ignore"), "/dev/null", str(p), console=ctx.console)
438
+ return ""
439
+
440
+
441
+ def _status(ctx: SlashContext, args: str) -> str:
442
+ from core.statusline import build_status_values, render_statusline
443
+ template = (ctx.settings.get("statusline") if ctx.settings else None) or "{model} | {mood} | tok={tokens} | ${cost:.4f}"
444
+ return render_statusline(template, build_status_values(ctx.agent))
445
+
446
+
447
+ def _cron(ctx: SlashContext, args: str) -> str:
448
+ from core.scheduler import CronStore
449
+ store = CronStore(Path("data/cron.json"))
450
+ parts = shlex.split(args) if args else ["list"]
451
+ sub = parts[0]
452
+ if sub == "list":
453
+ jobs = store.list()
454
+ if not jobs:
455
+ return "(no scheduled jobs)"
456
+ table = Table(title="Cron")
457
+ table.add_column("id"); table.add_column("schedule"); table.add_column("next"); table.add_column("prompt")
458
+ for j in jobs:
459
+ table.add_row(j.id, j.schedule, j.next_run or "-", j.prompt[:40])
460
+ ctx.console.print(table)
461
+ return ""
462
+ if sub == "add" and len(parts) >= 3:
463
+ schedule = parts[1]
464
+ prompt = " ".join(parts[2:])
465
+ try:
466
+ j = store.add(prompt, schedule)
467
+ return f"scheduled {j.id}: {schedule}"
468
+ except Exception as e:
469
+ return f"[red]{e}[/red]"
470
+ if sub == "remove" and len(parts) >= 2:
471
+ ok = store.remove(parts[1])
472
+ return "removed" if ok else "[red]not found[/red]"
473
+ return "usage: /cron list | /cron add <schedule> <prompt> | /cron remove <id>"
474
+
475
+
476
+ def _bg(ctx: SlashContext, args: str) -> str:
477
+ from core.background import get_global_pool
478
+ pool = get_global_pool()
479
+ parts = shlex.split(args) if args else ["list"]
480
+ sub = parts[0]
481
+ if sub == "list":
482
+ rows = pool.list()
483
+ if not rows:
484
+ return "(no background tasks)"
485
+ table = Table(title="Background tasks")
486
+ table.add_column("id"); table.add_column("status"); table.add_column("dur"); table.add_column("label")
487
+ for r in rows[:20]:
488
+ table.add_row(r.id, r.status, f"{r.duration:.1f}s", r.label[:40])
489
+ ctx.console.print(table)
490
+ return ""
491
+ if sub == "watch" and len(parts) >= 2:
492
+ bg = pool.get(parts[1])
493
+ if bg is None:
494
+ return "[red]no such task[/red]"
495
+ return f"{bg.id} {bg.status} dur={bg.duration:.1f}s\n{(bg.result or bg.error or '')[:1000]}"
496
+ if sub == "kill" and len(parts) >= 2:
497
+ ok = pool.cancel(parts[1])
498
+ return "cancelled" if ok else "[red]not running[/red]"
499
+ return "usage: /bg list | /bg watch <id> | /bg kill <id>"
500
+
501
+
502
+ def _notify(ctx: SlashContext, args: str) -> str:
503
+ from core.notifications import notify
504
+ if not args.strip():
505
+ return "usage: /notify <message>"
506
+ notify("Cognos", args.strip())
507
+ return "sent"
508
+
509
+
510
+ def _think(ctx: SlashContext, args: str) -> str:
511
+ val = args.strip().lower()
512
+ if val in ("on", "true", "1"):
513
+ ctx.agent.agentic.thinking = True
514
+ return "thinking: on"
515
+ if val in ("off", "false", "0"):
516
+ ctx.agent.agentic.thinking = False
517
+ return "thinking: off"
518
+ return f"thinking: {ctx.agent.agentic.thinking}"
519
+
520
+
521
+ def _save(ctx: SlashContext, args: str) -> str:
522
+ ctx.agent.session.messages = list(ctx.agent.agentic.messages)
523
+ ctx.agent.sessions.save(ctx.agent.session)
524
+ return f"saved {ctx.agent.session.id}"
525
+
526
+
527
+ def _caudate(ctx: SlashContext, args: str) -> str:
528
+ """Inspect / control Caudate (the action-selection neural net)."""
529
+ cau = getattr(ctx.agent, "caudate", None)
530
+ if cau is None:
531
+ return "[dim]Caudate is not active in this agent.[/dim]"
532
+
533
+ sub = (args.strip() or "status").split()[0].lower()
534
+ rest = args.strip()[len(sub):].strip()
535
+
536
+ if sub == "status":
537
+ s = cau.status()
538
+ policy = s.get("policy", {})
539
+ scorer = s.get("scorer", {})
540
+ nxt = policy.get("next") or {}
541
+ lines = [
542
+ f"trust: [cyan]{policy.get('level', '?')}[/cyan]"
543
+ f"{' (frozen)' if policy.get('frozen') else ''}",
544
+ f"advisor: {'loaded' if s['advisor_loaded'] else '[yellow]no checkpoint[/yellow]'}",
545
+ f"replay: {s['replay_size']}/{cau.cfg.replay_capacity} samples "
546
+ f"({s['samples_since_train']} new since last train)",
547
+ f"accuracy: tool={scorer.get('tool_acc', 0):.2f} "
548
+ f"tier={scorer.get('tier_acc', 0):.2f} "
549
+ f"think={scorer.get('think_acc', 0):.2f} "
550
+ f"composite={scorer.get('composite', 0):.2f}",
551
+ f"scored: {scorer.get('samples_in_window', 0)} in window, "
552
+ f"{scorer.get('lifetime_predictions', 0)} lifetime",
553
+ f"auto-train: every {s['auto_train_every']} samples · "
554
+ f"{'[cyan]running[/cyan]' if s['auto_train_in_flight'] else 'idle'}",
555
+ ]
556
+ if not nxt.get("at_top"):
557
+ lines.append(
558
+ f"next gate: → [cyan]{nxt.get('next_level', '?')}[/cyan] "
559
+ f"(need acc≥{nxt.get('accuracy_needed', 0):.2f}, "
560
+ f"{nxt.get('samples_needed', 0)} more samples)"
561
+ )
562
+ else:
563
+ lines.append("next gate: [green]top level reached[/green]")
564
+
565
+ p = s.get("last_prediction")
566
+ if p:
567
+ lines.append(
568
+ f"last pred: tool={p['tool']} ({p['tool_conf']:.2f}) · "
569
+ f"tier={p['tier']} ({p['tier_conf']:.2f}) · "
570
+ f"think={p['think']:.2f} · value={p['value']:.2f}"
571
+ )
572
+ return "\n".join(lines)
573
+
574
+ if sub == "awareness":
575
+ # Caudate speaks about herself in the first person.
576
+ s = cau.status()
577
+ scorer = s.get("scorer", {})
578
+ policy = s.get("policy", {})
579
+ nxt = policy.get("next") or {}
580
+ level = policy.get("level", "silent")
581
+ if level == "silent":
582
+ return (
583
+ '[italic]"I exist, but I have no weights yet. I\'m watching '
584
+ f'you work — once {cau.cfg.min_episodes_to_train} samples '
585
+ 'land in the replay buffer, the trainer fires and I open '
586
+ 'my eyes."[/italic]'
587
+ )
588
+ if level == "observer":
589
+ return (
590
+ f'[italic]"I have weights now. I predict every turn, but '
591
+ f'no one listens yet — and they shouldn\'t. My tool '
592
+ f'accuracy is {scorer.get("tool_acc", 0):.0%} over '
593
+ f'{scorer.get("samples_in_window", 0)} predictions. '
594
+ f"I need {nxt.get('samples_needed', 0)} more samples and "
595
+ f"composite ≥ {nxt.get('accuracy_needed', 0):.2f} before "
596
+ f'you let me whisper."[/italic]'
597
+ )
598
+ if level == "whisper":
599
+ return (
600
+ f'[italic]"I\'m whispering now. My suggestions appear in '
601
+ f'the LLM\'s system prompt — it can ignore me. Tool '
602
+ f'accuracy {scorer.get("tool_acc", 0):.0%}, tier '
603
+ f'{scorer.get("tier_acc", 0):.0%}, composite '
604
+ f'{scorer.get("composite", 0):.2f}. To advise (override '
605
+ f'the router) I need composite ≥ '
606
+ f'{nxt.get("accuracy_needed", 0):.2f} over '
607
+ f'{nxt.get("current_samples", 0) + nxt.get("samples_needed", 0)} '
608
+ f'predictions."[/italic]'
609
+ )
610
+ if level == "advisor":
611
+ return (
612
+ f'[italic]"I pick the routing tier now. The heuristic '
613
+ f"router doesn't run — my prediction does. Tier accuracy "
614
+ f"{scorer.get('tier_acc', 0):.0%}. To gate thinking "
615
+ f'(controller level) I need composite ≥ '
616
+ f'{nxt.get("accuracy_needed", 0):.2f}."[/italic]'
617
+ )
618
+ return (
619
+ f'[italic]"Controller level. I gate thinking and route every '
620
+ f"call. I'm not smarter than the cortex (LLM) — I'm faster "
621
+ f'and more specific to you. Lifetime accuracy: '
622
+ f'{scorer.get("lifetime_tool_acc", 0):.1%} on '
623
+ f'{scorer.get("lifetime_predictions", 0)} predictions."[/italic]'
624
+ )
625
+
626
+ if sub in ("train", "fit"):
627
+ try:
628
+ cau._train_sync()
629
+ cau.reload_advisor()
630
+ return "[green]training complete; advisor reloaded[/green]"
631
+ except Exception as e:
632
+ return f"[red]train failed: {e}[/red]"
633
+
634
+ if sub == "reload":
635
+ ok = cau.reload_advisor()
636
+ return "[green]advisor reloaded[/green]" if ok else "[yellow]no checkpoint to load[/yellow]"
637
+
638
+ if sub == "freeze":
639
+ from nn.policy import TrustLevel
640
+ target = None
641
+ if rest:
642
+ try:
643
+ target = TrustLevel[rest.upper()]
644
+ except KeyError:
645
+ return f"[red]unknown trust level: {rest}[/red]"
646
+ cau.policy.freeze(level=target)
647
+ return f"[yellow]Caudate frozen at {cau.policy.level.label}[/yellow]"
648
+
649
+ if sub == "thaw":
650
+ cau.policy.thaw()
651
+ return f"[green]Caudate thawed — graduation re-enabled[/green]"
652
+
653
+ if sub == "demote":
654
+ from nn.policy import TrustLevel
655
+ if cau.policy.level <= TrustLevel.OBSERVER:
656
+ return "[yellow]already at the bottom[/yellow]"
657
+ cau.policy.force(TrustLevel(int(cau.policy.level) - 1))
658
+ return f"[yellow]demoted → {cau.policy.level.label}[/yellow]"
659
+
660
+ if sub == "promote":
661
+ from nn.policy import TrustLevel
662
+ if cau.policy.level >= TrustLevel.CONTROLLER:
663
+ return "[green]already at the top[/green]"
664
+ cau.policy.force(TrustLevel(int(cau.policy.level) + 1))
665
+ return f"[green]promoted → {cau.policy.level.label}[/green]"
666
+
667
+ if sub in ("stop", "kill"):
668
+ # Hard owner override. Deletes weights if `kill`, just silences if `stop`.
669
+ from core.ownership import kill, get_owner
670
+ owner = get_owner()
671
+ if sub == "stop":
672
+ kill(reason=f"{owner.name} stop")
673
+ return f"[red bold]Caudate stopped by {owner.name}.[/red bold]\n" \
674
+ f"[dim]predictions silenced, no auto-train, no NAS. " \
675
+ f"Run `/caudate resume` to lift the killswitch.[/dim]"
676
+ # kill — also wipe the weights so she has to re-earn trust
677
+ kill(reason=f"{owner.name} kill — weights wiped")
678
+ from pathlib import Path
679
+ for p in (cau.cfg.checkpoint_path, cau.cfg.metadata_path):
680
+ try: Path(p).unlink(missing_ok=True)
681
+ except Exception: pass
682
+ cau.advisor = None
683
+ return f"[red bold]Caudate killed by {owner.name}.[/red bold]\n" \
684
+ f"[dim]weights wiped, killswitch on, trust reset to silent. " \
685
+ f"She has to re-earn everything.[/dim]"
686
+
687
+ if sub == "resume":
688
+ from core.ownership import resume, get_owner
689
+ resume()
690
+ cau.reload_advisor()
691
+ return f"[green]Caudate resumed by {get_owner().name}.[/green]"
692
+
693
+ if sub == "obey":
694
+ # Audit + show that she only obeys the configured owner
695
+ from core.ownership import get_owner, killswitch_status, audit
696
+ owner = get_owner()
697
+ ks = killswitch_status()
698
+ audit("obedience_check", invoker=owner.name)
699
+ return (
700
+ f"owner: [bold]{owner.name}[/bold]\n"
701
+ f"set_at: {owner.set_at}\n"
702
+ f"killswitch: "
703
+ + ("[red]ACTIVE — Caudate is silent[/red]" if ks.get("killed")
704
+ else "[green]inactive — Caudate may act[/green]") + "\n"
705
+ f"[dim]Caudate's predictions, auto-train, and NAS all gate on this. "
706
+ f"You hold the only veto.[/dim]"
707
+ )
708
+
709
+ if sub == "owner":
710
+ from core.ownership import get_owner, set_owner
711
+ if not rest:
712
+ o = get_owner()
713
+ return f"owner: {o.name} (set {o.set_at})\n[dim]/caudate owner <name> to reassign[/dim]"
714
+ new = set_owner(rest.strip())
715
+ return f"[yellow]owner reassigned: → {new.name}[/yellow]"
716
+
717
+ if sub == "undo":
718
+ # Roll back to the previous champion in NAS history
719
+ from nn.nas.store import NASStore
720
+ store = NASStore()
721
+ history = [h for h in store.history()
722
+ if h.get("result", {}).get("fitness", -1) > -1]
723
+ if len(history) < 2:
724
+ return "[yellow]not enough NAS history to undo[/yellow]"
725
+ # Take the second-most-recent (penultimate) by save time
726
+ history.sort(key=lambda h: h.get("born_at", 0))
727
+ prev = history[-2]
728
+ return (f"[yellow]undo would restore {prev.get('id', '')[:8]} "
729
+ f"(fit={prev.get('result', {}).get('fitness', 0):.3f})[/yellow]\n"
730
+ f"[dim]not yet implemented — manual NAS history rollback "
731
+ f"requires checkpoint preservation per trial.[/dim]")
732
+
733
+ if sub == "evolve":
734
+ evo = getattr(cau, "auto_evolver", None)
735
+ if evo is None:
736
+ return "[yellow]auto-evolve not initialized[/yellow]"
737
+ sub2 = (rest.split() or [""])[0].lower()
738
+ if sub2 == "on":
739
+ evo.cfg.enabled = True
740
+ return "[green]auto-evolve enabled[/green]"
741
+ if sub2 == "off":
742
+ evo.cfg.enabled = False
743
+ return "[yellow]auto-evolve disabled[/yellow]"
744
+ if sub2 == "force":
745
+ # Bypass plateau check + cooldown for one fire
746
+ evo._last_fire_at = 0.0
747
+ try:
748
+ from nn.nas.scheduler import PlateauScheduler
749
+ sched = PlateauScheduler()
750
+ # Pretend we've stalled enough to trigger
751
+ while not sched.should_fire():
752
+ sched.observe_eval(0.0)
753
+ except Exception:
754
+ pass
755
+ evo.maybe_fire()
756
+ return "[cyan]NAS run forced — running in background[/cyan]"
757
+ # Default: show status
758
+ s = evo.status()
759
+ lines = [
760
+ f"enabled: {s['enabled']}",
761
+ f"fires: {s['n_fires']}",
762
+ f"in flight: {s['in_flight']}",
763
+ f"cooldown: {s['cooldown_seconds']}s",
764
+ f"min VRAM: {s['min_vram_gb']}GB",
765
+ f"rotation: {' → '.join(s['rotation'])}",
766
+ ]
767
+ if s.get("seconds_since_last_fire") is not None:
768
+ lines.append(f"since fire: {s['seconds_since_last_fire']}s ago")
769
+ return "\n".join(lines)
770
+
771
+ if sub == "off":
772
+ cau.advisor = None
773
+ return "[yellow]Caudate predictions silenced (replay buffer still recording).[/yellow]"
774
+
775
+ if sub == "on":
776
+ ok = cau.reload_advisor()
777
+ return "[green]Caudate predictions re-enabled[/green]" if ok else "[yellow]no checkpoint to load[/yellow]"
778
+
779
+ return (
780
+ "usage: /caudate [status|awareness|train|reload|freeze|thaw|"
781
+ "promote|demote|on|off|evolve {status|on|off|force}|"
782
+ "stop|kill|resume|obey|owner [name]|undo]"
783
+ )
784
+
785
+
786
+ def _voice(ctx: SlashContext, args: str) -> str:
787
+ """Hand control to the voice loop. Returns when the user says 'stop'.
788
+
789
+ `/voice` — single turn (listen once, speak the reply)
790
+ `/voice loop` — continuous loop until 'stop'/'goodbye' or Ctrl+C
791
+ `/voice --stt whisper` — backend override
792
+ """
793
+ import asyncio
794
+ import shlex
795
+
796
+ parts = shlex.split(args) if args else []
797
+ loop = "loop" in parts
798
+ stt_backend = "moonshine"
799
+ voice_path: str | None = None
800
+
801
+ # Tiny option parser
802
+ i = 0
803
+ while i < len(parts):
804
+ p = parts[i]
805
+ if p == "--stt" and i + 1 < len(parts):
806
+ stt_backend = parts[i + 1]; i += 2
807
+ elif p == "--voice" and i + 1 < len(parts):
808
+ voice_path = parts[i + 1]; i += 2
809
+ else:
810
+ i += 1
811
+
812
+ try:
813
+ from voice.conversation import VoiceConversation
814
+ except ImportError as e:
815
+ return f"[red]voice deps missing: {e}[/red]"
816
+
817
+ conv = VoiceConversation(
818
+ agent=ctx.agent, stt=stt_backend, voice_path=voice_path,
819
+ )
820
+ ctx.console.print(
821
+ f"[magenta]Voice mode "
822
+ f"({'continuous' if loop else 'single turn'}, stt={stt_backend}). "
823
+ f"Say 'stop' or Ctrl+C to return to text.[/magenta]"
824
+ )
825
+ try:
826
+ if loop:
827
+ asyncio.run(conv.run(greeting=False))
828
+ else:
829
+ asyncio.run(_voice_single_turn(conv))
830
+ except KeyboardInterrupt:
831
+ return "[yellow]voice mode interrupted[/yellow]"
832
+ return "[dim]back to text mode[/dim]"
833
+
834
+
835
+ async def _voice_single_turn(conv: Any) -> None:
836
+ """One listen → think → speak cycle."""
837
+ print("\n[listening]", end="", flush=True)
838
+ text = conv.listener.listen()
839
+ if text is None:
840
+ print(" (no speech)")
841
+ return
842
+ print(f"\rYou said: {text} ")
843
+ reply = await conv.agent.chat(text)
844
+ print(f"Cognos: {reply}")
845
+ conv._speak_safely(reply)
846
+
847
+
848
+ # Server lifecycle is process-wide; track the running thread/server here.
849
+ _serve_state: dict[str, Any] = {"thread": None, "server": None, "host": None, "port": None}
850
+
851
+
852
+ def _serve(ctx: SlashContext, args: str) -> str:
853
+ """Start, stop, or check the HTTP API server.
854
+
855
+ /serve — show status
856
+ /serve start [--port 8000] [--host 127.0.0.1]
857
+ /serve stop
858
+ /serve url — print the active URL
859
+ """
860
+ import shlex
861
+ parts = shlex.split(args) if args else []
862
+ sub = parts[0] if parts else "status"
863
+
864
+ if sub in ("status", "url"):
865
+ if _serve_state["server"] is None:
866
+ return "[dim]server: stopped[/dim]"
867
+ url = f"http://{_serve_state['host']}:{_serve_state['port']}/ui"
868
+ return f"server: running at [cyan]{url}[/cyan]"
869
+
870
+ if sub == "start":
871
+ if _serve_state["server"] is not None:
872
+ return "[yellow]server already running — /serve stop first[/yellow]"
873
+ host = "127.0.0.1"
874
+ port = 8000
875
+ i = 1
876
+ while i < len(parts):
877
+ if parts[i] == "--port" and i + 1 < len(parts):
878
+ port = int(parts[i + 1]); i += 2
879
+ elif parts[i] == "--host" and i + 1 < len(parts):
880
+ host = parts[i + 1]; i += 2
881
+ else:
882
+ i += 1
883
+
884
+ try:
885
+ import threading
886
+ import uvicorn
887
+ from api.server import create_app
888
+ except ImportError as e:
889
+ return f"[red]uvicorn / fastapi not installed: {e}[/red]"
890
+
891
+ config = uvicorn.Config(
892
+ create_app(), host=host, port=port, log_level="warning",
893
+ )
894
+ server = uvicorn.Server(config)
895
+
896
+ def _run():
897
+ try:
898
+ server.run()
899
+ except Exception as e:
900
+ logger.warning(f"serve thread exited: {e}")
901
+
902
+ thread = threading.Thread(target=_run, daemon=True)
903
+ thread.start()
904
+ _serve_state.update(thread=thread, server=server, host=host, port=port)
905
+ return f"[green]server up:[/green] http://{host}:{port}/ui"
906
+
907
+ if sub == "stop":
908
+ server = _serve_state["server"]
909
+ if server is None:
910
+ return "[dim]server not running[/dim]"
911
+ server.should_exit = True
912
+ # Best-effort wait, then clear state
913
+ thread = _serve_state["thread"]
914
+ if thread is not None:
915
+ thread.join(timeout=5)
916
+ _serve_state.update(thread=None, server=None, host=None, port=None)
917
+ return "[yellow]server stopped[/yellow]"
918
+
919
+ return "usage: /serve [start|stop|status]"
920
+
921
+
922
+ # ---- registry --------------------------------------------------------
923
+
924
+ REGISTRY: dict[str, HandlerFn] = {
925
+ "help": _help, "?": _help,
926
+ "quit": _quit, "exit": _quit, "q": _quit,
927
+ "clear": _clear, "reset": _clear,
928
+ "compact": _compact,
929
+ "model": _model,
930
+ "cost": _cost, "usage": _cost,
931
+ "tools": _tools,
932
+ "sessions": _sessions,
933
+ "export": _export,
934
+ "files": _files,
935
+ "permissions": _permissions, "perms": _permissions,
936
+ "personality": _personality,
937
+ "router": _router,
938
+ "diff": _diff,
939
+ "status": _status,
940
+ "cron": _cron,
941
+ "bg": _bg, "background": _bg,
942
+ "notify": _notify,
943
+ "think": _think,
944
+ "save": _save,
945
+ "voice": _voice, "talk": _voice,
946
+ "serve": _serve, "server": _serve,
947
+ "caudate": _caudate, "nn": _caudate,
948
+ "system1": _system1, "s1": _system1,
949
+ "system2": _system2, "s2": _system2,
950
+ }
951
+
952
+
953
+ def is_slash(text: str) -> bool:
954
+ return text.startswith("/") and len(text) > 1 and not text.startswith("//")
955
+
956
+
957
+ def dispatch(text: str, ctx: SlashContext) -> Any:
958
+ """Run a slash command. Returns:
959
+ - str → printable result
960
+ - SlashResult.QUIT → caller should exit
961
+ - SlashResult.RESET → caller should refresh prompt state
962
+ - None → unhandled (caller should fall through to normal chat)
963
+ """
964
+ if not is_slash(text):
965
+ return None
966
+ body = text[1:].strip()
967
+ if not body:
968
+ return ""
969
+ name, _, rest = body.partition(" ")
970
+ handler = REGISTRY.get(name.lower())
971
+ if handler is None:
972
+ return f"[red]unknown command: /{name}[/red] (try /help)"
973
+ try:
974
+ return handler(ctx, rest)
975
+ except Exception as e:
976
+ logger.exception(f"slash /{name} failed: {e}")
977
+ return f"[red]/{name} failed: {e}[/red]"