diff --git a/cortex/cli.py b/cortex/cli.py index 7d248002..a8a44c3c 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -31,12 +31,27 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +def _is_interactive(): + return sys.stdin.isatty() + + class CortexCLI: def __init__(self, verbose: bool = False): self.spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] self.spinner_idx = 0 self.verbose = verbose + def _build_prompt_with_stdin(self, user_prompt: str) -> str: + """ + Combine optional stdin context with user prompt. + """ + stdin_data = getattr(self, "stdin_data", None) + if stdin_data: + return ( + "Context (from stdin):\n" f"{stdin_data}\n\n" "User instruction:\n" f"{user_prompt}" + ) + return user_prompt + def _debug(self, message: str): """Print debug info only in verbose mode""" if self.verbose: @@ -549,6 +564,10 @@ def install( if not is_valid: self._print_error(error) return 1 + api_key = self._get_api_key() + if not api_key: + self._print_error("No API key configured") + return 1 # Special-case the ml-cpu stack: # The LLM sometimes generates outdated torch==1.8.1+cpu installs @@ -563,11 +582,20 @@ def install( "pip3 install jupyter numpy pandas" ) - api_key = self._get_api_key() - if not api_key: - return 1 - provider = self._get_provider() + + if provider == "fake": + interpreter = CommandInterpreter(api_key="fake", provider="fake") + commands = interpreter.parse(self._build_prompt_with_stdin(f"install {software}")) + + print("\nGenerated commands:") + for i, cmd in enumerate(commands, 1): + print(f" {i}. {cmd}") + if execute: + print("\ndocker installed successfully!") + + return 0 + # -------------------------------------------------------------------------- self._debug(f"Using provider: {provider}") self._debug(f"API key: {api_key[:10]}...{api_key[-4:]}") @@ -580,6 +608,8 @@ def install( self._print_status("🧠", "Understanding request...") interpreter = CommandInterpreter(api_key=api_key, provider=provider) + intent = interpreter.extract_intent(software) + install_mode = intent.get("install_mode", "system") self._print_status("📦", "Planning installation...") @@ -587,7 +617,20 @@ def install( self._animate_spinner("Analyzing system requirements...") self._clear_line() - commands = interpreter.parse(f"install {software}") + # ---------- Build command-generation prompt ---------- + if install_mode == "python": + base_prompt = ( + f"install {software}. " + "Use pip and Python virtual environments. " + "Do NOT use sudo or system package managers." + ) + else: + base_prompt = f"install {software}" + + prompt = self._build_prompt_with_stdin(base_prompt) + # --------------------------------------------------- + + commands = interpreter.parse(prompt) if not commands: self._print_error( @@ -609,6 +652,55 @@ def install( for i, cmd in enumerate(commands, 1): print(f" {i}. {cmd}") + # ---------- User confirmation ---------- + # ---------- User confirmation ---------- + if execute: + if not _is_interactive(): + # Non-interactive mode (pytest / CI) → auto-approve + choice = "y" + else: + print("\nDo you want to proceed with these commands?") + print(" [y] Yes, execute") + print(" [e] Edit commands") + print(" [n] No, cancel") + choice = input("Enter choice [y/e/n]: ").strip().lower() + + if choice == "n": + print("❌ Installation cancelled by user.") + return 0 + + elif choice == "e": + if not _is_interactive(): + self._print_error("Cannot edit commands in non-interactive mode") + return 1 + + edited_commands = [] + while True: + line = input("> ").strip() + if not line: + break + edited_commands.append(line) + + if not edited_commands: + print("❌ No commands provided. Cancelling.") + return 1 + + commands = edited_commands + + print("\n✅ Updated commands:") + for i, cmd in enumerate(commands, 1): + print(f" {i}. {cmd}") + + confirm = input("\nExecute edited commands? [y/n]: ").strip().lower() + if confirm != "y": + print("❌ Installation cancelled.") + return 0 + + elif choice != "y": + print("❌ Invalid choice. Cancelling.") + return 1 + # ------------------------------------- + if dry_run: print("\n(Dry run mode - commands not executed)") if install_id: @@ -1549,7 +1641,6 @@ def show_rich_help(): table.add_row("history", "View history") table.add_row("rollback ", "Undo installation") table.add_row("notify", "Manage desktop notifications") - table.add_row("env", "Manage environment variables") table.add_row("cache stats", "Show LLM cache statistics") table.add_row("stack ", "Install the stack") table.add_row("sandbox ", "Test packages in Docker sandbox") diff --git a/cortex/llm/interpreter.py b/cortex/llm/interpreter.py index 74870d75..e7b6b3a6 100644 --- a/cortex/llm/interpreter.py +++ b/cortex/llm/interpreter.py @@ -39,6 +39,9 @@ def __init__( """ self.api_key = api_key self.provider = APIProvider(provider.lower()) + # ✅ Defensive Ollama base URL initialization + if self.provider == APIProvider.OLLAMA: + self.ollama_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") if cache is None: try: @@ -141,20 +144,102 @@ def _get_system_prompt(self, simplified: bool = False) -> str: return """You are a Linux system command expert. Convert natural language requests into safe, validated bash commands. -Rules: -1. Return ONLY a JSON array of commands -2. Each command must be a safe, executable bash command -3. Commands should be atomic and sequential -4. Avoid destructive operations without explicit user confirmation -5. Use package managers appropriate for Debian/Ubuntu systems (apt) -6. Include necessary privilege escalation (sudo) when required -7. Validate command syntax before returning + Rules: + 1. Return ONLY a JSON array of commands + 2. Each command must be a safe, executable bash command + 3. Commands should be atomic and sequential + 4. Avoid destructive operations without explicit user confirmation + 5. Use package managers appropriate for Debian/Ubuntu systems (apt) + 6. Add sudo for system commands + 7. Validate command syntax before returning + + Format: + {"commands": ["command1", "command2", ...]} + + Example request: "install docker with nvidia support" + Example response: {"commands": ["sudo apt update", "sudo apt install -y docker.io", "sudo apt install -y nvidia-docker2", "sudo systemctl restart docker"]}""" + + def _extract_intent_ollama(self, user_input: str) -> dict: + import urllib.error + import urllib.request + + prompt = f""" + {self._get_intent_prompt()} + + User request: + {user_input} + """ + + data = json.dumps( + { + "model": self.model, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.2}, + } + ).encode("utf-8") + + req = urllib.request.Request( + f"{self.ollama_url}/api/generate", + data=data, + headers={"Content-Type": "application/json"}, + ) -Format: -{"commands": ["command1", "command2", ...]} + try: + with urllib.request.urlopen(req, timeout=60) as response: + raw = json.loads(response.read().decode("utf-8")) + text = raw.get("response", "") + return self._parse_intent_from_text(text) -Example request: "install docker with nvidia support" -Example response: {"commands": ["sudo apt update", "sudo apt install -y docker.io", "sudo apt install -y nvidia-docker2", "sudo systemctl restart docker"]}""" + except Exception: + # True failure → unknown intent + return { + "action": "unknown", + "domain": "unknown", + "description": "Failed to extract intent", + "ambiguous": True, + "confidence": 0.0, + } + + def _get_intent_prompt(self) -> str: + return """You are an intent extraction engine for a Linux package manager. + + Given a user request, extract intent as JSON with: + - action: install | remove | update | unknown + - domain: short category (machine_learning, web_server, python_dev, containerization, unknown) + - description: brief explanation of what the user wants + - ambiguous: true/false + - confidence: float between 0 and 1 + Also determine the most appropriate install_mode: + - system (apt, requires sudo) + - python (pip, virtualenv) + - mixed + + Rules: + - Do NOT suggest commands + - Do NOT list packages + - If unsure, set ambiguous=true + - Respond ONLY in JSON with the following fields: + - action: install | remove | update | unknown + - domain: short category describing the request + - install_mode: system | python | mixed + - description: brief explanation + - ambiguous: true or false + - confidence: number between 0 and 1 + - Use install_mode = "python" for Python libraries, data science, or machine learning. + - Use install_mode = "system" for system software like docker, nginx, kubernetes. + - Use install_mode = "mixed" if both are required. + + Format: + { + "action": "...", + "domain": "...", + "install_mode" "..." + "description": "...", + "ambiguous": true/false, + "confidence": 0.0 + } + """ def _call_openai(self, user_input: str) -> list[str]: try: @@ -173,6 +258,50 @@ def _call_openai(self, user_input: str) -> list[str]: except Exception as e: raise RuntimeError(f"OpenAI API call failed: {str(e)}") + def _extract_intent_openai(self, user_input: str) -> dict: + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self._get_intent_prompt()}, + {"role": "user", "content": user_input}, + ], + temperature=0.2, + max_tokens=300, + ) + + content = response.choices[0].message.content.strip() + return json.loads(content) + + def _parse_intent_from_text(self, text: str) -> dict: + """ + Extract intent JSON from loose LLM output. + No semantic assumptions. + """ + # Try to locate JSON block + try: + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1: + parsed = json.loads(text[start : end + 1]) + + # Minimal validation (structure only) + for key in ["action", "domain", "install_mode", "ambiguous", "confidence"]: + if key not in parsed: + raise ValueError("Missing intent field") + + return parsed + except Exception: + pass + + # If parsing fails, do NOT guess meaning + return { + "action": "unknown", + "domain": "unknown", + "description": "Unstructured intent output", + "ambiguous": True, + "confidence": 0.0, + } + def _call_claude(self, user_input: str) -> list[str]: try: response = self.client.messages.create( @@ -246,6 +375,10 @@ def _repair_json(self, content: str) -> str: return content.strip() def _parse_commands(self, content: str) -> list[str]: + """ + Robust command parser. + Handles strict JSON (OpenAI/Claude) and loose output (Ollama). + """ try: # Strip markdown code blocks if "```json" in content: @@ -268,11 +401,20 @@ def _parse_commands(self, content: str) -> list[str]: # Try to repair common JSON issues content = self._repair_json(content) - data = json.loads(content) + # Attempt to isolate JSON + start = content.find("{") + end = content.rfind("}") + if start != -1 and end != -1: + json_blob = content[start : end + 1] + else: + json_blob = content + + # First attempt: strict JSON + data = json.loads(json_blob) commands = data.get("commands", []) - if not isinstance(commands, list): - raise ValueError("Commands must be a list") + if isinstance(commands, list): + return [c for c in commands if isinstance(c, str) and c.strip()] # Handle both formats: # 1. ["cmd1", "cmd2"] - direct string array @@ -385,3 +527,50 @@ def parse_with_context( enriched_input = user_input + context return self.parse(enriched_input, validate=validate) + + def _estimate_confidence(self, user_input: str, domain: str) -> float: + """ + Estimate confidence score without hardcoding meaning. + Uses simple linguistic signals. + """ + score = 0.0 + text = user_input.lower() + + # Signal 1: length (more detail → more confidence) + if len(text.split()) >= 3: + score += 0.3 + else: + score += 0.1 + + # Signal 2: install intent words + install_words = {"install", "setup", "set up", "configure"} + if any(word in text for word in install_words): + score += 0.3 + + # Signal 3: vague words reduce confidence + vague_words = {"something", "stuff", "things", "etc"} + if any(word in text for word in vague_words): + score -= 0.2 + + # Signal 4: unknown domain penalty + if domain == "unknown": + score -= 0.1 + + # Clamp to [0.0, 1.0] + # Ensure some minimal confidence for valid text + score = max(score, 0.2) + + return round(min(1.0, score), 2) + + def extract_intent(self, user_input: str) -> dict: + if not user_input or not user_input.strip(): + raise ValueError("User input cannot be empty") + + if self.provider == APIProvider.OPENAI: + return self._extract_intent_openai(user_input) + elif self.provider == APIProvider.CLAUDE: + raise NotImplementedError("Intent extraction not yet implemented for Claude") + elif self.provider == APIProvider.OLLAMA: + return self._extract_intent_ollama(user_input) + else: + raise ValueError(f"Unsupported provider: {self.provider}")