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}>(.*?)(?:{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}>(.*?)(?:{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",