elidia-agent-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 (93) hide show
  1. elidia/__init__.py +0 -0
  2. elidia/__main__.py +4 -0
  3. elidia/agent/__init__.py +3 -0
  4. elidia/agent/loop.py +449 -0
  5. elidia/agent/personas.py +130 -0
  6. elidia/agent/portal.py +115 -0
  7. elidia/api/__init__.py +0 -0
  8. elidia/api/client.py +221 -0
  9. elidia/api/streaming.py +55 -0
  10. elidia/auth/__init__.py +0 -0
  11. elidia/auth/keychain.py +86 -0
  12. elidia/cache/__init__.py +1 -0
  13. elidia/cache/lru.py +158 -0
  14. elidia/cli/__init__.py +0 -0
  15. elidia/cli/commands.py +122 -0
  16. elidia/cli/main.py +293 -0
  17. elidia/cli/pager.py +104 -0
  18. elidia/cli/progress.py +173 -0
  19. elidia/cli/renderer.py +32 -0
  20. elidia/cli/repl.py +1234 -0
  21. elidia/cli/themes.py +214 -0
  22. elidia/config/__init__.py +0 -0
  23. elidia/config/defaults.py +5 -0
  24. elidia/config/rules.py +48 -0
  25. elidia/config/settings.py +217 -0
  26. elidia/creative/__init__.py +1 -0
  27. elidia/creative/audio.py +213 -0
  28. elidia/creative/display.py +260 -0
  29. elidia/creative/image.py +142 -0
  30. elidia/creative/local.py +163 -0
  31. elidia/creative/video.py +181 -0
  32. elidia/daemon/__init__.py +0 -0
  33. elidia/daemon/manager.py +349 -0
  34. elidia/db/__init__.py +0 -0
  35. elidia/db/database.py +87 -0
  36. elidia/mcp/__init__.py +0 -0
  37. elidia/mcp/client.py +198 -0
  38. elidia/mcp/config.py +93 -0
  39. elidia/mcp/registry.py +101 -0
  40. elidia/mcp/types.py +43 -0
  41. elidia/memory/__init__.py +3 -0
  42. elidia/memory/auto.py +164 -0
  43. elidia/memory/compaction.py +101 -0
  44. elidia/memory/embeddings.py +47 -0
  45. elidia/memory/outcomes.py +131 -0
  46. elidia/memory/patterns.py +79 -0
  47. elidia/memory/store.py +338 -0
  48. elidia/models/__init__.py +0 -0
  49. elidia/models/adaptive.py +53 -0
  50. elidia/models/router.py +115 -0
  51. elidia/modes/__init__.py +1 -0
  52. elidia/modes/autonomous.py +318 -0
  53. elidia/modes/budget.py +182 -0
  54. elidia/modes/classifier.py +169 -0
  55. elidia/modes/consensus.py +210 -0
  56. elidia/modes/deep_think.py +190 -0
  57. elidia/modes/swarm.py +229 -0
  58. elidia/modes/thinking.py +153 -0
  59. elidia/permissions/__init__.py +0 -0
  60. elidia/permissions/audit.py +138 -0
  61. elidia/permissions/manager.py +199 -0
  62. elidia/permissions/trust.py +63 -0
  63. elidia/rag/__init__.py +4 -0
  64. elidia/rag/chunker.py +216 -0
  65. elidia/rag/engine.py +345 -0
  66. elidia/rag/ingest.py +154 -0
  67. elidia/rag/portal_bridge.py +84 -0
  68. elidia/rag/watcher.py +98 -0
  69. elidia/research/__init__.py +1 -0
  70. elidia/research/export.py +237 -0
  71. elidia/research/orchestrator.py +310 -0
  72. elidia/research/sources.py +223 -0
  73. elidia/session/__init__.py +0 -0
  74. elidia/session/history.py +119 -0
  75. elidia/session/manager.py +82 -0
  76. elidia/tools/__init__.py +28 -0
  77. elidia/tools/base.py +70 -0
  78. elidia/tools/fetch.py +100 -0
  79. elidia/tools/filesystem.py +204 -0
  80. elidia/tools/git.py +156 -0
  81. elidia/tools/search.py +99 -0
  82. elidia/tools/terminal.py +77 -0
  83. elidia/widgets/__init__.py +1 -0
  84. elidia/widgets/protocol.py +248 -0
  85. elidia/widgets/renderer.py +225 -0
  86. elidia/workflow/__init__.py +1 -0
  87. elidia/workflow/engine.py +377 -0
  88. elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
  89. elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
  90. elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  91. elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
  93. elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
elidia/cli/repl.py ADDED
@@ -0,0 +1,1234 @@
1
+ import logging
2
+ import sys
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from prompt_toolkit import PromptSession
7
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
8
+ from prompt_toolkit.history import FileHistory
9
+ from rich.console import Console
10
+ from rich.live import Live
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+
15
+ from elidia.agent.loop import AgentLoop
16
+ from elidia.agent.personas import PersonaEngine
17
+ from elidia.api.client import AiUtilsClient, ChatMessage
18
+ from elidia.auth.keychain import get_api_key, mask_api_key
19
+ from elidia.cache.lru import ResponseCache
20
+ from elidia.cli.commands import CommandRegistry, build_default_commands
21
+ from elidia.cli.pager import AutoPager
22
+ from elidia.cli.themes import ThemeManager
23
+ from elidia.config.defaults import VERSION
24
+ from elidia.config.rules import load_project_rules
25
+ from elidia.config.settings import ELIDIA_HOME, ElidiaConfig, ensure_elidia_home, load_config
26
+ from elidia.creative.audio import generate_music, generate_speech, list_audio_models
27
+ from elidia.creative.display import display_image
28
+ from elidia.creative.image import generate_image, list_image_models
29
+ from elidia.creative.video import generate_video, list_video_models
30
+ from elidia.daemon.manager import DaemonManager
31
+ from elidia.db.database import Database
32
+ from elidia.mcp.registry import MCPRegistry
33
+ from elidia.memory.auto import AutoMemory
34
+ from elidia.memory.store import MemoryStore, MemoryTier
35
+ from elidia.models.router import ModelRouter
36
+ from elidia.modes.budget import BudgetGovernor
37
+ from elidia.modes.thinking import ThinkingLevel, describe_level, parse_thinking_level
38
+ from elidia.permissions.audit import AuditLogger
39
+ from elidia.permissions.manager import PermissionManager
40
+ from elidia.permissions.trust import TrustEngine
41
+ from elidia.research.export import export_html, export_markdown
42
+ from elidia.research.orchestrator import ResearchOrchestrator
43
+ from elidia.research.sources import ResearchSources
44
+ from elidia.session.history import HistorySearch
45
+ from elidia.session.manager import SessionManager
46
+ from elidia.tools import ToolRegistry, create_default_registry
47
+ from elidia.widgets.renderer import CliWidgetRenderer
48
+ from elidia.workflow.engine import WorkflowExecutor, parse_workflow
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ class ElidiaRepl:
54
+ def __init__(self, forced_model: str | None = None, mode: str = "chat"):
55
+ logger.debug(f"Entered into ElidiaRepl.__init__: mode={mode}")
56
+ self._config: ElidiaConfig | None = None
57
+ self._client: AiUtilsClient | None = None
58
+ self._db: Database | None = None
59
+ self._session_mgr: SessionManager | None = None
60
+ self._router: ModelRouter | None = None
61
+ self._session_id: str | None = None
62
+ self._mode = mode
63
+ self._forced_model = forced_model
64
+ self._console = Console()
65
+ self._messages: list[ChatMessage] = []
66
+ self._total_cost_dt: float = 0.0
67
+ self._total_tokens_in: int = 0
68
+ self._total_tokens_out: int = 0
69
+
70
+ self._tool_registry: ToolRegistry | None = None
71
+ self._mcp_registry: MCPRegistry | None = None
72
+ self._audit: AuditLogger | None = None
73
+ self._permission_mgr: PermissionManager | None = None
74
+ self._trust_engine: TrustEngine | None = None
75
+ self._persona_engine: PersonaEngine | None = None
76
+ self._agent_loop: AgentLoop | None = None
77
+ self._command_registry: CommandRegistry | None = None
78
+ self._memory_store: MemoryStore | None = None
79
+ self._auto_memory: AutoMemory | None = None
80
+ self._history_search: HistorySearch | None = None
81
+ self._project_rules: str | None = None
82
+
83
+ self._budget: BudgetGovernor | None = None
84
+ self._thinking_level: ThinkingLevel = ThinkingLevel.MEDIUM
85
+ self._daemon: DaemonManager | None = None
86
+ self._widget_renderer: CliWidgetRenderer | None = None
87
+ self._research_sources: ResearchSources | None = None
88
+
89
+ self._theme_manager: ThemeManager | None = None
90
+ self._pager: AutoPager | None = None
91
+ self._cache: ResponseCache | None = None
92
+
93
+ async def initialize(self) -> None:
94
+ logger.debug("Entered into initialize")
95
+ ensure_elidia_home()
96
+ self._config = load_config()
97
+
98
+ api_key = get_api_key()
99
+ if not api_key and self._config.api.key:
100
+ api_key = self._config.api.key
101
+
102
+ if not api_key:
103
+ self._console.print("[red]No API key found.[/red] Run: [bold]elidia auth login[/bold]")
104
+ raise SystemExit(1)
105
+
106
+ self._client = AiUtilsClient(
107
+ api_key=api_key,
108
+ base_url=self._config.api.base_url,
109
+ timeout=self._config.api.timeout_seconds,
110
+ max_retries=self._config.api.max_retries,
111
+ )
112
+
113
+ model_overrides: dict[str, str] = {}
114
+ mc = self._config.models
115
+ for attr in ("code", "reasoning", "creative", "vision", "cheap"):
116
+ val = getattr(mc, attr, None)
117
+ if val and val != "auto":
118
+ model_overrides[attr] = val
119
+
120
+ self._router = ModelRouter(config_models=model_overrides)
121
+ if self._forced_model:
122
+ self._router.force_model(self._forced_model)
123
+
124
+ self._db = Database()
125
+ self._db.connect()
126
+ self._session_mgr = SessionManager(self._db)
127
+
128
+ last_sid = self._session_mgr.get_last_session_id()
129
+ if last_sid:
130
+ self._session_id = last_sid
131
+ history = self._session_mgr.get_messages(last_sid, limit=50)
132
+ for msg in history:
133
+ self._messages.append(ChatMessage(role=msg["role"], content=msg["content"]))
134
+ else:
135
+ self._session_id = self._session_mgr.create_session(mode=self._mode)
136
+
137
+ self._tool_registry = create_default_registry()
138
+
139
+ self._audit = AuditLogger()
140
+ self._audit.open()
141
+
142
+ self._permission_mgr = PermissionManager(
143
+ config=self._config.permissions,
144
+ audit=self._audit,
145
+ prompt_fn=self._prompt_user_permission,
146
+ )
147
+ self._permission_mgr.set_project_root(Path.cwd())
148
+
149
+ self._trust_engine = TrustEngine(config=self._config.permissions)
150
+
151
+ self._mcp_registry = MCPRegistry()
152
+ try:
153
+ await self._mcp_registry.load_and_connect()
154
+ connected = self._mcp_registry.get_connected_servers()
155
+ if connected:
156
+ logger.info(f"MCP servers connected: {connected}")
157
+ except Exception as e:
158
+ logger.warning(f"MCP initialization failed (non-fatal): {e}")
159
+
160
+ self._persona_engine = PersonaEngine()
161
+ self._command_registry = build_default_commands()
162
+
163
+ self._memory_store = MemoryStore()
164
+ self._memory_store.open()
165
+ self._auto_memory = AutoMemory(self._memory_store)
166
+ self._history_search = HistorySearch(self._db)
167
+
168
+ self._project_rules = load_project_rules()
169
+
170
+ self._budget = BudgetGovernor(
171
+ session_limit_dt=self._config.budget.session_limit_dt if hasattr(self._config, "budget") else 50000.0,
172
+ )
173
+ self._daemon = DaemonManager()
174
+ self._widget_renderer = CliWidgetRenderer(console=self._console)
175
+ self._research_sources = ResearchSources(mcp_registry=self._mcp_registry)
176
+
177
+ self._theme_manager = ThemeManager()
178
+ theme_name = getattr(self._config, "theme", None)
179
+ if theme_name and isinstance(theme_name, str):
180
+ self._theme_manager.set_theme(theme_name)
181
+ self._console = self._theme_manager.create_console()
182
+ self._widget_renderer = CliWidgetRenderer(console=self._console)
183
+
184
+ self._pager = AutoPager(console=self._console)
185
+ self._cache = ResponseCache(max_size=256, default_ttl=600)
186
+
187
+ self._agent_loop = AgentLoop(
188
+ client=self._client,
189
+ tool_registry=self._tool_registry,
190
+ mcp_registry=self._mcp_registry,
191
+ model_router=self._router,
192
+ permission_manager=self._permission_mgr,
193
+ audit=self._audit,
194
+ budget=self._budget,
195
+ thinking_level=self._thinking_level,
196
+ )
197
+
198
+ async def cleanup(self) -> None:
199
+ logger.debug("Entered into cleanup")
200
+ if self._daemon:
201
+ await self._daemon.stop()
202
+ if self._mcp_registry:
203
+ await self._mcp_registry.disconnect_all()
204
+ if self._client:
205
+ await self._client.close()
206
+ if self._memory_store:
207
+ self._memory_store.close()
208
+ if self._audit:
209
+ self._audit.close()
210
+ if self._db:
211
+ self._db.close()
212
+
213
+ def _prompt_user_permission(self, description: str) -> bool:
214
+ logger.debug(f"Entered into _prompt_user_permission: {description}")
215
+ self._console.print(f"\n[yellow]Permission required:[/yellow] {description}")
216
+ try:
217
+ answer = input(" Allow? [y/N] ").strip().lower()
218
+ except (EOFError, KeyboardInterrupt):
219
+ return False
220
+ approved = answer in ("y", "yes")
221
+ if self._trust_engine:
222
+ action = description.split(":")[0].strip() if ":" in description else description
223
+ self._trust_engine.record_decision(action, approved)
224
+ return approved
225
+
226
+ def _print_banner(self) -> None:
227
+ logger.debug("Entered into _print_banner")
228
+ api_key = get_api_key() or (self._config.api.key if self._config else "")
229
+ masked = mask_api_key(api_key) if api_key else "not set"
230
+ model = self._forced_model or "auto"
231
+ msg_count = len(self._messages)
232
+
233
+ tool_count = len(self._tool_registry.list_tools()) if self._tool_registry else 0
234
+ mcp_servers = self._mcp_registry.get_connected_servers() if self._mcp_registry else {}
235
+ mcp_tool_count = sum(mcp_servers.values())
236
+ persona = self._persona_engine.active.name if self._persona_engine and self._persona_engine.active else "none"
237
+
238
+ banner = Text()
239
+ banner.append(f"ELIDIA v{VERSION}", style="bold cyan")
240
+ banner.append(f" --- {masked}", style="dim")
241
+ banner.append(f"\n Model: {model}", style="")
242
+ banner.append(f" | Mode: {self._mode}", style="")
243
+ banner.append(f" | Persona: {persona}", style="")
244
+ banner.append(f"\n Tools: {tool_count} built-in", style="dim")
245
+ if mcp_servers:
246
+ banner.append(f" + {mcp_tool_count} MCP ({len(mcp_servers)} servers)", style="dim")
247
+ banner.append(f" | Messages: {msg_count}", style="dim")
248
+
249
+ self._console.print(Panel(banner, border_style="cyan", padding=(0, 1)))
250
+ self._console.print("[dim]Type your message. Ctrl+C to cancel, Ctrl+D to exit. /help for commands.[/dim]")
251
+ self._console.print()
252
+
253
+ async def run(self) -> None:
254
+ logger.debug("Entered into run")
255
+ self._print_banner()
256
+
257
+ history_file = ELIDIA_HOME / "history"
258
+ prompt_session: PromptSession[str] = PromptSession(
259
+ history=FileHistory(str(history_file)),
260
+ auto_suggest=AutoSuggestFromHistory(),
261
+ multiline=False,
262
+ )
263
+
264
+ while True:
265
+ try:
266
+ user_input = await prompt_session.prompt_async("> ")
267
+ except (EOFError, KeyboardInterrupt):
268
+ self._console.print("\n[dim]Goodbye.[/dim]")
269
+ break
270
+
271
+ user_input = user_input.strip()
272
+ if not user_input:
273
+ continue
274
+
275
+ try:
276
+ if user_input.startswith("/"):
277
+ handled = await self._handle_slash_command(user_input)
278
+ if handled:
279
+ continue
280
+
281
+ await self.send_message(user_input, interactive=True)
282
+ except EOFError:
283
+ self._console.print("\n[dim]Goodbye.[/dim]")
284
+ break
285
+
286
+ async def _handle_slash_command(self, command: str) -> bool:
287
+ logger.debug(f"Entered into _handle_slash_command: {command}")
288
+ parts = command.split(maxsplit=1)
289
+ cmd = parts[0].lower().lstrip("/")
290
+ arg = parts[1].strip() if len(parts) > 1 else ""
291
+
292
+ if cmd == "help":
293
+ return self._cmd_help(arg)
294
+ if cmd == "model":
295
+ return self._cmd_model(arg)
296
+ if cmd == "mode":
297
+ return self._cmd_mode(arg)
298
+ if cmd == "cost":
299
+ return self._cmd_cost()
300
+ if cmd == "new":
301
+ return self._cmd_new()
302
+ if cmd == "sessions":
303
+ return self._cmd_sessions()
304
+ if cmd == "clear":
305
+ return self._cmd_clear()
306
+ if cmd == "tools":
307
+ return self._cmd_tools(arg)
308
+ if cmd == "mcp":
309
+ return await self._cmd_mcp(arg)
310
+ if cmd == "persona":
311
+ return self._cmd_persona(arg)
312
+ if cmd == "trust":
313
+ return self._cmd_trust()
314
+ if cmd == "balance":
315
+ return await self._cmd_balance()
316
+ if cmd == "memory":
317
+ return self._cmd_memory(arg)
318
+ if cmd == "history":
319
+ return self._cmd_history(arg)
320
+ if cmd == "rules":
321
+ return self._cmd_rules()
322
+ if cmd == "think":
323
+ return self._cmd_think(arg)
324
+ if cmd == "budget":
325
+ return self._cmd_budget()
326
+ if cmd == "research":
327
+ return await self._cmd_research(arg)
328
+ if cmd == "create":
329
+ return await self._cmd_create(arg)
330
+ if cmd == "workflow":
331
+ return await self._cmd_workflow(arg)
332
+ if cmd == "daemon":
333
+ return await self._cmd_daemon(arg)
334
+ if cmd == "theme":
335
+ return self._cmd_theme(arg)
336
+ if cmd == "cache":
337
+ return self._cmd_cache(arg)
338
+ if cmd == "pager":
339
+ return self._cmd_pager(arg)
340
+ if cmd in ("quit", "exit"):
341
+ raise EOFError()
342
+
343
+ self._console.print(f"[yellow]Unknown command: /{cmd}[/yellow] -- type /help")
344
+ return True
345
+
346
+ def _cmd_help(self, arg: str) -> bool:
347
+ if arg and self._command_registry:
348
+ cmd_def = self._command_registry.get(arg)
349
+ if cmd_def:
350
+ self._console.print(f"[bold]/{cmd_def.name}[/bold] -- {cmd_def.description}")
351
+ if cmd_def.usage:
352
+ self._console.print(f" Usage: {cmd_def.usage}")
353
+ return True
354
+
355
+ cats = self._command_registry.list_by_category() if self._command_registry else {}
356
+ lines: list[str] = []
357
+ for cat, cmds in cats.items():
358
+ lines.append(f"\n[bold]{cat.title()}[/bold]")
359
+ for c in cmds:
360
+ lines.append(f" [bold]/{c.name:12s}[/bold] {c.description}")
361
+ self._console.print(Panel("\n".join(lines), title="Commands", border_style="blue"))
362
+ return True
363
+
364
+ def _cmd_model(self, arg: str) -> bool:
365
+ if arg:
366
+ if arg == "auto":
367
+ self._router.force_model(None)
368
+ self._forced_model = None
369
+ self._console.print("[green]v[/green] Switched to auto model routing.")
370
+ else:
371
+ self._router.force_model(arg)
372
+ self._forced_model = arg
373
+ self._console.print(f"[green]v[/green] Model set to: [cyan]{arg}[/cyan]")
374
+ else:
375
+ current = self._forced_model or "auto"
376
+ self._console.print(f"Current model: [cyan]{current}[/cyan]")
377
+ return True
378
+
379
+ def _cmd_mode(self, arg: str) -> bool:
380
+ valid_modes = ("chat", "code", "research", "think", "create")
381
+ if arg and arg in valid_modes:
382
+ self._mode = arg
383
+ self._console.print(f"[green]v[/green] Mode: [cyan]{arg}[/cyan]")
384
+ elif arg:
385
+ self._console.print(f"[yellow]Invalid mode.[/yellow] Choose: {', '.join(valid_modes)}")
386
+ else:
387
+ self._console.print(f"Current mode: [cyan]{self._mode}[/cyan]")
388
+ return True
389
+
390
+ def _cmd_cost(self) -> bool:
391
+ self._console.print(
392
+ f"Session cost: [cyan]{self._total_cost_dt:.1f} DT[/cyan] "
393
+ f"(${self._total_cost_dt / 1000:.4f})\n"
394
+ f"Tokens: {self._total_tokens_in:,} in / {self._total_tokens_out:,} out"
395
+ )
396
+ return True
397
+
398
+ def _cmd_new(self) -> bool:
399
+ self._messages.clear()
400
+ if self._session_mgr:
401
+ self._session_id = self._session_mgr.create_session(mode=self._mode)
402
+ if self._permission_mgr:
403
+ self._permission_mgr.reset_session()
404
+ self._console.print("[green]v[/green] New session started.")
405
+ return True
406
+
407
+ def _cmd_sessions(self) -> bool:
408
+ if not self._session_mgr:
409
+ return True
410
+ sessions = self._session_mgr.list_sessions()
411
+ for s in sessions:
412
+ marker = "->" if s["id"] == self._session_id else " "
413
+ self._console.print(
414
+ f" {marker} [dim]{s['id'][:8]}[/dim] {s['title']} [dim]{s['updated_at']}[/dim]"
415
+ )
416
+ return True
417
+
418
+ def _cmd_clear(self) -> bool:
419
+ self._messages.clear()
420
+ self._console.print("[green]v[/green] Conversation cleared.")
421
+ return True
422
+
423
+ def _cmd_tools(self, arg: str) -> bool:
424
+ if not self._tool_registry:
425
+ return True
426
+
427
+ tools = self._tool_registry.list_tools()
428
+ if arg:
429
+ tools = [t for t in tools if t.category == arg]
430
+
431
+ categories: dict[str, list[str]] = {}
432
+ for t in tools:
433
+ categories.setdefault(t.category, []).append(f" {t.name:20s} {t.description}")
434
+
435
+ lines: list[str] = []
436
+ for cat, items in sorted(categories.items()):
437
+ lines.append(f"\n[bold]{cat}[/bold]")
438
+ lines.extend(items)
439
+
440
+ if self._mcp_registry:
441
+ mcp_tools = self._mcp_registry.list_all_tools()
442
+ if mcp_tools:
443
+ lines.append("\n[bold]mcp[/bold]")
444
+ for t in mcp_tools:
445
+ desc = t.description[:50] if t.description else ""
446
+ lines.append(f" {t.server_name}__{t.name:20s} {desc}")
447
+
448
+ total = len(tools) + (len(self._mcp_registry.list_all_tools()) if self._mcp_registry else 0)
449
+ self._console.print(Panel("\n".join(lines), title=f"Tools ({total})", border_style="green"))
450
+ return True
451
+
452
+ async def _cmd_mcp(self, arg: str) -> bool:
453
+ if not self._mcp_registry:
454
+ self._console.print("[dim]MCP not initialized.[/dim]")
455
+ return True
456
+
457
+ if not arg:
458
+ servers = self._mcp_registry.get_connected_servers()
459
+ if servers:
460
+ for name, tool_count in servers.items():
461
+ self._console.print(f" [green]connected[/green] {name} ({tool_count} tools)")
462
+ else:
463
+ self._console.print("[dim]No MCP servers connected.[/dim]")
464
+ return True
465
+
466
+ parts = arg.split(maxsplit=1)
467
+ action = parts[0]
468
+ name = parts[1] if len(parts) > 1 else ""
469
+
470
+ if action == "disconnect" and name:
471
+ await self._mcp_registry.disconnect_server(name)
472
+ self._console.print(f"[green]v[/green] Disconnected MCP server: {name}")
473
+ else:
474
+ self._console.print("[yellow]Usage: /mcp [disconnect <name>][/yellow]")
475
+ return True
476
+
477
+ def _cmd_persona(self, arg: str) -> bool:
478
+ if not self._persona_engine:
479
+ return True
480
+
481
+ if not arg or arg == "list":
482
+ personas = self._persona_engine.list_personas()
483
+ active = self._persona_engine.active
484
+ for p in personas:
485
+ marker = "->" if active and active.slug == p.slug else " "
486
+ self._console.print(f" {marker} [bold]{p.slug:12s}[/bold] {p.name} -- {p.system_prompt[:60]}...")
487
+ return True
488
+
489
+ if arg == "off":
490
+ self._persona_engine.deactivate()
491
+ self._console.print("[green]v[/green] Persona deactivated.")
492
+ return True
493
+
494
+ persona = self._persona_engine.activate(arg)
495
+ if persona:
496
+ self._console.print(f"[green]v[/green] Persona: [cyan]{persona.name}[/cyan]")
497
+ if persona.greeting:
498
+ self._console.print(f"[dim]{persona.greeting}[/dim]")
499
+ else:
500
+ self._console.print(f"[yellow]Unknown persona: {arg}[/yellow] -- type /persona list")
501
+ return True
502
+
503
+ def _cmd_trust(self) -> bool:
504
+ if not self._trust_engine:
505
+ return True
506
+ stats = self._trust_engine.get_stats()
507
+ if not stats:
508
+ self._console.print("[dim]No trust data yet.[/dim]")
509
+ return True
510
+ for action, s in stats.items():
511
+ status = "[green]promoted[/green]" if s.auto_promoted else "[dim]tracking[/dim]"
512
+ self._console.print(f" {action:25s} approved={s.approved_count} denied={s.denied_count} {status}")
513
+ return True
514
+
515
+ async def _cmd_balance(self) -> bool:
516
+ if not self._client:
517
+ return True
518
+ balance = await self._client.get_balance()
519
+ if balance.get("balance_dt", -1) >= 0:
520
+ self._console.print(f"Balance: [cyan]{balance['balance_dt']:,.0f} DT[/cyan] (${balance['balance_dt'] / 1000:.2f})")
521
+ else:
522
+ self._console.print(f"[yellow]Could not fetch balance: {balance.get('error', 'unknown')}[/yellow]")
523
+ return True
524
+
525
+ def _cmd_memory(self, arg: str) -> bool:
526
+ if not self._memory_store:
527
+ self._console.print("[dim]Memory not initialized.[/dim]")
528
+ return True
529
+
530
+ parts = arg.split(maxsplit=1)
531
+ action = parts[0] if parts else "list"
532
+ rest = parts[1] if len(parts) > 1 else ""
533
+
534
+ if action == "search" and rest:
535
+ results = self._memory_store.search_text(rest, limit=10)
536
+ if not results:
537
+ self._console.print(f"[dim]No memories matching '{rest}'[/dim]")
538
+ for m in results:
539
+ tier = MemoryTier(m.tier).name
540
+ self._console.print(f" [{tier}] [bold]{m.key}[/bold]: {m.content[:80]}")
541
+ return True
542
+
543
+ if action == "save" and rest:
544
+ sep_idx = rest.find("=")
545
+ if sep_idx > 0:
546
+ key = rest[:sep_idx].strip()
547
+ content = rest[sep_idx + 1:].strip()
548
+ from elidia.memory.store import MemoryEntry
549
+ mid = self._memory_store.save(MemoryEntry(
550
+ tier=MemoryTier.USER, key=key, content=content,
551
+ session_id=self._session_id or "", source="user_command",
552
+ ))
553
+ self._console.print(f"[green]v[/green] Saved memory: {key} ({mid[:8]})")
554
+ else:
555
+ self._console.print("[yellow]Usage: /memory save key=value[/yellow]")
556
+ return True
557
+
558
+ if action == "forget" and rest:
559
+ count = self._memory_store.delete_by_key(rest)
560
+ self._console.print(f"[green]v[/green] Deleted {count} memories with key '{rest}'")
561
+ return True
562
+
563
+ if action == "list" or not arg:
564
+ tier_filter = None
565
+ if rest:
566
+ try:
567
+ tier_filter = MemoryTier[rest.upper()]
568
+ except KeyError:
569
+ pass
570
+ memories = self._memory_store.list_memories(tier=tier_filter, limit=20)
571
+ if not memories:
572
+ self._console.print("[dim]No memories stored.[/dim]")
573
+ for m in memories:
574
+ tier = MemoryTier(m.tier).name
575
+ self._console.print(f" [{tier}] [bold]{m.key}[/bold]: {m.content[:80]}")
576
+ total = self._memory_store.count()
577
+ self._console.print(f"\n[dim]Total: {total} memories[/dim]")
578
+ return True
579
+
580
+ self._console.print("[yellow]Usage: /memory [list|search <query>|save key=value|forget <key>][/yellow]")
581
+ return True
582
+
583
+ def _cmd_history(self, arg: str) -> bool:
584
+ if not self._history_search:
585
+ self._console.print("[dim]History not available.[/dim]")
586
+ return True
587
+
588
+ if not arg:
589
+ stats = self._history_search.get_session_stats()
590
+ for s in stats[:15]:
591
+ self._console.print(
592
+ f" [dim]{s['id'][:8]}[/dim] {s['title'][:40]:40s} "
593
+ f"[dim]{s['message_count']} msgs | {s['updated_at']}[/dim]"
594
+ )
595
+ return True
596
+
597
+ results = self._history_search.search(arg, limit=10)
598
+ if not results:
599
+ self._console.print(f"[dim]No messages matching '{arg}'[/dim]")
600
+ return True
601
+
602
+ for r in results:
603
+ role = r["role"].upper()
604
+ preview = r["content"][:100].replace("\n", " ")
605
+ self._console.print(
606
+ f" [dim]{r['session_id'][:8]}[/dim] [{role}] {preview}"
607
+ )
608
+ return True
609
+
610
+ def _cmd_rules(self) -> bool:
611
+ if self._project_rules:
612
+ self._console.print(Panel(self._project_rules, title="Project Rules", border_style="blue"))
613
+ else:
614
+ self._console.print("[dim]No project rules found (.elidia/rules.md)[/dim]")
615
+ return True
616
+
617
+ def _cmd_think(self, arg: str) -> bool:
618
+ logger.debug(f"Entered into _cmd_think: arg={arg}")
619
+ if not arg:
620
+ desc = describe_level(self._thinking_level)
621
+ self._console.print(f"Thinking level: [cyan]{desc}[/cyan]")
622
+ return True
623
+
624
+ try:
625
+ level = parse_thinking_level(arg)
626
+ self._thinking_level = level
627
+ if self._agent_loop:
628
+ self._agent_loop._thinking_level = level
629
+ desc = describe_level(level)
630
+ self._console.print(f"[green]v[/green] Thinking level: [cyan]{desc}[/cyan]")
631
+ except ValueError:
632
+ self._console.print(
633
+ "[yellow]Invalid level.[/yellow] Choose: minimal, low, medium, high, max (or 1-5)"
634
+ )
635
+ return True
636
+
637
+ def _cmd_budget(self) -> bool:
638
+ logger.debug("Entered into _cmd_budget")
639
+ if not self._budget:
640
+ self._console.print("[dim]Budget not initialized.[/dim]")
641
+ return True
642
+
643
+ summary = self._budget.get_summary()
644
+ self._console.print(Panel(
645
+ f"Session: [cyan]{summary['session_dt_used']:.1f}[/cyan] / "
646
+ f"{summary['session_limit_dt']:.0f} DT ({summary['session_pct']:.1f}%)\n"
647
+ f"Tokens: {summary['session_tokens_in']:,} in / {summary['session_tokens_out']:,} out\n"
648
+ f"Calls: {summary['call_count']}",
649
+ title="Budget",
650
+ border_style="yellow",
651
+ ))
652
+ return True
653
+
654
+ async def _cmd_research(self, arg: str) -> bool:
655
+ logger.debug(f"Entered into _cmd_research: arg={arg}")
656
+ if not arg:
657
+ self._console.print("[yellow]Usage: /research <query> [--export md|html][/yellow]")
658
+ return True
659
+
660
+ export_format = None
661
+ query = arg
662
+ if " --export " in arg:
663
+ parts = arg.split(" --export ", 1)
664
+ query = parts[0].strip()
665
+ export_format = parts[1].strip().lower()
666
+
667
+ if not self._client:
668
+ self._console.print("[red]No client initialized.[/red]")
669
+ return True
670
+
671
+ self._console.print(f"[cyan]Researching:[/cyan] {query}")
672
+
673
+ async def search_fn(q: str):
674
+ if self._research_sources:
675
+ return await self._research_sources.search(q)
676
+ return []
677
+
678
+ orchestrator = ResearchOrchestrator(client=self._client, search_fn=search_fn)
679
+
680
+ try:
681
+ async for event in orchestrator.run(query):
682
+ if event.kind == "stage":
683
+ self._console.print(f" [dim]{event.data}[/dim]")
684
+ elif event.kind == "result":
685
+ report = event.data.get("report", "")
686
+ sources = event.data.get("sources", [])
687
+
688
+ if export_format == "html":
689
+ result = export_html(report, sources=sources)
690
+ output_path = ELIDIA_HOME / "research" / f"report_{int(time.time())}.html"
691
+ output_path.parent.mkdir(parents=True, exist_ok=True)
692
+ output_path.write_text(result.content, encoding="utf-8")
693
+ self._console.print(f"\n[green]v[/green] Saved: {output_path}")
694
+ elif export_format == "md":
695
+ result = export_markdown(report, sources=sources)
696
+ output_path = ELIDIA_HOME / "research" / f"report_{int(time.time())}.md"
697
+ output_path.parent.mkdir(parents=True, exist_ok=True)
698
+ output_path.write_text(result.content, encoding="utf-8")
699
+ self._console.print(f"\n[green]v[/green] Saved: {output_path}")
700
+ else:
701
+ self._console.print()
702
+ self._console.print(Markdown(report))
703
+ if sources:
704
+ self._console.print(f"\n[dim]{len(sources)} source(s) cited[/dim]")
705
+ elif event.kind == "error":
706
+ self._console.print(f"[red]Research error: {event.data}[/red]")
707
+ except Exception as e:
708
+ self._console.print(f"[red]Research failed: {e}[/red]")
709
+
710
+ return True
711
+
712
+ async def _cmd_create(self, arg: str) -> bool:
713
+ logger.debug(f"Entered into _cmd_create: arg={arg}")
714
+ if not arg:
715
+ self._console.print(
716
+ "[yellow]Usage:[/yellow]\n"
717
+ " /create image <prompt> [--model <model>]\n"
718
+ " /create video <prompt> [--model <model>]\n"
719
+ " /create speech <text> [--voice <voice>]\n"
720
+ " /create music <prompt>\n"
721
+ " /create models"
722
+ )
723
+ return True
724
+
725
+ parts = arg.split(maxsplit=1)
726
+ sub_cmd = parts[0].lower()
727
+ rest = parts[1] if len(parts) > 1 else ""
728
+
729
+ if not self._client:
730
+ self._console.print("[red]No client initialized.[/red]")
731
+ return True
732
+
733
+ if sub_cmd == "models":
734
+ self._console.print("[bold]Image models:[/bold]")
735
+ for m in list_image_models():
736
+ self._console.print(f" {m['model']:25s} {m.get('vendor', '')}")
737
+ self._console.print("[bold]Video models:[/bold]")
738
+ for m in list_video_models():
739
+ self._console.print(f" {m['model']:25s} {m.get('vendor', '')}")
740
+ self._console.print("[bold]Audio models:[/bold]")
741
+ for m in list_audio_models():
742
+ self._console.print(f" {m['model']:25s} {m.get('vendor', '')}")
743
+ return True
744
+
745
+ model = None
746
+ voice = None
747
+ prompt = rest
748
+ if " --model " in rest:
749
+ prompt, model = rest.rsplit(" --model ", 1)
750
+ prompt = prompt.strip()
751
+ model = model.strip()
752
+ if " --voice " in rest:
753
+ prompt, voice = rest.rsplit(" --voice ", 1)
754
+ prompt = prompt.strip()
755
+ voice = voice.strip()
756
+
757
+ if not prompt:
758
+ self._console.print("[yellow]Prompt required.[/yellow]")
759
+ return True
760
+
761
+ try:
762
+ if sub_cmd == "image":
763
+ self._console.print("[cyan]Generating image...[/cyan]")
764
+ result = await generate_image(self._client, prompt, model=model)
765
+ self._console.print(f"[green]v[/green] Saved: {result.file_path}")
766
+ display_image(result.file_path)
767
+
768
+ elif sub_cmd == "video":
769
+ self._console.print("[cyan]Generating video (this may take a few minutes)...[/cyan]")
770
+ result = await generate_video(self._client, prompt, model=model)
771
+ self._console.print(f"[green]v[/green] Saved: {result.file_path}")
772
+
773
+ elif sub_cmd == "speech":
774
+ self._console.print("[cyan]Generating speech...[/cyan]")
775
+ result = await generate_speech(self._client, prompt, voice=voice)
776
+ self._console.print(f"[green]v[/green] Saved: {result.file_path}")
777
+
778
+ elif sub_cmd == "music":
779
+ self._console.print("[cyan]Generating music...[/cyan]")
780
+ result = await generate_music(self._client, prompt)
781
+ self._console.print(f"[green]v[/green] Saved: {result.file_path}")
782
+
783
+ else:
784
+ self._console.print(f"[yellow]Unknown create type: {sub_cmd}[/yellow]")
785
+
786
+ except Exception as e:
787
+ self._console.print(f"[red]Creation failed: {e}[/red]")
788
+
789
+ return True
790
+
791
+ async def _cmd_workflow(self, arg: str) -> bool:
792
+ logger.debug(f"Entered into _cmd_workflow: arg={arg}")
793
+ if not arg:
794
+ self._console.print("[yellow]Usage: /workflow <path_to_yaml>[/yellow]")
795
+ return True
796
+
797
+ workflow_path = Path(arg)
798
+ if not workflow_path.exists():
799
+ self._console.print(f"[red]Workflow file not found: {arg}[/red]")
800
+ return True
801
+
802
+ try:
803
+ workflow = parse_workflow(workflow_path)
804
+ except Exception as e:
805
+ self._console.print(f"[red]Failed to parse workflow: {e}[/red]")
806
+ return True
807
+
808
+ self._console.print(f"[cyan]Running workflow:[/cyan] {workflow.name}")
809
+ executor = WorkflowExecutor(client=self._client)
810
+
811
+ try:
812
+ async for event in executor.run(workflow):
813
+ if event.kind == "start":
814
+ self._console.print(f" [dim]Steps: {event.data.get('step_count', 0)}[/dim]")
815
+ elif event.kind == "step_start":
816
+ self._console.print(f" [cyan]>{event.data['name']}[/cyan] ({event.data['type']})")
817
+ elif event.kind == "step_done":
818
+ status = event.data.get("status", "?")
819
+ elapsed = event.data.get("elapsed_ms", 0)
820
+ style = "green" if status == "completed" else "yellow" if status == "skipped" else "red"
821
+ self._console.print(f" [{style}]{status}[/{style}] {event.data['name']} ({elapsed}ms)")
822
+ elif event.kind == "done":
823
+ total = event.data.get("total_steps", 0)
824
+ completed = event.data.get("completed", 0)
825
+ failed = event.data.get("failed", 0)
826
+ elapsed = event.data.get("elapsed_ms", 0)
827
+ self._console.print(
828
+ f"\n[bold]Done:[/bold] {completed}/{total} completed, "
829
+ f"{failed} failed ({elapsed}ms)"
830
+ )
831
+ elif event.kind == "error":
832
+ self._console.print(f" [red]Error: {event.data}[/red]")
833
+ except Exception as e:
834
+ self._console.print(f"[red]Workflow error: {e}[/red]")
835
+
836
+ return True
837
+
838
+ async def _cmd_daemon(self, arg: str) -> bool:
839
+ logger.debug(f"Entered into _cmd_daemon: arg={arg}")
840
+ if not self._daemon:
841
+ self._console.print("[dim]Daemon not initialized.[/dim]")
842
+ return True
843
+
844
+ parts = arg.split(maxsplit=2) if arg else []
845
+ action = parts[0] if parts else "status"
846
+
847
+ if action == "status":
848
+ status = self._daemon.get_status()
849
+ self._console.print(Panel(
850
+ f"Running: {'yes' if status['running'] else 'no'}\n"
851
+ f"Tasks: {status['task_count']} ({status['active']} active)",
852
+ title="Daemon",
853
+ border_style="magenta",
854
+ ))
855
+ for t in status.get("tasks", []):
856
+ style = "green" if t["status"] == "running" else "dim"
857
+ self._console.print(
858
+ f" [{style}]{t['status']:8s}[/{style}] {t['name']} ({t['type']}) "
859
+ f"runs={t['run_count']}"
860
+ )
861
+ return True
862
+
863
+ if action == "start":
864
+ await self._daemon.start()
865
+ self._console.print("[green]v[/green] Daemon started.")
866
+ return True
867
+
868
+ if action == "stop":
869
+ await self._daemon.stop()
870
+ self._console.print("[green]v[/green] Daemon stopped.")
871
+ return True
872
+
873
+ if action == "watch" and len(parts) >= 2:
874
+ watch_path = parts[1]
875
+ name = parts[2] if len(parts) > 2 else Path(watch_path).name
876
+ task_id = self._daemon.add_watcher(name, watch_path)
877
+ self._console.print(f"[green]v[/green] Watcher added: {name} → {watch_path} ({task_id[:16]})")
878
+ return True
879
+
880
+ if action == "schedule" and len(parts) >= 2:
881
+ interval = int(parts[1])
882
+ command = parts[2] if len(parts) > 2 else ""
883
+ name = f"sched_{interval}s"
884
+ task_id = self._daemon.add_schedule(name, interval, command)
885
+ self._console.print(f"[green]v[/green] Schedule added: every {interval}s ({task_id[:16]})")
886
+ return True
887
+
888
+ self._console.print(
889
+ "[yellow]Usage:[/yellow]\n"
890
+ " /daemon status\n"
891
+ " /daemon start\n"
892
+ " /daemon stop\n"
893
+ " /daemon watch <path> [name]\n"
894
+ " /daemon schedule <seconds> [command]"
895
+ )
896
+ return True
897
+
898
+ def _cmd_theme(self, arg: str) -> bool:
899
+ logger.debug(f"Entered into _cmd_theme: arg={arg}")
900
+ if not self._theme_manager:
901
+ self._console.print("[dim]Theme manager not initialized.[/dim]")
902
+ return True
903
+
904
+ if not arg or arg == "list":
905
+ themes = self._theme_manager.list_themes()
906
+ current = self._theme_manager.current_name
907
+ for t in themes:
908
+ marker = "->" if t["name"] == current else " "
909
+ self._console.print(f" {marker} {t['name']:12s} {t['description']}")
910
+ return True
911
+
912
+ result = self._theme_manager.set_theme(arg)
913
+ if result:
914
+ self._console = self._theme_manager.create_console()
915
+ if self._pager:
916
+ self._pager = AutoPager(console=self._console)
917
+ self._console.print(f"[green]v[/green] Theme: [cyan]{arg}[/cyan]")
918
+ else:
919
+ self._console.print(f"[yellow]Unknown theme: {arg}[/yellow] -- type /theme list")
920
+ return True
921
+
922
+ def _cmd_cache(self, arg: str) -> bool:
923
+ logger.debug(f"Entered into _cmd_cache: arg={arg}")
924
+ if not self._cache:
925
+ self._console.print("[dim]Cache not initialized.[/dim]")
926
+ return True
927
+
928
+ if arg == "clear":
929
+ self._cache.clear()
930
+ self._console.print("[green]v[/green] Cache cleared.")
931
+ return True
932
+
933
+ if arg == "off":
934
+ self._cache.enabled = False
935
+ self._console.print("[green]v[/green] Cache disabled.")
936
+ return True
937
+
938
+ if arg == "on":
939
+ self._cache.enabled = True
940
+ self._console.print("[green]v[/green] Cache enabled.")
941
+ return True
942
+
943
+ stats = self._cache.get_stats()
944
+ self._console.print(Panel(
945
+ f"Enabled: {'yes' if stats['enabled'] else 'no'}\n"
946
+ f"Size: {stats['size']} / {stats['max_size']}\n"
947
+ f"Hits: {stats['hits']} | Misses: {stats['misses']}\n"
948
+ f"Hit rate: {stats['hit_rate_pct']:.1f}%",
949
+ title="Cache",
950
+ border_style="blue",
951
+ ))
952
+ return True
953
+
954
+ def _cmd_pager(self, arg: str) -> bool:
955
+ logger.debug(f"Entered into _cmd_pager: arg={arg}")
956
+ if not self._pager:
957
+ self._console.print("[dim]Pager not initialized.[/dim]")
958
+ return True
959
+
960
+ if arg == "off":
961
+ self._pager.enabled = False
962
+ self._console.print("[green]v[/green] Auto-pager disabled.")
963
+ return True
964
+
965
+ if arg == "on":
966
+ self._pager.enabled = True
967
+ self._console.print("[green]v[/green] Auto-pager enabled.")
968
+ return True
969
+
970
+ status = "enabled" if self._pager.enabled else "disabled"
971
+ self._console.print(f"Auto-pager: [cyan]{status}[/cyan] (threshold: {self._pager.threshold:.0%})")
972
+ return True
973
+
974
+ async def send_message(self, user_input: str, interactive: bool = True) -> None:
975
+ logger.debug(f"Entered into send_message: msg_len={len(user_input)}, interactive={interactive}")
976
+
977
+ self._messages.append(ChatMessage(role="user", content=user_input))
978
+
979
+ if self._session_mgr and self._session_id:
980
+ self._session_mgr.add_message(self._session_id, "user", user_input)
981
+
982
+ if self._auto_memory:
983
+ saved = self._auto_memory.analyze_user_message(
984
+ user_input,
985
+ session_id=self._session_id or "",
986
+ project_path=str(Path.cwd()),
987
+ )
988
+ if saved and interactive:
989
+ for entry in saved:
990
+ self._console.print(f"[dim] (remembered: {entry.key})[/dim]")
991
+
992
+ if self._agent_loop:
993
+ await self._send_with_agent_loop(user_input, interactive)
994
+ else:
995
+ await self._send_direct(user_input, interactive)
996
+
997
+ async def _send_with_agent_loop(self, user_input: str, interactive: bool) -> None:
998
+ logger.debug(f"Entered into _send_with_agent_loop: interactive={interactive}")
999
+ start_time = time.monotonic()
1000
+ full_response = ""
1001
+ total_tokens_in = 0
1002
+ total_tokens_out = 0
1003
+ total_cost = 0.0
1004
+
1005
+ try:
1006
+ async for event in self._agent_loop.run(
1007
+ messages=self._messages,
1008
+ mode=self._mode,
1009
+ forced_model=self._forced_model,
1010
+ session_id=self._session_id or "",
1011
+ ):
1012
+ if event.kind == "mode_info":
1013
+ if interactive:
1014
+ exec_mode = event.data.get("exec_mode", "direct")
1015
+ confidence = event.data.get("confidence", 0.0)
1016
+ reason = event.data.get("reason", "")
1017
+ if exec_mode != "direct":
1018
+ self._console.print(f"[dim]Mode: {exec_mode} ({confidence:.0%}) — {reason}[/dim]")
1019
+
1020
+ elif event.kind == "budget_warning":
1021
+ if interactive:
1022
+ self._console.print(f"[yellow]Budget: {event.data.get('message', '')}[/yellow]")
1023
+
1024
+ elif event.kind == "thinking":
1025
+ if interactive:
1026
+ model = event.data.get("model", "?")
1027
+ reason = event.data.get("reason", "")
1028
+ self._console.print(f"\n[dim]Model: {model} ({reason})[/dim]")
1029
+
1030
+ elif event.kind == "content":
1031
+ full_response = event.data
1032
+ if interactive:
1033
+ self._console.print()
1034
+ try:
1035
+ if self._pager and self._pager.should_page(full_response):
1036
+ self._pager.print_or_page(full_response, as_markdown=True)
1037
+ else:
1038
+ self._console.print(Markdown(full_response))
1039
+ except Exception:
1040
+ self._console.print(full_response)
1041
+ else:
1042
+ sys.stdout.write(full_response)
1043
+ sys.stdout.flush()
1044
+
1045
+ elif event.kind == "tool_call":
1046
+ name = event.data.get("name", "?")
1047
+ args = event.data.get("arguments", {})
1048
+ args_preview = str(args)[:100]
1049
+ if interactive:
1050
+ self._console.print(f" [cyan]> {name}[/cyan]({args_preview})")
1051
+
1052
+ elif event.kind == "tool_result":
1053
+ name = event.data.get("name", "?")
1054
+ content = event.data.get("content", "")
1055
+ is_error = event.data.get("is_error", False)
1056
+ if interactive:
1057
+ style = "red" if is_error else "dim"
1058
+ preview = content[:200].replace("\n", " ")
1059
+ self._console.print(f" [{style}]< {name}: {preview}[/{style}]")
1060
+
1061
+ elif event.kind == "usage":
1062
+ total_tokens_in += event.data.get("tokens_in", 0)
1063
+ total_tokens_out += event.data.get("tokens_out", 0)
1064
+ total_cost += event.data.get("cost_dt", 0.0)
1065
+
1066
+ elif event.kind == "error":
1067
+ err_msg = str(event.data)
1068
+ if interactive:
1069
+ self._console.print(f"\n[red]Error: {err_msg}[/red]")
1070
+ else:
1071
+ sys.stderr.write(f"\nError: {err_msg}\n")
1072
+ self._messages.pop()
1073
+ return
1074
+
1075
+ elif event.kind == "done":
1076
+ loops = event.data.get("loops", 0)
1077
+ tools_used = event.data.get("tools_called", [])
1078
+ if interactive and tools_used:
1079
+ self._console.print(f"[dim] ({loops} loop(s), {len(tools_used)} tool call(s))[/dim]")
1080
+
1081
+ except KeyboardInterrupt:
1082
+ self._console.print("\n[dim]Generation cancelled.[/dim]")
1083
+ self._messages.pop()
1084
+ return
1085
+
1086
+ elapsed_ms = int((time.monotonic() - start_time) * 1000)
1087
+
1088
+ if full_response:
1089
+ self._messages.append(ChatMessage(role="assistant", content=full_response))
1090
+
1091
+ self._total_cost_dt += total_cost
1092
+ self._total_tokens_in += total_tokens_in
1093
+ self._total_tokens_out += total_tokens_out
1094
+
1095
+ if self._session_mgr and self._session_id:
1096
+ model_used = self._forced_model or "auto"
1097
+ self._session_mgr.add_message(
1098
+ self._session_id,
1099
+ "assistant",
1100
+ full_response,
1101
+ model=model_used,
1102
+ tokens_in=total_tokens_in,
1103
+ tokens_out=total_tokens_out,
1104
+ cost_dt=total_cost,
1105
+ )
1106
+ if len(self._messages) == 2:
1107
+ title = self._messages[0].content[:60]
1108
+ if len(self._messages[0].content) > 60:
1109
+ title += "..."
1110
+ self._session_mgr.update_title(self._session_id, title)
1111
+
1112
+ if self._audit:
1113
+ self._audit.log_model_call(
1114
+ model=self._forced_model or "auto",
1115
+ tokens_in=total_tokens_in,
1116
+ tokens_out=total_tokens_out,
1117
+ cost_dt=total_cost,
1118
+ session_id=self._session_id or "",
1119
+ )
1120
+
1121
+ if interactive:
1122
+ if not full_response.endswith("\n"):
1123
+ self._console.print()
1124
+ self._console.print(
1125
+ f"[dim]{total_tokens_in + total_tokens_out:,} tokens | {elapsed_ms}ms | "
1126
+ f"{total_cost:.1f} DT[/dim]\n"
1127
+ )
1128
+ elif not full_response.endswith("\n"):
1129
+ sys.stdout.write("\n")
1130
+
1131
+ async def _send_direct(self, user_input: str, interactive: bool) -> None:
1132
+ logger.debug(f"Entered into _send_direct: interactive={interactive}")
1133
+ decision = self._router.route(user_input, mode=self._mode)
1134
+
1135
+ if interactive:
1136
+ self._console.print(f"\n[dim]Model: {decision.model} ({decision.reason})[/dim]")
1137
+
1138
+ full_response = ""
1139
+ tokens_in = 0
1140
+ tokens_out = 0
1141
+ start_time = time.monotonic()
1142
+
1143
+ try:
1144
+ if interactive:
1145
+ self._console.print()
1146
+ with Live(
1147
+ Text("...", style="cyan"),
1148
+ console=self._console,
1149
+ refresh_per_second=15,
1150
+ transient=True,
1151
+ ) as live:
1152
+ async for event in self._client.chat_completion_stream(
1153
+ messages=self._messages,
1154
+ model=decision.model,
1155
+ ):
1156
+ if event.event_type == "content":
1157
+ full_response += event.data
1158
+ try:
1159
+ live.update(Markdown(full_response + " |"))
1160
+ except Exception:
1161
+ live.update(Text(full_response + " |"))
1162
+ elif event.event_type == "usage" and isinstance(event.data, dict):
1163
+ tokens_in = event.data.get("prompt_tokens", 0)
1164
+ tokens_out = event.data.get("completion_tokens", 0)
1165
+ elif event.event_type == "error":
1166
+ msg = (
1167
+ event.data.get("message", str(event.data))
1168
+ if isinstance(event.data, dict)
1169
+ else str(event.data)
1170
+ )
1171
+ live.update(Text(f"Error: {msg}", style="red"))
1172
+ self._messages.pop()
1173
+ return
1174
+
1175
+ if full_response:
1176
+ self._console.print(Markdown(full_response))
1177
+ else:
1178
+ async for event in self._client.chat_completion_stream(
1179
+ messages=self._messages,
1180
+ model=decision.model,
1181
+ ):
1182
+ if event.event_type == "content":
1183
+ full_response += event.data
1184
+ sys.stdout.write(event.data)
1185
+ sys.stdout.flush()
1186
+ elif event.event_type == "usage" and isinstance(event.data, dict):
1187
+ tokens_in = event.data.get("prompt_tokens", 0)
1188
+ tokens_out = event.data.get("completion_tokens", 0)
1189
+ elif event.event_type == "error":
1190
+ msg = (
1191
+ event.data.get("message", str(event.data))
1192
+ if isinstance(event.data, dict)
1193
+ else str(event.data)
1194
+ )
1195
+ sys.stderr.write(f"\nError: {msg}\n")
1196
+ self._messages.pop()
1197
+ return
1198
+ if full_response:
1199
+ sys.stdout.write("\n")
1200
+
1201
+ except KeyboardInterrupt:
1202
+ self._console.print("\n[dim]Generation cancelled.[/dim]")
1203
+ self._messages.pop()
1204
+ return
1205
+
1206
+ elapsed_ms = int((time.monotonic() - start_time) * 1000)
1207
+
1208
+ if full_response:
1209
+ self._messages.append(ChatMessage(role="assistant", content=full_response))
1210
+
1211
+ cost_dt = tokens_in * 0.001 + tokens_out * 0.002
1212
+ self._total_cost_dt += cost_dt
1213
+ self._total_tokens_in += tokens_in
1214
+ self._total_tokens_out += tokens_out
1215
+
1216
+ if self._session_mgr and self._session_id:
1217
+ self._session_mgr.add_message(
1218
+ self._session_id,
1219
+ "assistant",
1220
+ full_response,
1221
+ model=decision.model,
1222
+ tokens_in=tokens_in,
1223
+ tokens_out=tokens_out,
1224
+ cost_dt=cost_dt,
1225
+ )
1226
+ if len(self._messages) == 2:
1227
+ title = user_input[:60] + ("..." if len(user_input) > 60 else "")
1228
+ self._session_mgr.update_title(self._session_id, title)
1229
+
1230
+ if interactive:
1231
+ self._console.print(
1232
+ f"\n[dim]{tokens_in + tokens_out:,} tokens | {elapsed_ms}ms | "
1233
+ f"{cost_dt:.1f} DT[/dim]\n"
1234
+ )