diff --git a/build/lib/ollama_cli/__init__.py b/build/lib/ollama_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/ollama_cli/agents/__init__.py b/build/lib/ollama_cli/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/ollama_cli/core/__init__.py b/build/lib/ollama_cli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/ollama_cli/core/config.py b/build/lib/ollama_cli/core/config.py new file mode 100644 index 0000000..0c8987a --- /dev/null +++ b/build/lib/ollama_cli/core/config.py @@ -0,0 +1,57 @@ +import os +import json + +CONFIG_FILE = os.path.expanduser("~/.ollama-cli-config.json") + +DEFAULT_CONFIG = { + "excluded_models": [], + "git": { + "remote_url": "", + "username": "", + "password": "" + }, + "gitea": { + "url": "", + "token": "" + }, + "auto_save_sessions": True, + "mcp_servers": {}, + "ollama_url": "http://localhost:11434", + "comfy_url": "http://localhost:9000", + "piper": { + "path": "~/.ollama-cli/piper/piper", + "model": "~/.ollama-cli/piper/voice.onnx" + }, + "ntfy": { + "enabled": False, + "topic": "", + "server": "https://ntfy.sh" + }, + "default_model": "llama3.2" +} + +def load_config(): + """Load config file""" + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + # Merge with defaults to ensure all keys exist + for k, v in DEFAULT_CONFIG.items(): + if k not in config: + config[k] = v + return config + except Exception: + pass + return DEFAULT_CONFIG.copy() + +def save_config(config): + """Save config file""" + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + +def update_config(key, value): + """Update a specific config key""" + config = load_config() + config[key] = value + save_config(config) diff --git a/build/lib/ollama_cli/core/ollama.py b/build/lib/ollama_cli/core/ollama.py new file mode 100644 index 0000000..d415281 --- /dev/null +++ b/build/lib/ollama_cli/core/ollama.py @@ -0,0 +1,87 @@ +import json +import requests +import subprocess +import sys +from typing import List, Dict, Optional, Generator + +class OllamaClient: + def __init__(self, base_url: str = "http://localhost:11434"): + # Strip trailing slashes and common API paths if user provided them in config + self.base_url = base_url.rstrip("/") + if self.base_url.endswith("/api/chat"): + self.base_url = self.base_url[:-9] + elif self.base_url.endswith("/api"): + self.base_url = self.base_url[:-4] + + self.chat_url = f"{self.base_url}/api/chat" + self.list_url = f"{self.base_url}/api/tags" + + def get_available_models(self) -> List[str]: + """Get list of models available in Ollama""" + try: + # Try API first + response = requests.get(self.list_url, timeout=5) + if response.status_code == 200: + data = response.json() + return [m['name'] for m in data.get('models', [])] + + # Fallback to CLI + result = subprocess.run("ollama list", shell=True, capture_output=True, text=True, timeout=5) + models = [] + for line in result.stdout.split('\n')[1:]: # Skip header + if line.strip(): + models.append(line.split()[0]) + return models + except Exception: + return [] + + def chat(self, messages: List[Dict], model: str, stream: bool = True) -> Generator[str, None, str]: + """Call Ollama API with streaming""" + try: + response = requests.post(self.chat_url, json={ + "model": model, + "messages": messages, + "stream": stream + }, stream=stream, timeout=60) + response.raise_for_status() + + full_response = "" + if stream: + for line in response.iter_lines(): + if line: + chunk = json.loads(line) + if "message" in chunk and "content" in chunk["message"]: + content = chunk["message"]["content"] + full_response += content + yield content + return full_response + else: + data = response.json() + return data.get("message", {}).get("content", "") + + except requests.exceptions.ConnectionError: + raise ConnectionError(f"Cannot connect to Ollama at {self.base_url}. Make sure it's running.") + except Exception as e: + raise Exception(f"Error calling Ollama: {str(e)}") + + def select_best_model(self, user_input: str, available_models: List[str], default_model: str) -> str: + """Select best model based on task type""" + if not available_models: + return default_model + + # Model preferences + code_models = ["qwen2.5-coder", "mistral-nemo", "codellama", "deepseek-coder"] + general_models = ["llama3.2", "llama3.1", "mistral"] + + # Simple heuristic + code_keywords = ['code', 'script', 'program', 'function', 'debug', 'python', 'javascript'] + is_code_task = any(k in user_input.lower() for k in code_keywords) + + target_list = code_models if is_code_task else general_models + + for target in target_list: + for available in available_models: + if target in available.lower(): + return available + + return available_models[0] if available_models else default_model diff --git a/build/lib/ollama_cli/core/sessions.py b/build/lib/ollama_cli/core/sessions.py new file mode 100644 index 0000000..bc136c7 --- /dev/null +++ b/build/lib/ollama_cli/core/sessions.py @@ -0,0 +1,53 @@ +import os +import json +from datetime import datetime +from typing import List, Dict, Optional + +SESSION_DIR = os.path.expanduser("~/.ollama-cli-sessions") + +def save_session(messages: List[Dict], session_name: Optional[str] = None) -> str: + """Save conversation session""" + os.makedirs(SESSION_DIR, exist_ok=True) + if not session_name: + session_name = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Ensure filename is safe + safe_name = "".join([c for c in session_name if c.isalnum() or c in (' ', '.', '_', '-')]).strip() + session_file = os.path.join(SESSION_DIR, f"{safe_name}.json") + + with open(session_file, 'w') as f: + json.dump({ + "messages": messages, + "timestamp": datetime.now().isoformat(), + "name": session_name + }, f, indent=2) + return session_file + +def load_session(session_name: str) -> Optional[List[Dict]]: + """Load conversation session""" + session_file = os.path.join(SESSION_DIR, f"{session_name}.json") + if not os.path.exists(session_file): + # Try without extension if name doesn't have it + if not session_name.endswith('.json'): + session_file = os.path.join(SESSION_DIR, f"{session_name}.json") + + if os.path.exists(session_file): + with open(session_file, 'r') as f: + data = json.load(f) + return data.get("messages", []) + return None + +def list_sessions() -> List[str]: + """List available sessions""" + if not os.path.exists(SESSION_DIR): + return [] + sessions = [f.replace('.json', '') for f in os.listdir(SESSION_DIR) if f.endswith('.json')] + return sorted(sessions, reverse=True) + +def delete_session(session_name: str) -> bool: + """Delete a session""" + session_file = os.path.join(SESSION_DIR, f"{session_name}.json") + if os.path.exists(session_file): + os.remove(session_file) + return True + return False diff --git a/build/lib/ollama_cli/integrations/__init__.py b/build/lib/ollama_cli/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/ollama_cli/integrations/lsp.py b/build/lib/ollama_cli/integrations/lsp.py new file mode 100644 index 0000000..58153b3 --- /dev/null +++ b/build/lib/ollama_cli/integrations/lsp.py @@ -0,0 +1,75 @@ +import subprocess +import os +import json +from typing import Dict, Any, Optional + +LSP_SERVERS = {} + +def start_lsp_server(language: str, root_path: str = ".") -> str: + """Start a language server""" + lsp_commands = { + "python": ["pyright-langserver", "--stdio"], + "javascript": ["typescript-language-server", "--stdio"], + "typescript": ["typescript-language-server", "--stdio"], + "rust": ["rust-analyzer"], + "go": ["gopls"], + } + + if language not in lsp_commands: + return f"No LSP server configured for {language}" + + try: + process = subprocess.Popen( + lsp_commands[language], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=os.path.expanduser(root_path) + ) + + # Initialize + init_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": os.getpid(), + "rootUri": f"file://{os.path.abspath(os.path.expanduser(root_path))}", + "capabilities": {} + } + } + + msg = json.dumps(init_request) + process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}") + process.stdin.flush() + + LSP_SERVERS[language] = process + return f"Started LSP server for {language}" + except Exception as e: + return f"Error starting LSP: {str(e)}" + +def lsp_goto_definition(language: str, file_path: str, line: int, character: int) -> str: + """Get definition location""" + if language not in LSP_SERVERS: + return "LSP server not started" + + try: + process = LSP_SERVERS[language] + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/definition", + "params": { + "textDocument": {"uri": f"file://{os.path.abspath(file_path)}"}, + "position": {"line": line, "character": character} + } + } + + msg = json.dumps(request) + process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}") + process.stdin.flush() + + return "Definition lookup sent (response parsing not implemented)" + except Exception as e: + return f"LSP error: {str(e)}" diff --git a/build/lib/ollama_cli/integrations/mcp.py b/build/lib/ollama_cli/integrations/mcp.py new file mode 100644 index 0000000..f2fa29f --- /dev/null +++ b/build/lib/ollama_cli/integrations/mcp.py @@ -0,0 +1,103 @@ +import subprocess +import json +import os +from typing import Dict, Any, Optional, List + +MCP_SERVERS = {} +MCP_TOOLS = {} + +def connect_mcp_server(name: str, command: str, args: Optional[List[str]] = None) -> str: + """Connect to an MCP server via stdio""" + try: + cmd = [command] + (args or []) + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) + + # Send initialize request + init_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "ollama-cli", "version": "3.0"} + } + } + + process.stdin.write(json.dumps(init_request) + "\n") + process.stdin.flush() + + # Read response + response = process.stdout.readline() + if response: + data = json.loads(response) + if "result" in data: + MCP_SERVERS[name] = process + + # List tools + tools_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + } + process.stdin.write(json.dumps(tools_request) + "\n") + process.stdin.flush() + + tools_response = process.stdout.readline() + if tools_response: + tools_data = json.loads(tools_response) + if "result" in tools_data and "tools" in tools_data["result"]: + for tool in tools_data["result"]["tools"]: + tool_name = f"mcp_{name}_{tool['name']}" + MCP_TOOLS[tool_name] = { + "server": name, + "name": tool["name"], + "description": tool.get("description", ""), + "schema": tool.get("inputSchema", {}) + } + + return f"Connected to MCP server '{name}' with {len([t for t in MCP_TOOLS if t.startswith(f'mcp_{name}_')])} tools" + + return f"Failed to initialize MCP server '{name}'" + except Exception as e: + return f"Error connecting to MCP server: {str(e)}" + +def call_mcp_tool(server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str: + """Call a tool on an MCP server""" + try: + if server_name not in MCP_SERVERS: + return f"Error: MCP server '{server_name}' not connected" + + process = MCP_SERVERS[server_name] + + request = { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments + } + } + + process.stdin.write(json.dumps(request) + "\n") + process.stdin.flush() + + response = process.stdout.readline() + if response: + data = json.loads(response) + if "result" in data: + return json.dumps(data["result"], indent=2) + elif "error" in data: + return f"MCP Error: {data['error']}" + + return "No response from MCP server" + except Exception as e: + return f"Error calling MCP tool: {str(e)}" diff --git a/build/lib/ollama_cli/integrations/notify.py b/build/lib/ollama_cli/integrations/notify.py new file mode 100644 index 0000000..c7a2da5 --- /dev/null +++ b/build/lib/ollama_cli/integrations/notify.py @@ -0,0 +1,64 @@ +import requests +import json +import threading +from typing import Callable, List, Dict + +def send_notification(config: Dict, title: str, message: str, priority: str = "default"): + """Send notification via ntfy.sh""" + ntfy_config = config.get("ntfy", {}) + + if not ntfy_config.get("enabled") or not ntfy_config.get("topic"): + return + + try: + server = ntfy_config.get("server", "https://ntfy.sh") + topic = ntfy_config["topic"] + url = f"{server}/{topic}" + + requests.post(url, + data=message.encode('utf-8'), + headers={ + "Title": title, + "Priority": priority + }, + timeout=5 + ) + except: + pass + +def ntfy_listener(config: Dict, on_message: Callable[[str], str]): + """Listen for ntfy notifications and process them""" + ntfy_config = config.get("ntfy", {}) + + if not ntfy_config.get("enabled") or not ntfy_config.get("topic"): + return + + server = ntfy_config.get("server", "https://ntfy.sh") + topic = ntfy_config["topic"] + url = f"{server}/{topic}/json" + + try: + response = requests.get(url, stream=True, timeout=None) + for line in response.iter_lines(): + if line: + data = json.loads(line) + if data.get("event") == "message": + title = data.get("title", "") + message = data.get("message", "") + + # Skip messages sent by this script + if title in ["Response", "Ollama CLI", "File Created", "Command Complete"]: + continue + + # Process message + response_text = on_message(message) + + # Send response back + send_notification(config, "Response", response_text[:1000]) + except: + pass + +def start_notify_thread(config: Dict, on_message: Callable[[str], str]): + thread = threading.Thread(target=ntfy_listener, args=(config, on_message), daemon=True) + thread.start() + return thread diff --git a/build/lib/ollama_cli/main.py b/build/lib/ollama_cli/main.py new file mode 100644 index 0000000..91bc83c --- /dev/null +++ b/build/lib/ollama_cli/main.py @@ -0,0 +1,433 @@ +""" +Ollama CLI v3.0 +License: MIT +Author: Gemini CLI +""" + +import sys +import os +import json +import re +import logging +from typing import List, Dict, Optional, Tuple + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(os.path.expanduser("~/.ollama-cli.log")), + ] +) +logger = logging.getLogger("ollama-cli") + +# Add current directory to path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from ollama_cli.core.config import load_config, save_config, update_config +from ollama_cli.core.ollama import OllamaClient +from ollama_cli.core.sessions import save_session, load_session, list_sessions +from ollama_cli.ui.formatter import console, print_markdown, print_error, print_status, StreamingDisplay +from ollama_cli.ui.repl import REPL +from ollama_cli.tools.base import registry +import ollama_cli.tools.filesystem +import ollama_cli.tools.system +import ollama_cli.tools.web +import ollama_cli.tools.git +import ollama_cli.tools.code +import ollama_cli.tools.knowledge +import ollama_cli.tools.memory +import ollama_cli.tools.execution +import ollama_cli.tools.media +from ollama_cli.integrations.mcp import connect_mcp_server, call_mcp_tool, MCP_TOOLS +from ollama_cli.integrations.lsp import start_lsp_server, lsp_goto_definition +from ollama_cli.integrations.notify import start_notify_thread, send_notification + +VERSION = "3.0.0" + +class OllamaCLI: + def __init__(self): + self.config = load_config() + self.client = OllamaClient(self.config.get("ollama_url")) + self.repl = REPL() + self.messages = [] + self.available_models = [] + self.current_model = self.config.get("default_model", "llama3.2") + self.system_prompt = self._build_system_prompt() + self.max_tool_iterations = 5 + + def _build_system_prompt(self) -> str: + base_prompt = """You are a highly capable AI assistant powered by Ollama. +You have access to a variety of tools to help you complete tasks. + +CRITICAL RULES: +1. When you need to use a tool, you MUST use the EXACT format below. +2. Output ONLY the tool call. DO NOT explain what you are doing. +3. DO NOT repeat a tool call if you have already received a result for it. +4. If you have the answer, just give it. Do not use tools unless necessary. + + +name_of_tool +{"param": "value"} + + +You can call multiple tools in one response by repeating the block above.""" + + tool_desc = registry.generate_system_prompt_snippet() + + # Add MCP tools if any + if MCP_TOOLS: + tool_desc += "\nMCP Tools:\n" + for name, info in MCP_TOOLS.items(): + tool_desc += f"- {name}: {info['description']}\n" + + return f"{base_prompt}\n\n{tool_desc}" + + def reset_messages(self): + self.messages = [{"role": "system", "content": self.system_prompt}] + + def handle_command(self, user_input: str) -> bool: + """Handle slash commands.""" + if not user_input.startswith('/'): + return False + + parts = user_input.split() + cmd = parts[0].lower() + + try: + if cmd == '/help': + help_text = """ +[bold cyan]Ollama CLI v3.0 Help[/bold cyan] + +[bold]General Commands:[/bold] + /help - Show this help message + /quit - Exit the CLI + /clear - Clear conversation history + +[bold]Model & Sessions:[/bold] + /models - List available models + /model - Switch current model + /sessions - List saved sessions + /save [name] - Save current session + /load - Load a saved session + +[bold]Core Agent Tools:[/bold] + [blue]Filesystem:[/blue] read_file, write_file, list_directory, grep_search, get_tree + [blue]Code & Python:[/blue] run_python, replace_text, code_analyze_file, run_linter + [blue]Web:[/blue] web_search, read_url + [blue]Media:[/blue] generate_image, speak_text + [blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory + +[bold]Integrations:[/bold] + /mcp connect [args] - Connect MCP server + /lsp start [path] - Start LSP server + /notify setup - Setup ntfy.sh remote control + +[bold]Configuration:[/bold] + /config ollama - Set Ollama server URL (default: http://localhost:11434) + /config comfy - Set ComfyUI server URL (default: http://127.0.0.1:8188) + /config piper_path - Set path to Piper binary + /config piper_model - Set path to Piper voice model +""" + console.print(help_text) + elif cmd == '/config': + if len(parts) == 1: + cfg = load_config() + config_display = f""" +[bold cyan]Current Configuration:[/bold cyan] + [blue]Ollama URL:[/blue] {cfg.get('ollama_url')} + [blue]ComfyUI URL:[/blue] {cfg.get('comfy_url')} + [blue]Image Model:[/blue] {cfg.get('image_model')} + [blue]Piper Path:[/blue] {cfg.get('piper', {}).get('path')} + [blue]Piper Model:[/blue] {cfg.get('piper', {}).get('model')} + [blue]Default Model:[/blue] {cfg.get('default_model')} +""" + console.print(config_display) + elif len(parts) > 2: + subcmd = parts[1].lower() + val = parts[2] + if subcmd == 'ollama': + update_config("ollama_url", val) + print_status(f"Ollama URL updated to: {val}") + self.client = OllamaClient(val) + elif subcmd == 'comfy': + update_config("comfy_url", val) + print_status(f"ComfyUI URL updated to: {val}") + elif subcmd == 'piper_path': + cfg = load_config() + cfg["piper"]["path"] = val + save_config(cfg) + print_status(f"Piper binary path updated to: {val}") + elif subcmd == 'piper_model': + cfg = load_config() + cfg["piper"]["model"] = val + save_config(cfg) + print_status(f"Piper model path updated to: {val}") + else: + print_error("Usage: /config ") + else: + print_error("Usage: /config ") + elif cmd == '/quit': + print_status("Goodbye!") + sys.exit(0) + elif cmd == '/clear': + self.reset_messages() + print_status("History cleared.") + elif cmd == '/models': + self.available_models = self.client.get_available_models() + print_status(f"Available models: {', '.join(self.available_models)}") + elif cmd == '/model': + if len(parts) > 1: + self.current_model = parts[1] + update_config("default_model", self.current_model) + print_status(f"Switched to model: {self.current_model}") + else: + print_status(f"Current model: {self.current_model}") + elif cmd == '/sessions': + sessions = list_sessions() + print_status(f"Recent sessions: {', '.join(sessions[:10])}") + elif cmd == '/save': + name = parts[1] if len(parts) > 1 else None + path = save_session(self.messages, name) + print_status(f"Session saved to {path}") + elif cmd == '/load': + if len(parts) > 1: + messages = load_session(parts[1]) + if messages: + self.messages = messages + print_status(f"Session '{parts[1]}' loaded.") + else: + print_error(f"Session '{parts[1]}' not found.") + else: + print_error("Usage: /load ") + elif cmd == '/mcp': + if len(parts) > 3 and parts[1] == 'connect': + name = parts[2] + command = parts[3] + args = parts[4:] if len(parts) > 4 else None + result = connect_mcp_server(name, command, args) + print_status(result) + # Refresh system prompt with new tools + self.system_prompt = self._build_system_prompt() + self.reset_messages() + else: + print_error("Usage: /mcp connect [args...]") + elif cmd == '/lsp': + if len(parts) > 2 and parts[1] == 'start': + lang = parts[2] + path = parts[3] if len(parts) > 3 else "." + result = start_lsp_server(lang, path) + print_status(result) + else: + print_error("Usage: /lsp start [path]") + elif cmd == '/notify': + if len(parts) > 2 and parts[1] == 'setup': + topic = parts[2] + self.config["ntfy"]["enabled"] = True + self.config["ntfy"]["topic"] = topic + save_config(self.config) + start_notify_thread(self.config, self.process_remote_message) + print_status(f"Notifications enabled for topic: {topic}") + else: + print_error("Usage: /notify setup ") + else: + print_error(f"Unknown command: {cmd}") + except Exception as e: + print_error(f"Command error: {e}") + + return True + + def process_remote_message(self, message: str) -> str: + """Handle messages coming from ntfy.sh""" + temp_messages = self.messages + [{"role": "user", "content": message}] + try: + response = "" + for chunk in self.client.chat(temp_messages, self.current_model, stream=False): + response += chunk + return response + except Exception as e: + return f"Error: {e}" + + def run(self): + console.print(f"[bold cyan]Ollama CLI v{VERSION}[/bold cyan]") + + # Initial model detection + try: + self.available_models = self.client.get_available_models() + if not self.available_models: + print_error("No models found. Make sure Ollama is running.") + else: + if self.current_model not in self.available_models: + self.current_model = self.available_models[0] + print_status(f"Using model: [bold green]{self.current_model}[/bold green]") + except Exception as e: + print_error(f"Could not connect to Ollama: {e}") + + self.reset_messages() + + # Start notification listener if enabled + if self.config.get("ntfy", {}).get("enabled"): + start_notify_thread(self.config, self.process_remote_message) + print_status(f"Notification listener active on topic: {self.config['ntfy']['topic']}") + + while True: + user_input = self.repl.get_input() + + if not user_input: + continue + + if self.handle_command(user_input): + continue + + self.messages.append({"role": "user", "content": user_input}) + self.process_chat_cycle() + + def process_chat_cycle(self): + """The main loop for chat and tool execution.""" + executed_tool_calls = set() + for iteration in range(self.max_tool_iterations): + full_response = "" + try: + print_status(f"Thinking ([dim]{self.current_model}[/dim])...") + with StreamingDisplay() as display: + for chunk in self.client.chat(self.messages, self.current_model): + display.update(chunk) + full_response += chunk + + self.messages.append({"role": "assistant", "content": full_response}) + + # Check for tool calls + tool_calls = self.parse_tool_calls(full_response) + if not tool_calls: + break + + # Execute tool calls + results = [] + for name, params in tool_calls: + # Prevent identical tool call loops across the entire cycle + call_key = f"{name}:{json.dumps(params, sort_keys=True)}" + if call_key in executed_tool_calls: + print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]") + continue + executed_tool_calls.add(call_key) + + result = self.execute_tool(name, params) + # Show result to user + print_status(f"Result: [dim]{result}[/dim]") + results.append(f"Tool Result ({name}):\n{result}") + + if not results: + # If we had tool calls but they were all duplicates, we must stop + break + + # Add all results to conversation + self.messages.append({"role": "user", "content": "\n\n".join(results) + "\n\nTool execution complete. If the task is finished, provide a final response to the user. Do not repeat the tool call."}) + + # If we made it here, loop again to let the model process results + + except Exception as e: + print_error(str(e)) + logger.exception("Chat cycle error") + break + + def parse_tool_calls(self, text: str) -> List[tuple]: + calls = [] + + # 1. Try to find blocks (handles unclosed tags) + tc_blocks = re.findall(r'(.*?)(?:|$)', text, re.DOTALL) + if tc_blocks: + for block in tc_blocks: + if not block.strip(): continue + # Try to find tool_name tag (handles unclosed) + name_match = re.search(r'(.*?)(?:|$)', block, re.DOTALL) + name = name_match.group(1).strip() if name_match else None + + # Try to find parameters tag (handles unclosed) + params_match = re.search(r'(.*?)(?:|$)', block, re.DOTALL) + if name and params_match: + try: + params_str = params_match.group(1).strip() + # Auto-fix unclosed JSON if needed + if params_str.startswith('{') and not params_str.endswith('}'): + params_str += '}' + calls.append((name, json.loads(params_str))) + except: + # Last ditch: search for any JSON-like object in the parameters string + json_match = re.search(r'(\{.*\})', params_str, re.DOTALL) + if json_match: + try: calls.append((name, json.loads(json_match.group(1)))) + except: pass + + # 2. If no blocks, try searching for tool-specific tags + if not calls: + for tool_name in registry.tools.keys(): + # Handle or + attr_pattern = fr'<{tool_name}\s+(.*?)[\s/]*>' + attr_matches = re.findall(attr_pattern, text, re.DOTALL) + for attr_str in attr_matches: + attrs = {} + kv_pairs = re.findall(r'(\w+)\s*=\s*["\'](.*?)["\']', attr_str) + for k, v in kv_pairs: + attrs[k] = v + if attrs: + calls.append((tool_name, attrs)) + + # Handle {json} + block_pattern = fr'<{tool_name}>(.*?)(?:|$)' + block_matches = re.findall(block_pattern, text, re.DOTALL) + for b in block_matches: + json_match = re.search(r'(\{.*\})', b, re.DOTALL) + if json_match: + try: calls.append((tool_name, json.loads(json_match.group(1)))) + except: pass + + # 3. Final fallback: look for tool_name followed by JSON { ... } + if not calls: + for tool_name in registry.tools.keys(): + json_pattern = fr'{tool_name}\s*({{.*?}})' + json_matches = re.findall(json_pattern, text, re.DOTALL) + for json_str in json_matches: + try: + calls.append((tool_name, json.loads(json_str.strip()))) + except: + pass + + return calls + + def execute_tool(self, name: str, params: Dict) -> str: + """Execute a tool (standard or MCP).""" + # Try MCP tools first + if name in MCP_TOOLS: + mcp_tool = MCP_TOOLS[name] + print_status(f"Executing MCP tool [bold blue]{mcp_tool['name']}[/bold blue]...") + return call_mcp_tool(mcp_tool["server"], mcp_tool["name"], params) + + # Try standard tools + tool = registry.get_tool(name) + if tool: + print_status(f"Executing [bold blue]{name}[/bold blue]...") + try: + result = tool.execute(**params) + # Notification for successful file/system ops + if name == "write_file" and "Successfully" in str(result): + send_notification(self.config, "File Created", f"Path: {params.get('path')}") + return result + except Exception as e: + return f"Error executing tool: {e}" + + return f"Unknown tool: {name}" + +def main(): + try: + cli = OllamaCLI() + cli.run() + except KeyboardInterrupt: + print_status("Interrupted by user. Exiting.") + sys.exit(0) + except Exception as e: + print_error(f"Fatal error: {e}") + logger.exception("Fatal error") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/build/lib/ollama_cli/tools/__init__.py b/build/lib/ollama_cli/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/ollama_cli/tools/base.py b/build/lib/ollama_cli/tools/base.py new file mode 100644 index 0000000..207594d --- /dev/null +++ b/build/lib/ollama_cli/tools/base.py @@ -0,0 +1,55 @@ +from typing import Dict, Any, Callable, List, Optional +import json +import inspect + +class BaseTool: + def __init__(self, name: str, description: str, parameters: Dict[str, Any]): + self.name = name + self.description = description + self.parameters = parameters + + def execute(self, **kwargs) -> Any: + raise NotImplementedError + +class ToolRegistry: + def __init__(self): + self.tools: Dict[str, BaseTool] = {} + + def register(self, tool: BaseTool): + self.tools[tool.name] = tool + + def get_tool(self, name: str) -> Optional[BaseTool]: + return self.tools.get(name) + + def list_tools(self) -> List[Dict[str, Any]]: + return [ + { + "name": t.name, + "description": t.description, + "parameters": t.parameters + } + for t in self.tools.values() + ] + + def generate_system_prompt_snippet(self) -> str: + snippet = "Available tools:\n" + for t in self.tools.values(): + snippet += f"- {t.name}: {t.description}\n" + snippet += f" Parameters: {json.dumps(t.parameters)}\n" + return snippet + +# Singleton registry +registry = ToolRegistry() + +def tool(name: str, description: str, parameters: Dict[str, Any]): + """Decorator to register a function as a tool""" + def decorator(func: Callable): + class DecoratedTool(BaseTool): + def __init__(self): + super().__init__(name, description, parameters) + def execute(self, **kwargs): + return func(**kwargs) + + registry.register(DecoratedTool()) + return func + return decorator diff --git a/build/lib/ollama_cli/tools/code.py b/build/lib/ollama_cli/tools/code.py new file mode 100644 index 0000000..e8a8798 --- /dev/null +++ b/build/lib/ollama_cli/tools/code.py @@ -0,0 +1,96 @@ +import os +import subprocess +from .base import tool + +@tool( + name="code_analyze_file", + description="Analyze code structure and suggest improvements", + parameters={ + "file_path": {"type": "string", "description": "Path to the file to analyze"} + } +) +def code_analyze_file(file_path: str) -> str: + try: + with open(os.path.expanduser(file_path), 'r') as f: + content = f.read() + + lines = content.split('\n') + functions = [l for l in lines if 'def ' in l] + classes = [l for l in lines if 'class ' in l] + + issues = [] + if len(lines) > 500: + issues.append(f"Large file ({len(lines)} lines) - consider splitting") + if len(functions) > 30: + issues.append(f"Many functions ({len(functions)}) - consider refactoring") + + return f"""File: {file_path} +Lines: {len(lines)} | Functions: {len(functions)} | Classes: {len(classes)} + +Issues: +{chr(10).join(f'- {i}' for i in issues) if issues else '- None detected'}""" + except Exception as e: + return f"Error: {str(e)}" + +@tool( + name="generate_readme", + description="Generate a README.md for the current project", + parameters={ + "path": {"type": "string", "description": "Project root path", "default": "."} + } +) +def generate_readme(path: str = ".") -> str: + try: + project_name = os.path.basename(os.path.abspath(path)) + readme_content = f"# {project_name}\n\nGenerated by Ollama CLI." + readme_path = os.path.join(path, "README.md") + with open(readme_path, 'w') as f: + f.write(readme_content) + return f"Generated README.md at {readme_path}" + except Exception as e: + return f"Error: {str(e)}" + +@tool( + name="replace_text", + description="Surgically replace text in a file", + parameters={ + "path": {"type": "string", "description": "Path to the file"}, + "old_text": {"type": "string", "description": "The exact text to find"}, + "new_text": {"type": "string", "description": "The text to replace it with"} + } +) +def replace_text(path: str, old_text: str, new_text: str) -> str: + try: + expanded_path = os.path.expanduser(path) + with open(expanded_path, 'r') as f: + content = f.read() + + if old_text not in content: + return f"Error: The text to replace was not found in {path}" + + new_content = content.replace(old_text, new_text, 1) # Replace first occurrence + with open(expanded_path, 'w') as f: + f.write(new_content) + return f"Successfully updated {path}" + except Exception as e: + return f"Error: {str(e)}" + +@tool( + name="run_linter", + description="Run a linter on a file", + parameters={ + "file_path": {"type": "string", "description": "Path to the file"}, + "linter": {"type": "string", "description": "Linter to use (pylint|eslint|auto)", "default": "auto"} + } +) +def run_linter(file_path: str, linter: str = "auto") -> str: + try: + file_path = os.path.expanduser(file_path) + if linter == "auto": + ext = os.path.splitext(file_path)[1] + linter = "pylint" if ext == ".py" else "eslint" + + result = subprocess.run(f"{linter} {file_path}", shell=True, capture_output=True, text=True) + return result.stdout + result.stderr + except Exception as e: + return f"Error: {str(e)}" diff --git a/build/lib/ollama_cli/tools/execution.py b/build/lib/ollama_cli/tools/execution.py new file mode 100644 index 0000000..d7f65e6 --- /dev/null +++ b/build/lib/ollama_cli/tools/execution.py @@ -0,0 +1,36 @@ +import subprocess +import os +import sys +from .base import tool + +@tool( + name="run_python", + description="Execute a Python script or snippet", + parameters={ + "code": {"type": "string", "description": "Python code to execute"}, + "args": {"type": "string", "description": "Optional arguments", "default": ""} + } +) +def run_python(code: str, args: str = "") -> str: + try: + # Create a temp file + temp_file = ".ollama-cli-exec.py" + with open(temp_file, "w") as f: + f.write(code) + + cmd = f"{sys.executable} {temp_file} {args}" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30) + + output = result.stdout + result.stderr + + # Cleanup + if os.path.exists(temp_file): + os.remove(temp_file) + + if result.returncode == 0: + return f"Success:\n{output}" if output else "Success (no output)" + else: + return f"Error (Exit {result.returncode}):\n{output}" + + except Exception as e: + return f"Execution Error: {str(e)}" \ No newline at end of file diff --git a/build/lib/ollama_cli/tools/filesystem.py b/build/lib/ollama_cli/tools/filesystem.py new file mode 100644 index 0000000..ebb02a9 --- /dev/null +++ b/build/lib/ollama_cli/tools/filesystem.py @@ -0,0 +1,83 @@ +import os +import subprocess +from .base import tool + +@tool( + name="read_file", + description="Read the contents of a file", + parameters={ + "path": {"type": "string", "description": "Path to the file"} + } +) +def read_file(path: str) -> str: + try: + with open(os.path.expanduser(path), 'r') as f: + return f.read() + except Exception as e: + return f"Error reading file: {str(e)}" + +@tool( + name="write_file", + description="Write content to a file", + parameters={ + "path": {"type": "string", "description": "Path to the file"}, + "content": {"type": "string", "description": "Content to write"} + } +) +def write_file(path: str, content: str) -> str: + try: + expanded_path = os.path.expanduser(path) + os.makedirs(os.path.dirname(expanded_path), exist_ok=True) + with open(expanded_path, 'w') as f: + f.write(content) + return f"Successfully written to {path}" + except Exception as e: + return f"Error writing file: {str(e)}" + +@tool( + name="list_directory", + description="List contents of a directory", + parameters={ + "path": {"type": "string", "description": "Path to the directory", "default": "."} + } +) +def list_directory(path: str = ".") -> str: + try: + expanded = os.path.expanduser(path) + files = os.listdir(expanded) + return "\n".join(files) if files else "Directory is empty" + except Exception as e: + return f"Error listing directory: {str(e)}" + +@tool( + name="grep_search", + description="Search for a regular expression pattern in file contents", + parameters={ + "pattern": {"type": "string", "description": "The regex pattern to search for"}, + "path": {"type": "string", "description": "Directory to search in", "default": "."}, + "include": {"type": "string", "description": "Glob pattern for files to include", "default": "*"} + } +) +def grep_search(pattern: str, path: str = ".", include: str = "*") -> str: + try: + cmd = f'grep -rE --include="{include}" "{pattern}" {path}' + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20) + return result.stdout if result.stdout else "No matches found." + except Exception as e: + return f"Error: {str(e)}" + +@tool( + name="get_tree", + description="Show the directory structure as a tree", + parameters={ + "path": {"type": "string", "description": "Root path", "default": "."}, + "depth": {"type": "integer", "description": "Max depth", "default": 2} + } +) +def get_tree(path: str = ".", depth: int = 2) -> str: + try: + cmd = f"find {path} -maxdepth {depth} -not -path '*/.*'" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return result.stdout + except Exception as e: + return f"Error: {str(e)}" diff --git a/build/lib/ollama_cli/tools/git.py b/build/lib/ollama_cli/tools/git.py new file mode 100644 index 0000000..6b2a081 --- /dev/null +++ b/build/lib/ollama_cli/tools/git.py @@ -0,0 +1,52 @@ +import subprocess +import requests +from typing import Optional +from .base import tool +from ..core.config import load_config + +@tool( + name="git_operation", + description="Perform git operations (status, diff, commit, push, pull, list)", + parameters={ + "operation": {"type": "string", "description": "Operation to perform (status|diff|commit|push|pull|list)"}, + "message": {"type": "string", "description": "Commit message", "default": "Auto commit"} + } +) +def git_operation(operation: str, message: str = "Auto commit") -> str: + config = load_config() + git_config = config.get("git", {}) + gitea_config = config.get("gitea", {}) + + try: + if operation == "status": + result = subprocess.run("git status", shell=True, capture_output=True, text=True) + elif operation == "diff": + result = subprocess.run("git diff", shell=True, capture_output=True, text=True) + elif operation == "commit": + result = subprocess.run(f'git add -A && git commit -m "{message}"', + shell=True, capture_output=True, text=True) + elif operation == "push": + result = subprocess.run("git push", shell=True, capture_output=True, text=True) + elif operation == "pull": + result = subprocess.run("git pull", shell=True, capture_output=True, text=True) + elif operation == "list": + if gitea_config.get("url") and gitea_config.get("token"): + gitea_url = gitea_config["url"].rstrip("/") + token = gitea_config["token"] + response = requests.get( + f"{gitea_url}/api/v1/user/repos", + headers={"Authorization": f"token {token}"}, + timeout=10 + ) + if response.status_code == 200: + repos = response.json() + return "\n".join([f"- {r['full_name']} ({r['clone_url']})" for r in repos]) + return f"Gitea API error: {response.status_code}" + result = subprocess.run("find . -maxdepth 2 -name '.git' -type d | sed 's|/.git||'", + shell=True, capture_output=True, text=True) + else: + return f"Unknown git operation: {operation}" + + return result.stdout + result.stderr + except Exception as e: + return f"Git error: {str(e)}" diff --git a/build/lib/ollama_cli/tools/knowledge.py b/build/lib/ollama_cli/tools/knowledge.py new file mode 100644 index 0000000..9dc28af --- /dev/null +++ b/build/lib/ollama_cli/tools/knowledge.py @@ -0,0 +1,41 @@ +import json +import os +from .base import tool + +KB_FILE = os.path.expanduser("~/.ollama-cli-kb.json") + +def load_kb(): + if os.path.exists(KB_FILE): + with open(KB_FILE, 'r') as f: + return json.load(f) + return {} + +def save_kb(kb): + with open(KB_FILE, 'w') as f: + json.dump(kb, f, indent=2) + +@tool( + name="kb_add", + description="Add content to the knowledge base", + parameters={ + "key": {"type": "string", "description": "Search key"}, + "content": {"type": "string", "description": "Information content"} + } +) +def kb_add(key: str, content: str) -> str: + kb = load_kb() + kb[key] = content + save_kb(kb) + return f"Added '{key}' to knowledge base" + +@tool( + name="kb_search", + description="Search the knowledge base", + parameters={ + "query": {"type": "string", "description": "Search query"} + } +) +def kb_search(query: str) -> str: + kb = load_kb() + results = [f"{k}: {v[:100]}..." for k, v in kb.items() if query.lower() in k.lower() or query.lower() in v.lower()] + return "\n".join(results) if results else "No matches found" diff --git a/build/lib/ollama_cli/tools/media.py b/build/lib/ollama_cli/tools/media.py new file mode 100644 index 0000000..2611f11 --- /dev/null +++ b/build/lib/ollama_cli/tools/media.py @@ -0,0 +1,183 @@ +import subprocess +import os +import sys +import requests +import json +import random +from .base import tool +from ..core.config import load_config + +@tool( + name="generate_image", + description="Generate an image using the local Stable Diffusion workflow (ComfyUI)", + parameters={ + "prompt": {"type": "string", "description": "The image description"} + } +) +def generate_image(prompt: str) -> str: + """Invokes the ComfyUI API directly with a predefined workflow""" + config = load_config() + comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/") + image_model = config.get("image_model", "dreamshaper_8_pruned.safetensors") + + # Determine dimensions (SDXL prefers 1024x1024) + width, height = (512, 512) + if "xl" in image_model.lower(): + width, height = (1024, 1024) + + try: + # Check if ComfyUI is running + try: + requests.get(comfy_url, timeout=2) + except: + return f"Error: ComfyUI server ({comfy_url}) appears to be down. Please start it first or configure the URL with '/config comfy '." + + # Define the workflow directly + workflow = { + "1": { + "inputs": {"ckpt_name": image_model}, + "class_type": "CheckpointLoaderSimple" + }, + "2": { + "inputs": { + "text": f"masterpiece, best quality, highly detailed, {prompt}", + "clip": ["1", 1] + }, + "class_type": "CLIPTextEncode" + }, + "3": { + "inputs": { + "text": "blurry, low quality, distorted, watermark, nsfw", + "clip": ["1", 1] + }, + "class_type": "CLIPTextEncode" + }, + "4": { + "inputs": {"width": width, "height": height, "batch_size": 1}, + "class_type": "EmptyLatentImage" + }, + "5": { + "inputs": { + "seed": random.randint(1, 1000000000), "steps": 20, "cfg": 7.0, + "sampler_name": "dpmpp_2m", "scheduler": "karras", + "denoise": 1.0, "model": ["1", 0], + "positive": ["2", 0], "negative": ["3", 0], + "latent_image": ["4", 0] + }, + "class_type": "KSampler" + }, + "6": { + "inputs": {"samples": ["5", 0], "vae": ["1", 2]}, + "class_type": "VAEDecode" + }, + "7": { + "inputs": { + "filename_prefix": "ollama_cli_", + "images": ["6", 0] + }, + "class_type": "SaveImage" + } + } + + payload = {"prompt": workflow} + response = requests.post(f"{comfy_url}/prompt", json=payload, timeout=10) + + if response.status_code == 200: + data = response.json() + prompt_id = data.get('prompt_id', 'unknown') + return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nCheck your ComfyUI output folder for 'ollama_cli_' prefixed files." + else: + return f"Error form ComfyUI: {response.status_code} - {response.text}" + + except Exception as e: + return f"Error generating image: {str(e)}" + +@tool( + name="speak_text", + description="Convert text to speech using Piper TTS (high quality)", + parameters={ + "text": {"type": "string", "description": "Text to speak"}, + "output_file": {"type": "string", "description": "Output filename", "default": "output.wav"} + } +) +def speak_text(text: str, output_file: str = "output.wav") -> str: + """Uses the best available TTS engine (Kokoro > Piper > System)""" + config = load_config() + + # 1. Try Kokoro (Ultra-Realistic) + kokoro_python = os.path.expanduser(config.get("kokoro_path", "~/.ollama-cli/kokoro/venv/bin/python")) + if os.path.exists(kokoro_python): + try: + # Inline script to run in Kokoro venv + kokoro_script = f""" +import sys +import soundfile as sf +import os +try: + from kokoro import KPipeline + import torch +except ImportError: + print("Dependencies missing") + sys.exit(1) + +text = sys.argv[1] +output_file = sys.argv[2] + +# Initialize pipeline +pipeline = KPipeline(lang_code='a') +generator = pipeline(text, voice='af_bella', speed=1) + +all_audio = [] +for gs, ps, audio in generator: + if audio is not None: + all_audio.append(audio) + +if all_audio: + import numpy as np + full_audio = np.concatenate(all_audio) + sf.write(output_file, full_audio, 24000) + print("Success") +else: + print("No audio generated") + sys.exit(1) +""" + # Run in venv + subprocess.run([kokoro_python, "-c", kokoro_script, text, output_file], + check=True, capture_output=True) + + if sys.platform == "darwin": + subprocess.run(["afplay", output_file]) + return f"Audio saved to {output_file} and played (using Ultra-Realistic Kokoro TTS)" + except Exception as e: + # Continue to fallback + pass + + # 2. Try Piper (High-Quality) + piper_config = config.get("piper", {}) + piper_bin = os.path.expanduser(piper_config.get("path", "~/.ollama-cli/piper/piper")) + model_path = os.path.expanduser(piper_config.get("model", "~/.ollama-cli/piper/voice.onnx")) + + if os.path.exists(piper_bin) and os.path.exists(model_path): + try: + # Piper reads from stdin and writes to file + cmd = f'echo "{text}" | {piper_bin} --model {model_path} --output_file {output_file}' + subprocess.run(cmd, shell=True, check=True, capture_output=True) + + # Auto-play on macOS + if sys.platform == "darwin": + subprocess.run(["afplay", output_file]) + + return f"Audio saved to {output_file} and played (using high-quality Piper TTS)" + except Exception as e: + pass + + # 3. Fallback to system 'say' command on macOS + if sys.platform == "darwin": + try: + subprocess.run(["say", "-o", output_file, text], check=True) + subprocess.run(["afplay", output_file]) + return f"Audio saved to {output_file} and played (using system 'say' fallback)" + except Exception as e: + return f"Error using 'say': {e}" + + return "No compatible TTS engine found. Please run the installer." diff --git a/build/lib/ollama_cli/tools/memory.py b/build/lib/ollama_cli/tools/memory.py new file mode 100644 index 0000000..5e4a2c2 --- /dev/null +++ b/build/lib/ollama_cli/tools/memory.py @@ -0,0 +1,59 @@ +import json +import os +from .base import tool + +PROJECT_MEMORY_FILE = ".ollama-cli/memory.json" + +def load_project_memory(): + if os.path.exists(PROJECT_MEMORY_FILE): + try: + with open(PROJECT_MEMORY_FILE, 'r') as f: + return json.load(f) + except: + pass + return {} + +def save_project_memory(memory): + os.makedirs(os.path.dirname(PROJECT_MEMORY_FILE), exist_ok=True) + with open(PROJECT_MEMORY_FILE, 'w') as f: + json.dump(memory, f, indent=2) + +@tool( + name="remember_fact", + description="Store a key piece of information about the project", + parameters={ + "fact": {"type": "string", "description": "The fact to remember (e.g., 'API key is in .env', 'Main entry point is src/index.ts')"} + } +) +def remember_fact(fact: str) -> str: + memory = load_project_memory() + facts = memory.get("facts", []) + if fact not in facts: + facts.append(fact) + memory["facts"] = facts + save_project_memory(memory) + return f"Remembered: {fact}" + return "Fact already known." + +@tool( + name="recall_facts", + description="Recall all stored facts about the project", + parameters={} +) +def recall_facts() -> str: + memory = load_project_memory() + facts = memory.get("facts", []) + if not facts: + return "No project facts stored." + return "Project Facts:\n" + "\n".join(f"- {f}" for f in facts) + +@tool( + name="clear_memory", + description="Clear project memory", + parameters={} +) +def clear_memory() -> str: + if os.path.exists(PROJECT_MEMORY_FILE): + os.remove(PROJECT_MEMORY_FILE) + return "Project memory cleared." + return "Memory was already empty." \ No newline at end of file diff --git a/build/lib/ollama_cli/tools/system.py b/build/lib/ollama_cli/tools/system.py new file mode 100644 index 0000000..329fcc9 --- /dev/null +++ b/build/lib/ollama_cli/tools/system.py @@ -0,0 +1,46 @@ +import subprocess +import os +from .base import tool + +@tool( + name="execute_bash", + description="Execute a bash command and return its output", + parameters={ + "command": {"type": "string", "description": "The command to execute"} + } +) +def execute_bash(command: str) -> str: + # Basic safety check + dangerous = ['rm -rf /', 'dd if=', 'mkfs', ':(){:|:&};:'] + if any(d in command for d in dangerous): + return f"⚠️ DANGEROUS COMMAND BLOCKED: {command}" + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + cwd=os.getcwd(), + timeout=60 + ) + output = result.stdout + result.stderr + return f"Exit code: {result.returncode}\n{output}" if output else f"Exit code: {result.returncode} (no output)" + except subprocess.TimeoutExpired: + return "Error: Command timed out after 60 seconds" + except Exception as e: + return f"Error: {str(e)}" + +@tool( + name="get_env_info", + description="Get information about the current system environment", + parameters={} +) +def get_env_info() -> str: + import sys + import platform + return f"""OS: {platform.system()} {platform.release()} +Python: {sys.version} +Shell: {os.environ.get('SHELL', 'unknown')} +User: {os.environ.get('USER', 'unknown')} +CWD: {os.getcwd()}""" diff --git a/build/lib/ollama_cli/tools/web.py b/build/lib/ollama_cli/tools/web.py new file mode 100644 index 0000000..bef085e --- /dev/null +++ b/build/lib/ollama_cli/tools/web.py @@ -0,0 +1,67 @@ +import requests +import urllib.parse +from .base import tool + +@tool( + name="web_search", + description="Search the web for information", + parameters={ + "query": {"type": "string", "description": "The search query"} + } +) +def web_search(query: str) -> str: + """Enhanced web search with DuckDuckGo""" + try: + encoded = urllib.parse.quote(query) + # Use DuckDuckGo Instant Answer API + api_url = f"https://api.duckduckgo.com/?q={encoded}&format=json" + response = requests.get(api_url, timeout=10) + data = response.json() + + results = [] + if data.get("AbstractText"): + results.append(f"Summary: {data['AbstractText']}") + + for topic in data.get("RelatedTopics", [])[:5]: + if isinstance(topic, dict) and "Text" in topic: + results.append(f"- {topic['Text']}") + + return "\n".join(results) if results else "No direct results found. Try a different query." + except Exception as e: + return f"Search error: {str(e)}" + +@tool( + name="read_url", + description="Read and extract text content from a URL", + parameters={ + "url": {"type": "string", "description": "The URL to read"} + } +) +def read_url(url: str) -> str: + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + + try: + from bs4 import BeautifulSoup + soup = BeautifulSoup(response.text, 'html.parser') + + # Remove scripts and styles + for script in soup(["script", "style"]): + script.decompose() + + text = soup.get_text() + + # Clean up whitespace + lines = (line.strip() for line in text.splitlines()) + chunks = (phrase.strip() for line in lines for phrase in line.split(" ")) + text = '\n'.join(chunk for chunk in chunks if chunk) + + return text[:10000] + ("\n... (truncated)" if len(text) > 10000 else "") + + except ImportError: + # Fallback if bs4 is missing (though we checked) + return response.text[:10000] + + except Exception as e: + return f"Error reading URL: {str(e)}" diff --git a/build/lib/ollama_cli/ui/__init__.py b/build/lib/ollama_cli/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/ollama_cli/ui/formatter.py b/build/lib/ollama_cli/ui/formatter.py new file mode 100644 index 0000000..b9cd1f8 --- /dev/null +++ b/build/lib/ollama_cli/ui/formatter.py @@ -0,0 +1,40 @@ +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.live import Live +from rich.text import Text +from rich.syntax import Syntax +from typing import Optional + +console = Console() + +def print_markdown(text: str): + console.print(Markdown(text)) + +def print_panel(text: str, title: Optional[str] = None, style: str = "blue"): + console.print(Panel(text, title=title, border_style=style)) + +def print_error(text: str): + console.print(f"[bold red]Error:[/bold red] {text}") + +def print_status(text: str): + console.print(f"[bold blue]>>>[/bold blue] {text}") + +class StreamingDisplay: + def __init__(self, title: Optional[str] = None): + self.text = "" + self.live = Live(Text(self.text), refresh_per_second=10) + self.title = title + + def __enter__(self): + self.live.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.live.stop() + + def update(self, chunk: str): + self.text += chunk + # Try to parse markdown if it looks complete-ish, otherwise just show text + # For performance, we might just show raw text during streaming + self.live.update(Markdown(self.text) if "```" in self.text else Text(self.text)) diff --git a/build/lib/ollama_cli/ui/repl.py b/build/lib/ollama_cli/ui/repl.py new file mode 100644 index 0000000..9534678 --- /dev/null +++ b/build/lib/ollama_cli/ui/repl.py @@ -0,0 +1,25 @@ +from prompt_toolkit import PromptSession +from prompt_toolkit.history import FileHistory +from prompt_toolkit.styles import Style +from prompt_toolkit.formatted_text import HTML +import os + +class REPL: + def __init__(self): + history_file = os.path.expanduser("~/.ollama-cli-history") + self.session = PromptSession(history=FileHistory(history_file)) + self.style = Style.from_dict({ + 'prompt': 'ansicyan bold', + 'command': 'ansigreen', + }) + + def get_input(self) -> str: + try: + return self.session.prompt( + HTML('> '), + style=self.style + ).strip() + except KeyboardInterrupt: + return "" + except EOFError: + return "/quit" diff --git a/install.sh b/install.sh index 3328b28..41d10ff 100755 --- a/install.sh +++ b/install.sh @@ -82,18 +82,51 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then # 5. Download Image Generation Model echo "" - echo "[*] Downloading Image Generation Model (DreamShaper 8)..." + echo "-------------------------------------------" + echo "Select Image Generation Model:" + echo "1) DreamShaper 8 (Good quality, fast, ~2GB)" + echo "2) Juggernaut XL (Photo-realistic, high detail, ~6GB)" + read -p "Choose option (1 or 2): " MODEL_CHOICE + echo "" + MODEL_DIR="$INSTALL_DIR/models/checkpoints" mkdir -p "$MODEL_DIR" - MODEL_PATH="$MODEL_DIR/dreamshaper_8_pruned.safetensors" + + if [[ "$MODEL_CHOICE" == "2" ]]; then + MODEL_NAME="juggernaut_xl.safetensors" + MODEL_URL="https://huggingface.co/RunDiffusion/Juggernaut-XL-v9/resolve/main/Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors" + echo "[*] Downloading Juggernaut XL..." + else + MODEL_NAME="dreamshaper_8_pruned.safetensors" + MODEL_URL="https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors" + echo "[*] Downloading DreamShaper 8..." + fi + + MODEL_PATH="$MODEL_DIR/$MODEL_NAME" if [ ! -f "$MODEL_PATH" ]; then - echo " This may take a few minutes (approx. 2GB)..." - curl -L "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors" -o "$MODEL_PATH" + echo " This may take a few minutes..." + curl -L "$MODEL_URL" -o "$MODEL_PATH" echo "✅ Model downloaded." else echo "✅ Model already exists." fi + # Update config with the chosen model name + python3 -c " +import json +import os +config_path = os.path.expanduser('~/.ollama-cli-config.json') +if os.path.exists(config_path): + with open(config_path, 'r') as f: + config = json.load(f) +else: + config = {} +config['image_model'] = '$MODEL_NAME' +config['comfy_output_path'] = os.path.expanduser('$INSTALL_DIR/output') +with open(config_path, 'w') as f: + json.dump(config, f, indent=2) +" + echo "" echo "[!] To use Image Generation, you must start ComfyUI:" echo " cd $INSTALL_DIR" diff --git a/output.wav b/output.wav new file mode 100644 index 0000000..cd1295f Binary files /dev/null and b/output.wav differ diff --git a/src/ollama_cli.egg-info/SOURCES.txt b/src/ollama_cli.egg-info/SOURCES.txt index c19923b..a434739 100644 --- a/src/ollama_cli.egg-info/SOURCES.txt +++ b/src/ollama_cli.egg-info/SOURCES.txt @@ -20,9 +20,12 @@ src/ollama_cli/integrations/notify.py src/ollama_cli/tools/__init__.py src/ollama_cli/tools/base.py src/ollama_cli/tools/code.py +src/ollama_cli/tools/execution.py src/ollama_cli/tools/filesystem.py src/ollama_cli/tools/git.py src/ollama_cli/tools/knowledge.py +src/ollama_cli/tools/media.py +src/ollama_cli/tools/memory.py src/ollama_cli/tools/system.py src/ollama_cli/tools/web.py src/ollama_cli/ui/__init__.py diff --git a/src/ollama_cli/__pycache__/main.cpython-312.pyc b/src/ollama_cli/__pycache__/main.cpython-312.pyc index 5aa5b45..f22a8d0 100644 Binary files a/src/ollama_cli/__pycache__/main.cpython-312.pyc and b/src/ollama_cli/__pycache__/main.cpython-312.pyc differ diff --git a/src/ollama_cli/core/__pycache__/config.cpython-312.pyc b/src/ollama_cli/core/__pycache__/config.cpython-312.pyc index f326221..bbb3dda 100644 Binary files a/src/ollama_cli/core/__pycache__/config.cpython-312.pyc and b/src/ollama_cli/core/__pycache__/config.cpython-312.pyc differ diff --git a/src/ollama_cli/core/config.py b/src/ollama_cli/core/config.py index 8be3ff5..00ee674 100644 --- a/src/ollama_cli/core/config.py +++ b/src/ollama_cli/core/config.py @@ -18,6 +18,12 @@ DEFAULT_CONFIG = { "mcp_servers": {}, "ollama_url": "http://localhost:11434", "comfy_url": "http://localhost:9000", + "comfy_output_path": "~/ComfyUI/output", + "image_model": "dreamshaper_8_pruned.safetensors", + "piper": { + "path": "~/.ollama-cli/piper/piper", + "model": "~/.ollama-cli/piper/voice.onnx" + }, "ntfy": { "enabled": False, "topic": "", diff --git a/src/ollama_cli/main.py b/src/ollama_cli/main.py index c5914e7..64142cb 100644 --- a/src/ollama_cli/main.py +++ b/src/ollama_cli/main.py @@ -60,13 +60,18 @@ class OllamaCLI: base_prompt = """You are a highly capable AI assistant powered by Ollama. You have access to a variety of tools to help you complete tasks. -When you need to use a tool, output a tool call in the following format: +CRITICAL RULES: +1. When you need to use a tool, you MUST use the EXACT format below. +2. Output ONLY the tool call. DO NOT explain what you are doing. +3. DO NOT repeat a tool call if you have already received a result for it. +4. If you have the answer, just give it. Do not use tools unless necessary. + -tool_name +name_of_tool {"param": "value"} -You can call multiple tools in one response. Be precise and helpful.""" +You can call multiple tools in one response by repeating the block above.""" tool_desc = registry.generate_system_prompt_snippet() @@ -110,7 +115,7 @@ You can call multiple tools in one response. Be precise and helpful.""" [blue]Filesystem:[/blue] read_file, write_file, list_directory, grep_search, get_tree [blue]Code & Python:[/blue] run_python, replace_text, code_analyze_file, run_linter [blue]Web:[/blue] web_search, read_url - [blue]Media:[/blue] generate_image, speak_text + [blue]Media:[/blue] generate_image, speak_text, get_comfy_status [blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory [bold]Integrations:[/bold] @@ -119,12 +124,28 @@ You can call multiple tools in one response. Be precise and helpful.""" /notify setup - Setup ntfy.sh remote control [bold]Configuration:[/bold] - /config ollama - Set Ollama server URL (default: http://localhost:11434) - /config comfy - Set ComfyUI server URL (default: http://127.0.0.1:8188) + /config ollama - Set Ollama server URL + /config comfy - Set ComfyUI server URL + /config comfy_output - Set ComfyUI output folder path + /config piper_path - Set path to Piper binary + /config piper_model - Set path to Piper voice model """ console.print(help_text) elif cmd == '/config': - if len(parts) > 2: + if len(parts) == 1: + cfg = load_config() + config_display = f""" +[bold cyan]Current Configuration:[/bold cyan] + [blue]Ollama URL:[/blue] {cfg.get('ollama_url')} + [blue]ComfyUI URL:[/blue] {cfg.get('comfy_url')} + [blue]Comfy Output:[/blue] {cfg.get('comfy_output_path')} + [blue]Image Model:[/blue] {cfg.get('image_model')} + [blue]Piper Path:[/blue] {cfg.get('piper', {}).get('path')} + [blue]Piper Model:[/blue] {cfg.get('piper', {}).get('model')} + [blue]Default Model:[/blue] {cfg.get('default_model')} +""" + console.print(config_display) + elif len(parts) > 2: subcmd = parts[1].lower() val = parts[2] if subcmd == 'ollama': @@ -134,10 +155,23 @@ You can call multiple tools in one response. Be precise and helpful.""" elif subcmd == 'comfy': update_config("comfy_url", val) print_status(f"ComfyUI URL updated to: {val}") + elif subcmd == 'comfy_output': + update_config("comfy_output_path", val) + print_status(f"ComfyUI output path updated to: {val}") + elif subcmd == 'piper_path': + cfg = load_config() + cfg["piper"]["path"] = val + save_config(cfg) + print_status(f"Piper binary path updated to: {val}") + elif subcmd == 'piper_model': + cfg = load_config() + cfg["piper"]["model"] = val + save_config(cfg) + print_status(f"Piper model path updated to: {val}") else: - print_error("Usage: /config ") + print_error("Usage: /config ") else: - print_error("Usage: /config ") + print_error("Usage: /config ") elif cmd == '/quit': print_status("Goodbye!") sys.exit(0) @@ -255,6 +289,7 @@ You can call multiple tools in one response. Be precise and helpful.""" def process_chat_cycle(self): """The main loop for chat and tool execution.""" + executed_tool_calls = set() for iteration in range(self.max_tool_iterations): full_response = "" try: @@ -274,11 +309,24 @@ You can call multiple tools in one response. Be precise and helpful.""" # Execute tool calls results = [] for name, params in tool_calls: + # Prevent identical tool call loops across the entire cycle + call_key = f"{name}:{json.dumps(params, sort_keys=True)}" + if call_key in executed_tool_calls: + print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]") + continue + executed_tool_calls.add(call_key) + result = self.execute_tool(name, params) + # Show result to user + print_status(f"Result: [dim]{result}[/dim]") results.append(f"Tool Result ({name}):\n{result}") + if not results: + # If we had tool calls but they were all duplicates, we must stop + break + # Add all results to conversation - self.messages.append({"role": "user", "content": "\n\n".join(results)}) + self.messages.append({"role": "user", "content": "\n\n".join(results) + "\n\nTool execution complete. If the task is finished, provide a final response to the user. Do not repeat the tool call."}) # If we made it here, loop again to let the model process results @@ -288,17 +336,67 @@ You can call multiple tools in one response. Be precise and helpful.""" break def parse_tool_calls(self, text: str) -> List[tuple]: - pattern = r'\s*(.*?)\s*(.*?)\s*' - matches = re.findall(pattern, text, re.DOTALL) - calls = [] - for name, params_str in matches: - name = name.strip() - try: - params = json.loads(params_str.strip()) - calls.append((name, params)) - except Exception as e: - print_error(f"Failed to parse parameters for {name}: {e}") + + # 1. Try to find blocks (handles unclosed tags) + tc_blocks = re.findall(r'(.*?)(?:|$)', text, re.DOTALL) + if tc_blocks: + for block in tc_blocks: + if not block.strip(): continue + # Try to find tool_name tag (handles unclosed) + name_match = re.search(r'(.*?)(?:|$)', block, re.DOTALL) + name = name_match.group(1).strip() if name_match else None + + # Try to find parameters tag (handles unclosed) + params_match = re.search(r'(.*?)(?:|$)', block, re.DOTALL) + if name and params_match: + try: + params_str = params_match.group(1).strip() + # Auto-fix unclosed JSON if needed + if params_str.startswith('{') and not params_str.endswith('}'): + params_str += '}' + calls.append((name, json.loads(params_str))) + except: + # Last ditch: search for any JSON-like object in the parameters string + json_match = re.search(r'(\{.*\})', params_str, re.DOTALL) + if json_match: + try: calls.append((name, json.loads(json_match.group(1)))) + except: pass + + # 2. If no blocks, try searching for tool-specific tags + if not calls: + for tool_name in registry.tools.keys(): + # Handle or + attr_pattern = fr'<{tool_name}\s+(.*?)[\s/]*>' + attr_matches = re.findall(attr_pattern, text, re.DOTALL) + for attr_str in attr_matches: + attrs = {} + kv_pairs = re.findall(r'(\w+)\s*=\s*["\'](.*?)["\']', attr_str) + for k, v in kv_pairs: + attrs[k] = v + if attrs: + calls.append((tool_name, attrs)) + + # Handle {json} + block_pattern = fr'<{tool_name}>(.*?)(?:|$)' + block_matches = re.findall(block_pattern, text, re.DOTALL) + for b in block_matches: + json_match = re.search(r'(\{.*\})', b, re.DOTALL) + if json_match: + try: calls.append((tool_name, json.loads(json_match.group(1)))) + except: pass + + # 3. Final fallback: look for tool_name followed by JSON { ... } + if not calls: + for tool_name in registry.tools.keys(): + json_pattern = fr'{tool_name}\s*({{.*?}})' + json_matches = re.findall(json_pattern, text, re.DOTALL) + for json_str in json_matches: + try: + calls.append((tool_name, json.loads(json_str.strip()))) + except: + pass + return calls def execute_tool(self, name: str, params: Dict) -> str: diff --git a/src/ollama_cli/tools/__pycache__/code.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/code.cpython-312.pyc index 8312266..1e1a926 100644 Binary files a/src/ollama_cli/tools/__pycache__/code.cpython-312.pyc and b/src/ollama_cli/tools/__pycache__/code.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/__pycache__/execution.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/execution.cpython-312.pyc new file mode 100644 index 0000000..eecb826 Binary files /dev/null and b/src/ollama_cli/tools/__pycache__/execution.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc index 6a2a459..06db093 100644 Binary files a/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc and b/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc new file mode 100644 index 0000000..9b0c8fa Binary files /dev/null and b/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/__pycache__/memory.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/memory.cpython-312.pyc new file mode 100644 index 0000000..58d994c Binary files /dev/null and b/src/ollama_cli/tools/__pycache__/memory.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/__pycache__/system.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/system.cpython-312.pyc index abacd7a..9df7803 100644 Binary files a/src/ollama_cli/tools/__pycache__/system.cpython-312.pyc and b/src/ollama_cli/tools/__pycache__/system.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/__pycache__/web.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/web.cpython-312.pyc index 7e741fb..1d50962 100644 Binary files a/src/ollama_cli/tools/__pycache__/web.cpython-312.pyc and b/src/ollama_cli/tools/__pycache__/web.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/media.py b/src/ollama_cli/tools/media.py index b691167..04922fa 100644 --- a/src/ollama_cli/tools/media.py +++ b/src/ollama_cli/tools/media.py @@ -7,6 +7,56 @@ import random from .base import tool from ..core.config import load_config +@tool( + name="generate_image", + description="Generate an image using the local Stable Diffusion workflow (ComfyUI)", + parameters={ + "prompt": {"type": "string", "description": "The image description"} + } +) +@tool( + name="get_comfy_status", + description="Check the status of image generation jobs or the ComfyUI queue", + parameters={ + "prompt_id": {"type": "string", "description": "Optional: Specific prompt ID to check", "default": ""} + } +) +def get_comfy_status(prompt_id: str = "") -> str: + """Queries the ComfyUI API for queue or history status""" + config = load_config() + comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/") + + try: + if prompt_id: + # Check history for specific prompt + resp = requests.get(f"{comfy_url}/history/{prompt_id}", timeout=5) + if resp.status_code == 200: + history = resp.json() + if prompt_id in history: + return f"Status for {prompt_id}: Completed. Result saved." + + # Check queue if not in history + resp = requests.get(f"{comfy_url}/queue", timeout=5) + if resp.status_code == 200: + queue_data = resp.json() + for job in queue_data.get("queue_running", []): + if job[1] == prompt_id: return f"Status for {prompt_id}: Currently Running." + for job in queue_data.get("queue_pending", []): + if job[1] == prompt_id: return f"Status for {prompt_id}: Pending in Queue." + + return f"Status for {prompt_id}: ID not found in current queue or history." + else: + # General queue status + resp = requests.get(f"{comfy_url}/queue", timeout=5) + if resp.status_code == 200: + queue_data = resp.json() + running = len(queue_data.get("queue_running", [])) + pending = len(queue_data.get("queue_pending", [])) + return f"ComfyUI Queue Status: {running} running, {pending} pending." + return f"Error: Could not retrieve queue from {comfy_url}" + except Exception as e: + return f"Error connecting to ComfyUI: {str(e)}" + @tool( name="generate_image", description="Generate an image using the local Stable Diffusion workflow (ComfyUI)", @@ -18,7 +68,13 @@ def generate_image(prompt: str) -> str: """Invokes the ComfyUI API directly with a predefined workflow""" config = load_config() comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/") + image_model = config.get("image_model", "dreamshaper_8_pruned.safetensors") + # Determine dimensions (SDXL prefers 1024x1024) + width, height = (512, 512) + if "xl" in image_model.lower(): + width, height = (1024, 1024) + try: # Check if ComfyUI is running try: @@ -27,10 +83,9 @@ def generate_image(prompt: str) -> str: return f"Error: ComfyUI server ({comfy_url}) appears to be down. Please start it first or configure the URL with '/config comfy '." # Define the workflow directly - # Based on the user's previous generate_image.py, but using the configured URL workflow = { "1": { - "inputs": {"ckpt_name": "dreamshaper_8_pruned.safetensors"}, + "inputs": {"ckpt_name": image_model}, "class_type": "CheckpointLoaderSimple" }, "2": { @@ -48,7 +103,7 @@ def generate_image(prompt: str) -> str: "class_type": "CLIPTextEncode" }, "4": { - "inputs": {"width": 512, "height": 512, "batch_size": 1}, + "inputs": {"width": width, "height": height, "batch_size": 1}, "class_type": "EmptyLatentImage" }, "5": { @@ -80,7 +135,44 @@ def generate_image(prompt: str) -> str: if response.status_code == 200: data = response.json() prompt_id = data.get('prompt_id', 'unknown') - return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nCheck your ComfyUI output folder for 'ollama_cli_' prefixed files." + + # Start monitoring for the new image + output_path = os.path.expanduser(config.get("comfy_output_path", "~/ComfyUI/output")) + + # Helper to find the newest file with our prefix + def find_newest_image(): + import glob + files = glob.glob(os.path.join(output_path, "ollama_cli_*")) + if not files: return None + return max(files, key=os.path.getctime) + + initial_newest = find_newest_image() + + # Polling loop (max 60 seconds) + import time + found_file = None + for _ in range(30): + # Check status via API + status_check = get_comfy_status(prompt_id) + if "Completed" in status_check: + # Give filesystem a moment to sync + time.sleep(1) + current_newest = find_newest_image() + if current_newest and current_newest != initial_newest: + found_file = current_newest + break + + time.sleep(2) + + if found_file: + # Open the image automatically + if sys.platform == "darwin": + subprocess.run(["open", found_file]) + else: + subprocess.run(["xdg-open", found_file]) + return f"Image generation complete!\nSaved to: {found_file}\nOpening image now..." + + return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nMonitoring timed out, but the image should appear in {output_path} soon." else: return f"Error form ComfyUI: {response.status_code} - {response.text}" @@ -96,26 +188,83 @@ def generate_image(prompt: str) -> str: } ) def speak_text(text: str, output_file: str = "output.wav") -> str: - """Uses the high-quality Piper TTS installed by the installer""" - piper_dir = os.path.expanduser("~/.ollama-cli/piper") - piper_bin = os.path.join(piper_dir, "piper") - model_path = os.path.join(piper_dir, "voice.onnx") + """Uses the best available TTS engine (Kokoro > Piper > System)""" + config = load_config() + + # 1. Try Kokoro (Ultra-Realistic) + kokoro_python = os.path.expanduser(config.get("kokoro_path", "~/.ollama-cli/kokoro/venv/bin/python")) + if os.path.exists(kokoro_python): + try: + # Inline script to run in Kokoro venv + kokoro_script = f""" +import sys +import soundfile as sf +import os +try: + from kokoro import KPipeline + import torch +except ImportError: + print("Dependencies missing") + sys.exit(1) + +text = sys.argv[1] +output_file = sys.argv[2] + +# Initialize pipeline +pipeline = KPipeline(lang_code='a') +generator = pipeline(text, voice='af_bella', speed=1) + +all_audio = [] +for gs, ps, audio in generator: + if audio is not None: + all_audio.append(audio) + +if all_audio: + import numpy as np + full_audio = np.concatenate(all_audio) + sf.write(output_file, full_audio, 24000) + print("Success") +else: + print("No audio generated") + sys.exit(1) +""" + # Run in venv + subprocess.run([kokoro_python, "-c", kokoro_script, text, output_file], + check=True, capture_output=True) + + if sys.platform == "darwin": + subprocess.run(["afplay", output_file]) + return f"Audio saved to {output_file} and played (using Ultra-Realistic Kokoro TTS)" + except Exception as e: + # Continue to fallback + pass + + # 2. Try Piper (High-Quality) + piper_config = config.get("piper", {}) + piper_bin = os.path.expanduser(piper_config.get("path", "~/.ollama-cli/piper/piper")) + model_path = os.path.expanduser(piper_config.get("model", "~/.ollama-cli/piper/voice.onnx")) if os.path.exists(piper_bin) and os.path.exists(model_path): try: # Piper reads from stdin and writes to file cmd = f'echo "{text}" | {piper_bin} --model {model_path} --output_file {output_file}' subprocess.run(cmd, shell=True, check=True, capture_output=True) - return f"Audio saved to {output_file} (using high-quality Piper TTS)" + + # Auto-play on macOS + if sys.platform == "darwin": + subprocess.run(["afplay", output_file]) + + return f"Audio saved to {output_file} and played (using high-quality Piper TTS)" except Exception as e: - return f"Error using Piper: {e}. Falling back to system TTS..." + pass - # Fallback to simple 'say' command on macOS + # 3. Fallback to system 'say' command on macOS if sys.platform == "darwin": try: subprocess.run(["say", "-o", output_file, text], check=True) - return f"Audio saved to {output_file} (using system 'say' fallback)" + subprocess.run(["afplay", output_file]) + return f"Audio saved to {output_file} and played (using system 'say' fallback)" except Exception as e: return f"Error using 'say': {e}" - return "TTS engine not found. Please run the installer to set up Piper." + return "No compatible TTS engine found. Please run the installer." diff --git a/src/ollama_cli/tools/memory.py b/src/ollama_cli/tools/memory.py index 4ec450f..5e4a2c2 100644 --- a/src/ollama_cli/tools/memory.py +++ b/src/ollama_cli/tools/memory.py @@ -45,9 +45,7 @@ def recall_facts() -> str: facts = memory.get("facts", []) if not facts: return "No project facts stored." - return "Project Facts: -" + " -".join(f"- {f}" for f in facts) + return "Project Facts:\n" + "\n".join(f"- {f}" for f in facts) @tool( name="clear_memory",