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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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]"
|