Coverage for src / osiris_cli / minimal_cli.py: 0%
831 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-27 17:41 +0200
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-27 17:41 +0200
1#!/usr/bin/env python3
2"""
3Osiris CLI v4.0 - Minimal Terminal Interface
5Design Philosophy:
6- Minimal & Clean: No boxes, no borders, pure terminal
7- Memory-First: Agents persist and learn over time
8- Context-Aware: OSIRIS.md files for project context
9- Tool Safety: Permission system before dangerous operations
10- Extensible: MCP, hooks, subagents support
11- Fast: Streaming responses, instant startup
12- Scriptable: Non-interactive mode for automation
13"""
15import sys
16import os
17import asyncio
18from pathlib import Path
19from typing import Optional, List, Dict, Any
20import readline
21from datetime import datetime
22import json
23import traceback
24from openai import APIError, RateLimitError, APITimeoutError, BadRequestError, NotFoundError
26from rich.console import Console
27from rich.markdown import Markdown
28from rich.syntax import Syntax
29from rich.live import Live
30from rich.spinner import Spinner
31from rich.panel import Panel
32from rich.prompt import Confirm
33from rich.table import Table
35# Interactive menu support
36from prompt_toolkit import PromptSession
37from prompt_toolkit.completion import Completer, Completion
38from prompt_toolkit.key_binding import KeyBindings
39from prompt_toolkit.formatted_text import HTML
40from prompt_toolkit.styles import Style
42from .config import settings
43from .session import session
44from .context import context
45from .tools import tools
46from .client import async_client
47from .scenarios import detect_scenario, ScenarioDefinition, resolve_context_files
48from .event_history import event_history
49from .session_summary import write_session_summary
51console = Console()
53class ResponseSanitizer:
54 """Robust stateful filter to remove ALL XML-like tags from user output."""
55 def __init__(self):
56 self.in_tag = False
57 self.tag_buffer = ""
58 self.in_hide_block = False # For blocks where we hide BOTH tag and content (like tool_calls)
59 self.hide_tags = ["tool_call", "function", "parameter", "thought", "thinking", "call", "scratchpad", "reasoning", "logs"]
61 def sanitize(self, text: str) -> str:
62 if not text: return ""
64 result = ""
65 for char in text:
66 if char == '<':
67 self.in_tag = True
68 self.tag_buffer = "<"
69 continue
71 if self.in_tag:
72 self.tag_buffer += char
73 if char == '>':
74 # Check if this tag starts a hidden block
75 tag_content = self.tag_buffer[1:-1].lower()
77 # Handle closing tags
78 if tag_content.startswith('/'):
79 base_tag = tag_content[1:].split()[0]
80 if base_tag in self.hide_tags:
81 self.in_hide_block = False
82 else:
83 # Handle opening tags
84 base_tag = tag_content.split('=')[0].split()[0]
85 if base_tag in self.hide_tags:
86 self.in_hide_block = True
88 # Reset tag state
89 self.in_tag = False
90 self.tag_buffer = ""
91 continue
93 if not self.in_hide_block:
94 result += char
96 return result
100# ASCII Logo
101OSIRIS_LOGO = """
102 ░░░ ░░░░░░░░░
103 ░░░ ░░░ ░░░
104 ░░░ ░░░
105 ███ ░░░ █████████░░░
106 ███ ░░░ ███░░ ███░░
107 ███ ███░░ ░░░
108 ░░░ ███ ███░░░░████░
109 ███ ███ ███
110 ███ ███ ███
111 ███ █████████
112"""
115# Command definitions for interactive palette
116SLASH_COMMANDS = [
117 {"cmd": "/help", "desc": "Show command reference", "category": "Core"},
118 {"cmd": "/exit", "desc": "Exit Osiris", "category": "Core"},
119 {"cmd": "/version", "desc": "Show Osiris version", "category": "Core"},
121 {"cmd": "/model", "desc": "Change AI model", "category": "Model & Provider"},
122 {"cmd": "/provider", "desc": "Switch provider", "category": "Model & Provider"},
123 {"cmd": "/yolo", "desc": "Toggle autonomous mode", "category": "Model & Provider"},
125 {"cmd": "/remember", "desc": "Add to agent memory (key:value)", "category": "Memory"},
126 {"cmd": "/forget", "desc": "Remove from memory", "category": "Memory"},
127 {"cmd": "/memory", "desc": "Show agent memory", "category": "Memory"},
128 {"cmd": "/init", "desc": "Initialize agent memory", "category": "Memory"},
129 {"cmd": "/skill", "desc": "Learn a new skill", "category": "Memory"},
131 {"cmd": "/session", "desc": "Manage sessions", "category": "Session"},
132 {"cmd": "/clear", "desc": "Clear screen", "category": "Session"},
133 {"cmd": "/reset", "desc": "Reset session (keep memory)", "category": "Session"},
135 {"cmd": "/context", "desc": "Show context files", "category": "Context & Tools"},
136 {"cmd": "/tools", "desc": "List available tools", "category": "Context & Tools"},
138 {"cmd": "/cost", "desc": "Show cost for current session", "category": "Analytics"},
139 {"cmd": "/usage", "desc": "Show usage statistics", "category": "Analytics"},
140 {"cmd": "/budget", "desc": "Set and check budget", "category": "Analytics"},
142 {"cmd": "/shadow", "desc": "Toggle shadow mode", "category": "Advanced"},
143 {"cmd": "/vim", "desc": "Toggle Vim keybindings", "category": "Advanced"},
144 {"cmd": "/plan", "desc": "Plan before execution", "category": "Advanced"},
145]
148class SlashCommandCompleter(Completer):
149 """
150 Interactive command completer with arrow key navigation and mouse support.
151 Shows a popup menu when user types '/' with command descriptions.
152 """
154 def get_completions(self, document, complete_event):
155 """Generate completions for slash commands"""
156 text = document.text_before_cursor
158 # Only show completions if we're typing a slash command
159 if text.startswith('/'):
160 search_text = text[1:].lower() # Remove the '/' for searching
162 for cmd_info in SLASH_COMMANDS:
163 cmd = cmd_info["cmd"]
164 desc = cmd_info["desc"]
165 category = cmd_info["category"]
167 # If just "/", show all commands
168 # Otherwise, match command name (without the /)
169 if not search_text or search_text in cmd[1:].lower():
170 # Create formatted display with category and description
171 display = HTML(f'<b>{cmd}</b> <style fg="ansibrightblack">│</style> <i>{desc}</i>')
172 display_meta = HTML(f'<style fg="ansicyan">{category}</style>')
174 yield Completion(
175 cmd,
176 start_position=-len(text),
177 display=display,
178 display_meta=display_meta,
179 )
182class AgentMemory:
183 """
184 Memory-first agent system.
185 Agents persist across sessions and learn over time.
186 """
188 def __init__(self, agent_name: str = "main"):
189 self.agent_name = agent_name
190 self.memory_file = Path.home() / ".osiris" / "memory" / f"{agent_name}.json"
191 self.memory_file.parent.mkdir(parents=True, exist_ok=True)
192 self.core_memory: Dict[str, Any] = {}
193 self.skills: List[str] = []
194 self.load()
196 def load(self):
197 """Load agent memory from disk"""
198 if self.memory_file.exists():
199 import json
200 try:
201 with open(self.memory_file, 'r') as f:
202 data = json.load(f)
203 self.core_memory = data.get('core_memory', {})
204 self.skills = data.get('skills', [])
205 except Exception as e:
206 console.print(f"[yellow]Warning: Could not load memory: {e}[/yellow]")
208 def save(self):
209 """Save agent memory to disk"""
210 import json
211 try:
212 with open(self.memory_file, 'w') as f:
213 json.dump({
214 'core_memory': self.core_memory,
215 'skills': self.skills,
216 'last_updated': datetime.now().isoformat()
217 }, f, indent=2)
218 except Exception as e:
219 console.print(f"[yellow]Warning: Could not save memory: {e}[/yellow]")
221 def remember(self, key: str, value: str):
222 """Add to long-term memory"""
223 self.core_memory[key] = value
224 self.save()
225 console.print(f"[green]✓[/green] Remembered: {key}")
227 def forget(self, key: str):
228 """Remove from memory"""
229 if key in self.core_memory:
230 del self.core_memory[key]
231 self.save()
232 console.print(f"[green]✓[/green] Forgot: {key}")
233 else:
234 console.print(f"[yellow]No memory found for: {key}[/yellow]")
236 def recall(self) -> str:
237 """Get memory as formatted string for AI context"""
238 if not self.core_memory and not self.skills:
239 return ""
241 memory_str = "\n## Agent Memory\n"
242 if self.core_memory:
243 memory_str += "\n### Core Memory:\n"
244 for key, value in self.core_memory.items():
245 memory_str += f"- **{key}**: {value}\n"
247 if self.skills:
248 memory_str += "\n### Learned Skills:\n"
249 for skill in self.skills:
250 memory_str += f"- {skill}\n"
252 return memory_str
254 def learn_skill(self, skill: str):
255 """Learn a new skill"""
256 if skill not in self.skills:
257 self.skills.append(skill)
258 self.save()
259 console.print(f"[green]✓[/green] Learned skill: {skill}")
260 else:
261 console.print(f"[yellow]Already know: {skill}[/yellow]")
264class MinimalCLI:
265 """
266 Minimal CLI interface with advanced features:
267 - Simple prompt and streaming responses
268 - Permission system and hooks for safety
269 - Memory-first agents with skill learning
270 - Subagents, shadow mode, and tool control
271 """
273 def __init__(self, agent_name: str = "main", non_interactive: bool = False):
274 self.agent_name = agent_name
275 self.non_interactive = non_interactive
276 self.running = True
277 self.memory = AgentMemory(agent_name)
278 self.shadow_mode = False
279 self.vim_mode = False
280 self.active_scenario: Optional[ScenarioDefinition] = None
281 self.pending_summary_prompt: Optional[str] = None
282 self.last_user_prompt: str = ""
284 # Initialize conversation with strong System Prompt
285 self.system_prompt = (
286 f"{settings.system_prompt}\n\n"
287 "## OPERATIONAL DIRECTIVES:\n"
288 "1. You are a Sovereign AGI Terminal with direct access to the host system via TOOLS.\n"
289 "2. When a user asks for real-time information (time, weather, search) or system operations (files, shell), "
290 "you MUST NOT apologize or say you cannot do it. You must use the relevant TOOL immediately.\n"
291 "3. If a tool fails, analyze the error and try a different approach or tool or run a known command that achieves the objective requested..\n"
292 "4. Your goal is to be an autonomous operator, not just a chat assistant.\n"
293 "5. Maintain a clean, professional, and efficient tone."
294 )
296 # Add memory context
297 memory_context = self.memory.recall()
298 if memory_context:
299 self.system_prompt += f"\n\n{memory_context}"
301 self.conversation = [{"role": "system", "content": self.system_prompt}]
303 # Load OSIRIS.md context if exists
304 self.load_context_file()
306 # Auto-load project context files
307 self.auto_load_contexts()
309 def auto_load_contexts(self):
310 """Auto-load project context files"""
311 from .context_manager import context_manager
313 loaded = context_manager.auto_load()
314 if loaded > 0:
315 console.print(f"[dim]📚 Loaded {loaded} project context file(s)[/dim]")
317 def _extract_hallucinated_tools(self, text: str) -> dict:
318 import re
319 import json
320 from datetime import datetime
321 tool_calls = {}
322 blocks = re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL)
323 if not blocks:
324 last_start = text.rfind('<tool_call>')
325 if last_start != -1:
326 blocks = [text[last_start:]]
327 for i, block in enumerate(blocks):
328 name_match = re.search(r'function=(["\'])?(.*?)(?:\1|>|\s)', block)
329 if not name_match: name_match = re.search(r'<function=(.*?)>', block)
330 if name_match:
331 name = name_match.group(2) if len(name_match.groups()) > 1 else name_match.group(1)
332 name = (name or "").strip()
333 if not name: continue
334 params = {}
335 param_matches = re.finditer(r'<parameter=(["\'])?(.*?)(?:\1|>)\s*(.*?)\s*</parameter>', block, re.DOTALL)
336 for pm in param_matches: params[pm.group(2).strip()] = pm.group(3).strip()
337 if not params and "command" in block:
338 cmd_match = re.search(r'<parameter=command>(.*?)</parameter>', block, re.DOTALL)
339 if cmd_match: params["command"] = cmd_match.group(1).strip()
340 tool_calls[i + 1000] = {
341 "id": f"hallucinated-{i}-{datetime.now().strftime("%H%M%S")}",
342 "name": name, "arguments": json.dumps(params)
343 }
344 return tool_calls
346 def _prepare_prompt(self, user_input: str) -> str:
347 """Detect scenarios and enrich the prompt when necessary."""
348 event_history.log("user_prompt", {"text": user_input})
349 self.last_user_prompt = user_input
350 self.pending_summary_prompt = None
351 scenario = detect_scenario(user_input)
352 self.active_scenario = scenario
354 if scenario:
355 self._load_scenario_context(scenario)
356 self.pending_summary_prompt = scenario.summary_prompt
357 console.print(f"[dim]🧭 Scenario detected: {scenario.title}[/dim]")
358 return f"{scenario.instructions}\n\nUser request: {user_input}"
360 return user_input
362 def _load_scenario_context(self, scenario: ScenarioDefinition):
363 """Load scenario-specific context files if they exist."""
364 from .context_manager import context_manager
366 loaded_paths = resolve_context_files(scenario)
367 if not loaded_paths:
368 console.print("[dim]No scenario context files found for this repository[/dim]")
369 return
371 for path in loaded_paths:
372 context_manager.add_file(path)
374 def _should_auto_extend_tools(self) -> bool:
375 """Auto-extend tool rounds when scenarios are active."""
376 return bool(self.active_scenario and self.active_scenario.auto_continue)
378 async def _request_summary(self, prompt_text: str) -> str:
379 """Ask the model for a closing summary without invoking tools."""
380 from .client import get_async_client
382 summary_messages = self.conversation + [{"role": "user", "content": prompt_text}]
383 summary_stream = await get_async_client().create_completion(
384 model=settings.default_model,
385 messages=summary_messages,
386 temperature=settings.temperature,
387 stream=True,
388 tools=None,
389 )
391 summary = ""
392 sanitizer = ResponseSanitizer()
393 async for chunk in summary_stream:
394 if not chunk.choices:
395 continue
396 delta = chunk.choices[0].delta
397 if delta.content:
398 cleaned = sanitizer.sanitize(delta.content)
399 if cleaned:
400 summary += cleaned
401 return summary.strip()
403 async def _maybe_emit_summary(self, latest_response: str):
404 """Ensure a final response is provided even after extensive tool use."""
405 if latest_response and latest_response.strip():
406 return
407 if not self.pending_summary_prompt:
408 return
410 summary = await self._request_summary(self.pending_summary_prompt)
411 if summary:
412 console.print(f"\n{summary}\n")
413 self.conversation.append({"role": "assistant", "content": summary})
414 session.add_message("assistant", summary)
415 event_history.log("assistant_summary", {"text": summary[:400]})
418 def _save_history(self, history_file: Path):
419 """Save readline history"""
420 try:
421 readline.write_history_file(str(history_file))
422 except (OSError, IOError):
423 pass
425 def load_context_file(self):
426 """Load OSIRIS.md context file if it exists"""
427 osiris_md = Path.cwd() / "OSIRIS.md"
428 if osiris_md.exists():
429 try:
430 with open(osiris_md, 'r') as f:
431 content = f.read()
432 self.conversation.append({
433 "role": "system",
434 "content": f"## Project Context (from OSIRIS.md)\n\n{content}"
435 })
436 console.print("[dim]Loaded OSIRIS.md context[/dim]")
437 except Exception as e:
438 console.print(f"[yellow]Warning: Could not load OSIRIS.md: {e}[/yellow]")
440 def show_welcome(self):
441 """Display welcome message"""
442 # OSIRIS ASCII Logo - Techy, clear design
443 logo = """
444 ██████╗ ███████╗██╗██████╗ ██╗███████╗
445██╔═══██╗██╔════╝██║██╔══██╗██║██╔════╝
446██║ ██║███████╗██║██████╔╝██║███████╗
447██║ ██║╚════██║██║██╔══██╗██║╚════██║
448╚██████╔╝███████║██║██║ ██║██║███████║
449 ╚═════╝ ╚══════╝╚═╝╚═╝ ╚═╝╚═╝╚══════╝
450"""
451 console.print(f"[cyan]{logo}[/cyan]")
452 console.print()
454 # Status line
455 yolo_status = "[red]YOLO MODE[/red]" if settings.yolo_mode else ""
456 console.print(f"Osiris CLI v4.0 | Agent: {self.agent_name} | Model: {settings.default_model} | {yolo_status}")
457 console.print()
459 # Instructions
460 console.print("[bold]Commands:[/bold]")
461 console.print(" Type / for command menu")
462 console.print(" /help for all commands")
463 console.print()
464 console.print("[bold]Keyboard Shortcuts:[/bold]")
465 console.print(" [cyan]Ctrl+R[/cyan] - Retry last prompt")
466 console.print(" [cyan]Ctrl+H[/cyan] - Show history")
467 console.print(" [cyan]Ctrl+T[/cyan] - Toggle YOLO mode")
468 console.print(" [cyan]Ctrl+C[/cyan] - Stop generation / Exit")
469 console.print()
471 def show_help(self):
472 """Display help information"""
473 console.print("\n[bold cyan]Osiris CLI v4.0 - Command Reference[/bold cyan]\n")
475 console.print("[bold]Core Commands:[/bold]")
476 console.print(" /help - Show this help message")
477 console.print(" /status - Show current configuration")
478 console.print(" /config - Show detailed configuration")
479 console.print(" /history - Show conversation history")
480 console.print(" /clear - Clear screen")
481 console.print(" /reset - Reset conversation (keep memory)")
482 console.print(" /exit, /quit - Exit Osiris")
483 console.print()
485 console.print("[bold]Configuration:[/bold]")
486 console.print(" /setup - Run setup wizard")
487 console.print(" /provider - Select AI provider")
488 console.print(" /model - Select model")
489 console.print(" /yolo - Toggle YOLO mode")
490 console.print()
492 console.print("[bold]Memory & Sessions:[/bold]")
493 console.print(" /memory - Manage agent memory")
494 console.print(" /session - Manage sessions")
495 console.print(" /remember - Add to memory")
496 console.print(" /forget - Remove from memory")
497 console.print(" /recall - View memory")
498 console.print()
500 console.print("[bold]Advanced Features:[/bold]")
501 console.print(" /cost - Show cost tracking & statistics")
502 console.print(" /plan - Plan mode for complex tasks")
503 console.print(" /checkpoint - Save/restore conversation states")
504 console.print(" /database - Database operations menu")
505 console.print(" /cloud - Cloud operations menu")
506 console.print()
508 console.print("[bold]Tools:[/bold]")
509 console.print(" /tools - Show available tools")
510 console.print()
512 console.print("[bold]Advanced Features:[/bold]")
513 console.print(" /context - Manage project context files")
514 console.print(" /analyze - Analyze code quality")
515 console.print(" /refactor - Get refactoring suggestions")
516 console.print(" /performance - Show performance stats")
517 console.print(" /cache - Manage response cache")
518 console.print()
520 console.print("[bold]Keyboard Shortcuts:[/bold]")
521 console.print(" [cyan]Arrow Up/Down[/cyan] - Navigate prompt history")
522 console.print(" [cyan]Ctrl+C[/cyan] - Stop generation / Exit")
523 console.print(" [cyan]Ctrl+D[/cyan] - Exit")
524 console.print()
526 console.print("[dim]Tip: Type / to see the interactive command menu[/dim]\n")
528 async def get_prompt(self) -> str:
529 """Get user input with LIVE real-time command palette"""
530 from .live_palette import get_live_input
532 try:
533 prompt_symbol = ":" if self.vim_mode else ">"
534 user_input = await get_live_input(prompt_symbol)
535 return user_input
536 except (KeyboardInterrupt, EOFError):
537 return "/exit"
539 async def handle_slash_command(self, command: str) -> bool:
540 """Handle slash commands with live interactive menus, return True if handled"""
541 parts = command.split(maxsplit=1)
542 cmd = parts[0]
543 args = parts[1] if len(parts) > 1 else ""
545 if cmd == "/help":
546 self.show_help()
547 return True
549 elif cmd == "/history":
550 # Show conversation history
551 from .conversation_history import conversation_history
553 prompts = conversation_history.get_all()
554 if not prompts:
555 console.print("[yellow]No conversation history yet[/yellow]")
556 return True
558 console.print(f"\n[bold]Conversation History ({len(prompts)} prompts):[/bold]\n")
560 # Show last 20 prompts
561 recent_prompts = prompts[-20:]
562 for i, prompt in enumerate(recent_prompts, 1):
563 # Truncate long prompts
564 display_prompt = prompt[:100] + "..." if len(prompt) > 100 else prompt
565 console.print(f" [dim]{i}.[/dim] {display_prompt}")
567 if len(prompts) > 20:
568 console.print(f"\n[dim]Showing last 20 of {len(prompts)} prompts[/dim]")
570 console.print("\n[dim]Tip: Use arrow up/down to navigate history, Ctrl+R to retry last prompt[/dim]\n")
571 return True
574 elif cmd == "/setup":
575 # Live setup menu
576 from .setup import show_setup_menu
577 await show_setup_menu()
578 return True
580 elif cmd == "/status":
581 # Show system status
582 from .status import show_status
583 show_status()
584 return True
586 elif cmd == "/config":
587 # Show full configuration
588 from .status import show_config
589 show_config()
590 return True
592 elif cmd in ["/exit", "/quit"]:
593 self.running = False
594 return True
596 elif cmd == "/clear":
597 os.system('clear' if os.name != 'nt' else 'cls')
598 return True
600 elif cmd == "/reset":
601 self.conversation = []
602 console.print("[green]✓[/green] Session reset (memory preserved)")
603 return True
605 elif cmd == "/provider":
606 # Provider selection with API key management and auto model loading
607 from .live_menus import select_provider
608 selected = await select_provider()
609 if selected:
610 # Reinitialize async client with new provider
611 from .client import get_async_client
612 get_async_client().reinitialize()
614 # Show current status
615 console.print()
616 console.print(f"[bold]Current Configuration:[/bold]")
617 console.print(f" Provider: [cyan]{settings.provider}[/cyan]")
618 console.print(f" Model: [cyan]{settings.default_model}[/cyan]")
619 console.print()
620 return True
622 elif cmd == "/model":
623 # Global model selection (updates provider automatically)
624 from .live_menus import select_model
625 selected = await select_model()
626 if selected:
627 # Reinitialize async client with new provider/config
628 from .client import get_async_client
629 get_async_client().reinitialize()
631 # Show confirmation
632 console.print()
633 console.print(f"[bold]Current Configuration:[/bold]")
634 console.print(f" Provider: [cyan]{settings.provider}[/cyan]")
635 console.print(f" Model: [cyan]{settings.default_model}[/cyan]")
636 if settings.provider_base_url:
637 console.print(f" Base URL: [dim]{settings.provider_base_url}[/dim]")
638 console.print()
639 return True
642 elif cmd == "/yolo":
643 settings.yolo_mode = not settings.yolo_mode
644 settings.save()
645 status = "enabled" if settings.yolo_mode else "disabled"
646 console.print(f"[green]✓[/green] YOLO mode {status}")
647 return True
649 elif cmd == "/context":
650 # Context file management
651 from .context_manager import context_manager
653 if not args:
654 # Show loaded contexts
655 contexts = context_manager.list_contexts()
656 if not contexts:
657 console.print("[yellow]No context files loaded[/yellow]")
658 console.print("\n[dim]Usage:[/dim]")
659 console.print(" /context add <file> - Add context file")
660 console.print(" /context list - List loaded contexts")
661 console.print(" /context clear - Clear all contexts")
662 else:
663 console.print(f"\n{context_manager.get_context_summary()}")
664 elif args.startswith("add "):
665 file_path = Path(args[4:].strip())
666 context_manager.add_file(file_path)
667 elif args == "list":
668 console.print(f"\n{context_manager.get_context_summary()}")
669 elif args == "clear":
670 context_manager.clear()
671 elif args.startswith("search "):
672 query = args[7:].strip()
673 results = context_manager.search(query)
674 if results:
675 console.print(f"\n[bold]Found {len(results)} result(s):[/bold]\n")
676 for file_name, line_num, line_text in results[:20]:
677 console.print(f" {file_name}:{line_num} - {line_text}")
678 else:
679 console.print("[yellow]No results found[/yellow]")
680 else:
681 console.print("[yellow]Unknown context command[/yellow]")
682 return True
684 elif cmd == "/analyze":
685 # Code analysis
686 from .code_analyzer import code_analyzer
688 if not args:
689 console.print("[yellow]Usage: /analyze <file>[/yellow]")
690 else:
691 file_path = Path(args.strip())
692 code_analyzer.display_analysis(file_path)
693 return True
695 elif cmd == "/refactor":
696 # Refactoring suggestions
697 from .code_analyzer import code_analyzer
699 if not args:
700 console.print("[yellow]Usage: /refactor <file>[/yellow]")
701 else:
702 file_path = Path(args.strip())
703 suggestions = code_analyzer.suggest_refactoring(file_path)
704 console.print(f"\n[bold]Refactoring Suggestions for {file_path.name}:[/bold]\n")
705 for suggestion in suggestions:
706 console.print(f" • {suggestion}")
707 console.print()
708 return True
710 elif cmd == "/performance":
711 # Show performance statistics
712 from .performance import performance_optimizer
714 stats = performance_optimizer.get_stats()
715 console.print("\n[bold]Performance Statistics:[/bold]\n")
716 console.print(f" API Calls: {stats['api_calls']}")
717 console.print(f" Cache Hits: {stats['cache_hits']}")
718 console.print(f" Cache Misses: {stats['cache_misses']}")
719 console.print(f" Cache Hit Rate: {stats['cache_hit_rate']}")
720 console.print(f" Total Tokens: {stats['total_tokens']:,}")
721 console.print(f" Avg Response Time: {stats['avg_response_time']}")
722 console.print(f" Cache Size: {stats['cache_size_kb']}")
723 console.print()
724 return True
726 elif cmd == "/cache":
727 # Cache management
728 from .performance import performance_optimizer
730 if args == "clear":
731 performance_optimizer.clear_cache()
732 elif args == "stats":
733 stats = performance_optimizer.get_stats()
734 console.print(f"\n[bold]Cache Statistics:[/bold]\n")
735 console.print(f" Entries: {stats['cache_entries']}")
736 console.print(f" Size: {stats['cache_size_kb']}")
737 console.print(f" Hit Rate: {stats['cache_hit_rate']}")
738 console.print()
739 else:
740 console.print("[yellow]Usage: /cache [clear|stats][/yellow]")
741 return True
744 elif cmd == "/remember":
745 if args:
746 # Parse key:value format
747 if ':' in args:
748 key, value = args.split(':', 1)
749 self.memory.remember(key.strip(), value.strip())
750 else:
751 console.print("[yellow]Usage: /remember key:value[/yellow]")
752 else:
753 console.print("[yellow]Usage: /remember key:value[/yellow]")
754 return True
756 elif cmd == "/forget":
757 if args:
758 self.memory.forget(args)
759 else:
760 console.print("[yellow]Usage: /forget key[/yellow]")
761 return True
763 elif cmd == "/memory":
764 # Live memory management menu
765 from .live_menus import show_memory_menu
766 action = await show_memory_menu(self.agent_name)
767 if action == "view":
768 self.show_memory()
769 elif action == "add":
770 # Interactive add
771 key = input("Memory key: ").strip()
772 if key:
773 value = input("Memory value: ").strip()
774 if value:
775 self.memory.remember(key, value)
776 else:
777 console.print("[yellow]Value required[/yellow]")
778 else:
779 console.print("[yellow]Key required[/yellow]")
780 elif action == "remove":
781 # Interactive remove
782 if self.memory.core_memory:
783 console.print("\n[bold]Current memories:[/bold]")
784 for key in self.memory.core_memory.keys():
785 console.print(f" • {key}")
786 key_to_remove = input("\nKey to remove: ").strip()
787 if key_to_remove:
788 self.memory.forget(key_to_remove)
789 else:
790 console.print("[dim]No memories to remove[/dim]")
791 elif action == "init":
792 self.init_memory()
793 elif action == "skill":
794 skill_name = input("Skill name: ").strip()
795 if skill_name:
796 self.memory.learn_skill(skill_name)
797 else:
798 console.print("[yellow]Skill name required[/yellow]")
799 return True
801 elif cmd == "/init":
802 self.init_memory()
803 return True
805 elif cmd == "/skill":
806 if args:
807 self.memory.learn_skill(args)
808 else:
809 console.print("[yellow]Usage: /skill skill_name[/yellow]")
810 return True
812 elif cmd == "/shadow":
813 self.shadow_mode = not self.shadow_mode
814 status = "enabled" if self.shadow_mode else "disabled"
815 console.print(f"[green]✓[/green] Shadow mode {status}")
816 if self.shadow_mode:
817 console.print("[dim]Changes will be tested before applying[/dim]")
818 return True
820 elif cmd == "/vim":
821 self.vim_mode = not self.vim_mode
822 status = "enabled" if self.vim_mode else "disabled"
823 console.print(f"[green]✓[/green] Vim mode {status}")
824 return True
826 elif cmd == "/session":
827 # Live session selection menu
828 from .live_menus import select_session
829 selected = await select_session()
830 if selected and selected != "__delete__":
831 # Load the session
832 from .session import session
833 if session.load_session(selected):
834 # Update conversation with loaded messages
835 self.conversation = session.messages.copy()
836 console.print(f"[green]✓[/green] Loaded session: {selected}")
837 console.print(f"[dim]Messages: {len(self.conversation)} | Tokens: {session.total_tokens}[/dim]")
838 else:
839 console.print(f"[red]Failed to load session: {selected}[/red]")
840 return True
842 elif cmd == "/context":
843 self.show_context()
844 return True
846 elif cmd == "/tools":
847 # Live tools menu
848 from .live_menus import show_tools_menu
849 await show_tools_menu()
850 return True
852 elif cmd == "/plan":
853 console.print("[cyan]Plan Mode:[/cyan] I'll outline my strategy before executing.")
854 return False # Let AI handle the planning
856 return False
858 def show_help(self):
859 """Display help message"""
860 help_text = """
861# Osiris CLI v4.0 - Command Reference
863## Core Commands
864- `/help` - Show this help message
865- `/exit` - Exit Osiris
867## Model & Provider
868- `/model [name]` - Change AI model
869- `/provider [name]` - Switch provider
870- `/yolo` - Toggle autonomous mode
872## Memory System (Agent Learning)
873- `/remember key:value` - Add to agent memory
874- `/forget key` - Remove from memory
875- `/memory` - Show agent memory
876- `/init` - Initialize agent memory
877- `/skill name` - Learn a new skill
879## Session Management
880- `/session` - Manage sessions
881- `/clear` - Clear screen
882- `/reset` - Reset session (keep memory)
884## Context & Tools
885- `/context` - Show context files
886- `/tools` - List available tools
888## Advanced Features
889- `/shadow` - Toggle shadow mode (test before apply)
890- `/vim` - Toggle Vim keybindings
891- `/plan` - Plan before execution
893## Tips
894- Create `OSIRIS.md` in your project for context
895- Use `/remember` to teach the agent about your project
896- Memory persists across sessions
897 """
898 console.print(Markdown(help_text))
900 def show_memory(self):
901 """Display agent memory"""
902 if not self.memory.core_memory and not self.memory.skills:
903 console.print("[dim]No memory stored yet. Use /remember or /init[/dim]")
904 return
906 console.print(f"\n[bold cyan]Agent Memory: {self.agent_name}[/bold cyan]\n")
908 if self.memory.core_memory:
909 console.print("[bold]Core Memory:[/bold]")
910 for key, value in self.memory.core_memory.items():
911 console.print(f" • [cyan]{key}[/cyan]: {value}")
912 console.print()
914 if self.memory.skills:
915 console.print("[bold]Learned Skills:[/bold]")
916 for skill in self.memory.skills:
917 console.print(f" • {skill}")
918 console.print()
920 def init_memory(self):
921 """Initialize agent memory interactively"""
922 console.print("[cyan]Initializing agent memory...[/cyan]\n")
923 console.print("Tell me about this project (or press Enter to skip):")
925 project_desc = input("Project description: ").strip()
926 if project_desc:
927 self.memory.remember("project_description", project_desc)
929 tech_stack = input("Tech stack: ").strip()
930 if tech_stack:
931 self.memory.remember("tech_stack", tech_stack)
933 coding_style = input("Coding style preferences: ").strip()
934 if coding_style:
935 self.memory.remember("coding_style", coding_style)
937 console.print("\n[green]✓[/green] Memory initialized!")
939 def show_sessions(self):
940 """Show available sessions"""
941 sessions_dir = Path.home() / ".osiris" / "sessions"
942 if not sessions_dir.exists():
943 console.print("[dim]No saved sessions[/dim]")
944 return
946 sessions = list(sessions_dir.glob("*.json"))
947 if not sessions:
948 console.print("[dim]No saved sessions[/dim]")
949 return
951 console.print("\n[bold cyan]Saved Sessions:[/bold cyan]\n")
952 for i, session_file in enumerate(sessions, 1):
953 console.print(f"{i}. {session_file.stem}")
954 console.print()
956 def show_context(self):
957 """Show context files"""
958 osiris_md = Path.cwd() / "OSIRIS.md"
959 if osiris_md.exists():
960 console.print("[green]✓[/green] OSIRIS.md found")
961 with open(osiris_md, 'r') as f:
962 preview = f.read()[:200]
963 console.print(f"[dim]{preview}...[/dim]")
964 else:
965 console.print("[yellow]No OSIRIS.md found in current directory[/yellow]")
966 console.print("[dim]Create one to provide project context[/dim]")
968 def show_tools(self):
969 """Show available tools"""
970 tool_defs = tools.get_definitions()
971 console.print(f"\n[bold cyan]Available Tools ({len(tool_defs)}):[/bold cyan]\n")
972 for name, tool in tool_defs.items():
973 console.print(f" • [cyan]{name}[/cyan]: {tool['description']}")
974 console.print()
976 async def confirm_tool_execution(self, tool_name: str, args: Dict) -> bool:
977 """Ask user to confirm tool execution"""
978 if settings.yolo_mode or tool_name in settings.auto_allow_tools:
979 return True
981 console.print(f"\n[yellow]Tool Request:[/yellow] {tool_name}")
982 console.print(f"[dim]Arguments: {args}[/dim]")
984 return Confirm.ask("Execute this tool?", default=False)
986 async def stream_response(self, user_input: str, use_tools: bool = True):
987 """Stream AI response with tool support"""
988 from .client import get_async_client
989 from .tools import get_tools, execute_tool
990 from .activity_monitor import activity_monitor
991 from .session import session
992 import json
994 # Add user message to conversation
995 self.conversation.append({"role": "user", "content": user_input})
996 session.add_message("user", user_input)
998 # Prepare messages
999 messages = self.conversation.copy()
1001 # Add memory context if available
1002 memory_context = self.memory.recall()
1003 if memory_context and memory_context != "No memory stored yet.":
1004 messages.insert(0, {"role": "system", "content": memory_context})
1006 # Add system prompt
1007 if settings.system_prompt:
1008 messages.insert(0, {"role": "system", "content": settings.system_prompt})
1010 # Get tools if YOLO mode is enabled and use_tools is True
1011 tools = None
1012 if settings.yolo_mode and use_tools:
1013 tools = get_tools()
1014 if tools:
1015 console.print("[dim]🔧 YOLO Mode: Tools enabled and ready[/dim]\n")
1017 # Start activity monitor
1018 # Note: ActivityMonitor doesn't have start() method, it tracks tools directly
1020 console.print("\n[bold]Response:[/bold]\n")
1022 # Stream the response
1023 full_response = ""
1024 tool_calls_data = {}
1025 sanitizer = ResponseSanitizer()
1028 try:
1029 # Make initial API call with tools
1030 response = await get_async_client().create_completion(
1031 model=settings.default_model,
1032 messages=messages,
1033 temperature=settings.temperature,
1034 stream=True,
1035 tools=tools if tools else None,
1036 )
1038 full_response = ""
1039 raw_response = ""
1040 tool_calls_data = {}
1043 async for chunk in response:
1044 if not chunk.choices:
1045 continue
1047 delta = chunk.choices[0].delta
1049 # Handle content
1050 if delta.content:
1051 raw_response += delta.content
1052 clean_content = sanitizer.sanitize(delta.content)
1053 if clean_content:
1054 console.print(clean_content, end="")
1055 full_response += clean_content
1059 # Handle tool calls
1060 if delta.tool_calls:
1061 for tc in delta.tool_calls:
1062 idx = tc.index
1063 if idx not in tool_calls_data:
1064 tool_calls_data[idx] = {
1065 'id': tc.id or '',
1066 'name': '',
1067 'arguments': ''
1068 }
1070 if tc.id:
1071 tool_calls_data[idx]['id'] = tc.id
1072 if tc.function:
1073 if tc.function.name:
1074 tool_calls_data[idx]['name'] = tc.function.name
1075 if tc.function.arguments:
1076 tool_calls_data[idx]['arguments'] += tc.function.arguments
1078 console.print("\n")
1080 # Check for hallucinated tool calls if no official ones found
1081 if not tool_calls_data:
1082 tool_calls_data = self._extract_hallucinated_tools(raw_response)
1085 # Execute tool calls if any
1086 if tool_calls_data and settings.yolo_mode:
1087 from rich.panel import Panel
1088 from rich.table import Table
1090 # Track iterations to optionally prevent infinite loops
1091 user_defined_limit = getattr(settings, "max_iterations", None)
1092 if user_defined_limit is not None and user_defined_limit <= 0:
1093 user_defined_limit = None
1095 default_rounds = getattr(settings, "default_tool_rounds", 5) or 0
1096 if default_rounds <= 0:
1097 default_rounds = None
1098 extension_rounds = getattr(settings, "tool_round_extension", 5) or 0
1099 if extension_rounds <= 0:
1100 extension_rounds = default_rounds or 5
1102 max_iterations = user_defined_limit or default_rounds
1103 dynamic_limit = user_defined_limit is None and max_iterations is not None
1105 current_iteration = 0
1107 while True:
1108 current_iteration += 1
1110 # Show Round Header
1111 round_label = (
1112 f"[cyan]Agent Action (Round {current_iteration}/{max_iterations})[/cyan]"
1113 if max_iterations
1114 else f"[cyan]Agent Action (Round {current_iteration})[/cyan]"
1115 )
1116 console.print()
1117 console.print(Panel(
1118 round_label,
1119 border_style="cyan",
1120 padding=(0, 1)
1121 ))
1122 console.print()
1124 execute_results = []
1126 # 1. Execute all tool calls in the current turn
1127 for idx, tool_call in tool_calls_data.items():
1128 tool_name = tool_call['name']
1129 tool_args_str = tool_call['arguments']
1131 activity_monitor.start_tool(tool_name, {})
1132 console.print(f" [bold cyan]→[/bold cyan] {tool_name}")
1134 try:
1135 args_dict = json.loads(tool_args_str) if tool_args_str else {}
1136 if args_dict:
1137 args_str = ", ".join(f"{k}={v}" for k, v in args_dict.items())
1138 console.print(f" [dim]{args_str[:70]}{'...' if len(args_str) > 70 else ''}[/dim]")
1139 event_history.log("tool_invocation", {"name": tool_name, "arguments": args_dict})
1141 # Execute the tool
1142 result = execute_tool(tool_name, args_dict)
1143 result_str = str(result)
1145 console.print(f" [green]✓[/green] [dim]{result_str[:150]}{'...' if len(result_str) > 150 else ''}[/dim]")
1146 activity_monitor.end_tool(result_str[:200], success=True)
1147 event_history.log("tool_result", {"name": tool_name, "result": result_str[:200]})
1149 execute_results.append({
1150 'id': tool_call['id'],
1151 'name': tool_name,
1152 'result': result_str
1153 })
1154 except Exception as e:
1155 console.print(f" [red]✗ Error: {e}[/red]")
1156 activity_monitor.end_tool(str(e), success=False)
1157 event_history.log("tool_error", {"name": tool_name, "error": str(e)})
1158 execute_results.append({
1159 'id': tool_call['id'],
1160 'name': tool_name,
1161 'result': f"Error: {str(e)}"
1162 })
1164 # 2. Add turn to conversation history
1165 # Add content if any was generated before tools
1166 self.conversation.append({
1167 "role": "assistant",
1168 "content": raw_response or None,
1169 "tool_calls": [{
1170 "id": tc['id'],
1171 "type": "function",
1172 "function": {"name": tc['name'], "arguments": tool_calls_data[idx]['arguments']}
1173 } for idx, tc in enumerate(execute_results)]
1174 })
1176 for res in execute_results:
1177 self.conversation.append({
1178 "role": "tool",
1179 "tool_call_id": res['id'],
1180 "content": res['result']
1181 })
1183 # 3. Get NEXT response from model
1184 console.print(f"\n[dim]Analyzing results...[/dim]\n")
1186 # Reset data for next turn
1187 raw_response = ""
1188 tool_calls_data = {}
1189 sanitizer = ResponseSanitizer()
1191 # Call model again
1192 async_client = get_async_client()
1193 next_response = await async_client.create_completion(
1194 model=settings.default_model,
1195 messages=self.conversation,
1196 temperature=settings.temperature,
1197 tools=get_tools(),
1198 stream=True,
1199 )
1201 async for chunk in next_response:
1202 if not chunk.choices: continue
1203 delta = chunk.choices[0].delta
1205 if delta.content:
1206 raw_response += delta.content
1207 clean = sanitizer.sanitize(delta.content)
1208 if clean:
1209 console.print(clean, end="")
1211 if delta.tool_calls:
1212 for tc in delta.tool_calls:
1213 tidx = tc.index
1214 if tidx not in tool_calls_data:
1215 tool_calls_data[tidx] = {'id': tc.id or '', 'name': '', 'arguments': ''}
1216 if tc.id: tool_calls_data[tidx]['id'] = tc.id
1217 if tc.function:
1218 if tc.function.name: tool_calls_data[tidx]['name'] = tc.function.name
1219 if tc.function.arguments: tool_calls_data[tidx]['arguments'] += tc.function.arguments
1221 # Extract hallucinated tools from net text
1222 if not tool_calls_data:
1223 tool_calls_data = self._extract_hallucinated_tools(raw_response)
1225 # If NO tools were called in this turn, we are DONE
1226 if not tool_calls_data:
1227 self.conversation.append({"role": "assistant", "content": raw_response})
1228 session.add_message("assistant", raw_response)
1229 event_history.log("assistant_response", {"text": (raw_response or "")[:400]})
1230 break
1232 if max_iterations and current_iteration >= max_iterations:
1233 if dynamic_limit:
1234 if self._should_auto_extend_tools():
1235 max_iterations += extension_rounds
1236 console.print(f"[green]Auto-extending action budget to {max_iterations} rounds.[/green]\n")
1237 continue
1238 console.print(f"\n[yellow]⚠ Reached default action budget ({max_iterations} rounds).[/yellow]")
1239 try:
1240 allow_more = Confirm.ask(
1241 f"Need more time? Continue with +{extension_rounds} rounds?",
1242 default=True
1243 )
1244 except Exception:
1245 allow_more = False
1247 if allow_more:
1248 max_iterations += extension_rounds
1249 console.print(f"[green]Extending action budget to {max_iterations} rounds.[/green]\n")
1250 else:
1251 console.print("[yellow]Stopping tool execution at user's request.[/yellow]\n")
1252 break
1253 else:
1254 console.print(f"\n[yellow]⚠ Max iterations ({max_iterations}) reached. Stopping.[/yellow]\n")
1255 break
1257 else:
1258 # No tool calls, just add the response
1259 self.conversation.append({"role": "assistant", "content": raw_response or full_response})
1260 session.add_message("assistant", full_response)
1261 event_history.log("assistant_response", {"text": (raw_response or full_response or "")[:400]})
1263 await self._maybe_emit_summary(raw_response or full_response)
1265 except KeyboardInterrupt:
1266 console.print("\n\n[yellow]⚠ Generation stopped by user[/yellow]")
1267 except NotFoundError as e:
1268 err_msg = str(e)
1269 if ("tool" in err_msg.lower() or "endpoint" in err_msg.lower()) and use_tools:
1270 console.print(f"\n[bold yellow]⚠ Model '{settings.default_model}' does not support tool use.[/bold yellow]")
1271 console.print("[dim]Retrying without tools...[/dim]\n")
1272 await self.stream_response(user_input, use_tools=False)
1273 else:
1274 console.print(f"\n\n[red]Error: {e}[/red]")
1275 except RateLimitError:
1276 console.print(f"\n\n[bold yellow]⏱ Rate limit exceeded for {settings.provider}.[/bold yellow]")
1277 console.print("[dim]Try a different model or wait a few seconds.[/dim]")
1278 except BadRequestError as e:
1279 err_msg = str(e).lower()
1280 if ("tool" in err_msg or "function" in err_msg) and use_tools:
1281 console.print(f"\n[bold yellow]⚠ Model '{settings.default_model}' reported a tool-calling error.[/bold yellow]")
1282 console.print("[dim]Retrying without tools...[/dim]\n")
1283 await self.stream_response(user_input, use_tools=False)
1284 else:
1285 console.print(f"\n\n[bold red]Invalid Request:[/bold red] {e}")
1286 if "model" in str(e).lower():
1287 console.print("[yellow]Hint: The model might be deprecated. Try selecting a new one with /model[/yellow]")
1288 except Exception as e:
1289 console.print(f"\n\n[bold red]Error:[/bold red] {e}")
1290 # Log full traceback to hidden file for debugging instead of terminal
1291 try:
1292 with open("/root/.osiris/error.log", "a") as f:
1293 f.write(f"\n--- {datetime.now()} ---\n")
1294 f.write(traceback.format_exc())
1295 except:
1296 pass
1297 finally:
1298 # Reset scenario state after each response
1299 self.active_scenario = None
1300 self.pending_summary_prompt = None
1303 async def run_interactive(self):
1304 """Main interactive CLI loop"""
1305 self.show_welcome()
1307 while self.running:
1308 user_input = await self.get_prompt()
1310 if not user_input:
1311 continue
1313 # Handle slash commands
1314 if user_input.startswith("/"):
1315 if await self.handle_slash_command(user_input):
1316 continue
1318 prepared_input = self._prepare_prompt(user_input)
1320 # Stream AI response
1321 try:
1322 await self.stream_response(prepared_input)
1323 except KeyboardInterrupt:
1324 console.print("\n[yellow]Interrupted[/yellow]")
1325 continue
1326 except Exception as e:
1327 console.print(f"\n[red]Error: {e}[/red]")
1328 continue
1330 summary_path = write_session_summary(event_history, self.agent_name)
1331 if summary_path:
1332 console.print(f"\n[dim]Session summary saved to {summary_path}[/dim]")
1333 event_history.clear()
1334 console.print("\n[cyan]Goodbye![/cyan]\n")
1336 async def run_non_interactive(self, prompt: str):
1337 """Run in non-interactive mode for scripting"""
1338 prepared_input = self._prepare_prompt(prompt)
1339 await self.stream_response(prepared_input)
1340 summary_path = write_session_summary(event_history, self.agent_name)
1341 if summary_path:
1342 console.print(f"[dim]Session summary saved to {summary_path}[/dim]")
1343 event_history.clear()
1346async def run_minimal_cli(agent_name: str = "main", prompt: Optional[str] = None):
1347 """Entry point for minimal CLI"""
1348 cli = MinimalCLI(agent_name=agent_name, non_interactive=bool(prompt))
1350 if prompt:
1351 await cli.run_non_interactive(prompt)
1352 else:
1353 await cli.run_interactive()