diff --git a/install.sh b/install.sh index 41d10ff..f38642a 100755 --- a/install.sh +++ b/install.sh @@ -48,6 +48,8 @@ else if [[ $REPLY =~ ^[Yy]$ ]]; then echo "[*] Installing Ollama..." curl -fsSL https://ollama.com/install.sh | sh + echo "[*] Pulling vision model (llama3.2-vision)..." + ollama pull llama3.2-vision echo "✅ Ollama installed." else echo "Skipping Ollama installation." diff --git a/src/ollama_cli/__pycache__/main.cpython-312.pyc b/src/ollama_cli/__pycache__/main.cpython-312.pyc index f22a8d0..bfe51d1 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 bbb3dda..cad7021 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/__pycache__/ollama.cpython-312.pyc b/src/ollama_cli/core/__pycache__/ollama.cpython-312.pyc index eddf7f4..6713d4a 100644 Binary files a/src/ollama_cli/core/__pycache__/ollama.cpython-312.pyc and b/src/ollama_cli/core/__pycache__/ollama.cpython-312.pyc differ diff --git a/src/ollama_cli/core/config.py b/src/ollama_cli/core/config.py index 00ee674..076c525 100644 --- a/src/ollama_cli/core/config.py +++ b/src/ollama_cli/core/config.py @@ -29,7 +29,20 @@ DEFAULT_CONFIG = { "topic": "", "server": "https://ntfy.sh" }, - "default_model": "llama3.2" + "default_model": "llama3.2", + "vision_model": "llama3.2-vision", + "email": { + "username": "", + "password": "", + "imap_server": "imap.gmail.com", + "imap_port": 993, + "smtp_server": "smtp.gmail.com", + "smtp_port": 587 + }, + "google_calendar": { + "credentials_path": "~/credentials.json", + "token_path": "~/.ollama-cli-calendar-token.json" + } } def load_config(): diff --git a/src/ollama_cli/core/ollama.py b/src/ollama_cli/core/ollama.py index d415281..353b8d5 100644 --- a/src/ollama_cli/core/ollama.py +++ b/src/ollama_cli/core/ollama.py @@ -2,6 +2,7 @@ import json import requests import subprocess import sys +import os from typing import List, Dict, Optional, Generator class OllamaClient: @@ -16,21 +17,26 @@ class OllamaClient: 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]: + def get_available_models(self, include_vision: bool = False) -> 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', [])] + models = [m['name'] for m in data.get('models', [])] + if not include_vision: + models = [m for m in models if "vision" not in m.lower()] + return 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]) + model_name = line.split()[0] + if include_vision or "vision" not in model_name.lower(): + models.append(model_name) return models except Exception: return [] @@ -42,7 +48,7 @@ class OllamaClient: "model": model, "messages": messages, "stream": stream - }, stream=stream, timeout=60) + }, stream=stream, timeout=600) response.raise_for_status() full_response = "" @@ -65,23 +71,72 @@ class OllamaClient: 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""" + """Select best reasoning model based on task type""" if not available_models: return default_model - # Model preferences + # Model preferences for REASONING (prefer larger/smarter models) code_models = ["qwen2.5-coder", "mistral-nemo", "codellama", "deepseek-coder"] - general_models = ["llama3.2", "llama3.1", "mistral"] + general_models = ["mistral", "llama3.1", "llama3.2:3b", "llama3.2:latest", "llama3", "llama3.2"] - # Simple heuristic + user_input_lower = user_input.lower() + + # Check if it's a coding task code_keywords = ['code', 'script', 'program', 'function', 'debug', 'python', 'javascript'] - is_code_task = any(k in user_input.lower() for k in code_keywords) + 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 + # Filter out vision models + reasoning_pool = [m for m in available_models if "vision" not in m.lower()] + if not reasoning_pool: + return available_models[0] + for target in target_list: - for available in available_models: + for available in reasoning_pool: if target in available.lower(): + # If it's a 1b model, only pick it if no other preferred models are available + if ":1b" in available.lower() and len(reasoning_pool) > 1: + continue return available - return available_models[0] if available_models else default_model + # Fallback to a non-vision model, trying to avoid 1b + for available in reasoning_pool: + if ":1b" not in available.lower(): + return available + + return reasoning_pool[0] + + def describe_image(self, image_path: str, prompt: str = "What is in this image?", model: str = "llama3.2-vision") -> str: + """Analyze an image using a vision model""" + import base64 + + # Auto-download model if missing + available = self.get_available_models(include_vision=True) + if not any(model in m for m in available): + print(f"[*] Vision model '{model}' not found. Downloading...") + try: + subprocess.run(f"ollama pull {model}", shell=True, check=True) + except Exception as e: + return f"Error downloading vision model: {e}" + + try: + expanded_path = os.path.expanduser(image_path) + with open(expanded_path, "rb") as f: + img_data = base64.b64encode(f.read()).decode('utf-8') + + response = requests.post(self.chat_url, json={ + "model": model, + "messages": [ + { + "role": "user", + "content": prompt, + "images": [img_data] + } + ], + "stream": False + }, timeout=600) + response.raise_for_status() + return response.json().get("message", {}).get("content", "Error: No description returned.") + except Exception as e: + return f"Error analyzing image: {str(e)}" diff --git a/src/ollama_cli/main.py b/src/ollama_cli/main.py index 64142cb..8ca9fea 100644 --- a/src/ollama_cli/main.py +++ b/src/ollama_cli/main.py @@ -39,6 +39,8 @@ import ollama_cli.tools.knowledge import ollama_cli.tools.memory import ollama_cli.tools.execution import ollama_cli.tools.media +import ollama_cli.tools.email +import ollama_cli.tools.calendar 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 @@ -57,15 +59,23 @@ class OllamaCLI: self.max_tool_iterations = 5 def _build_system_prompt(self) -> str: - base_prompt = """You are a highly capable AI assistant powered by Ollama. + import datetime + now_str = datetime.datetime.now().strftime("%A, %B %d, %Y %I:%M %p") + + base_prompt = f"""Current Time: {now_str} +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. +1. When you need to use a tool, you MUST use the EXACT XML format below. +2. Output ONLY the tool call in its own block. 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. +5. If a tool returns a result, use that result to answer the user's question directly. +6. For email tasks, use 'check_inbox' to list emails, 'read_email' to read content by ID, and 'search_emails' to find specific messages. +7. When providing your final answer to the user, DO NOT include any tags. +REQUIRED TOOL FORMAT: name_of_tool {"param": "value"} @@ -115,7 +125,7 @@ You can call multiple tools in one response by repeating the block above.""" [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, get_comfy_status + [blue]Media:[/blue] generate_image, speak_text, get_comfy_status, analyze_image [blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory [bold]Integrations:[/bold] @@ -129,6 +139,12 @@ You can call multiple tools in one response by repeating the block above.""" /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 + /config email_user - Set email username (e.g., Gmail) + /config email_pass - Set email password (use App Password) + /config email_imap - Set IMAP server (default: imap.gmail.com) + /config email_smtp - Set SMTP server (default: smtp.gmail.com) + /config cal_creds - Set path to Google Calendar credentials.json + /config cal_token - Set path to Google Calendar token.json """ console.print(help_text) elif cmd == '/config': @@ -140,9 +156,15 @@ You can call multiple tools in one response by repeating the block above.""" [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]Vision Model:[/blue]{cfg.get('vision_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')} + [blue]Email User:[/blue] {cfg.get('email', {}).get('username')} + [blue]Email IMAP:[/blue] {cfg.get('email', {}).get('imap_server')} + [blue]Email SMTP:[/blue] {cfg.get('email', {}).get('smtp_server')} + [blue]Cal Creds:[/blue] {cfg.get('google_calendar', {}).get('credentials_path')} + [blue]Cal Token:[/blue] {cfg.get('google_calendar', {}).get('token_path')} """ console.print(config_display) elif len(parts) > 2: @@ -155,6 +177,9 @@ You can call multiple tools in one response by repeating the block above.""" elif subcmd == 'comfy': update_config("comfy_url", val) print_status(f"ComfyUI URL updated to: {val}") + elif subcmd == 'vision': + update_config("vision_model", val) + print_status(f"Vision model updated to: {val}") elif subcmd == 'comfy_output': update_config("comfy_output_path", val) print_status(f"ComfyUI output path updated to: {val}") @@ -168,10 +193,40 @@ You can call multiple tools in one response by repeating the block above.""" cfg["piper"]["model"] = val save_config(cfg) print_status(f"Piper model path updated to: {val}") + elif subcmd == 'email_user': + cfg = load_config() + cfg["email"]["username"] = val + save_config(cfg) + print_status(f"Email user updated to: {val}") + elif subcmd == 'email_pass': + cfg = load_config() + cfg["email"]["password"] = val + save_config(cfg) + print_status(f"Email password updated.") + elif subcmd == 'email_imap': + cfg = load_config() + cfg["email"]["imap_server"] = val + save_config(cfg) + print_status(f"IMAP server updated to: {val}") + elif subcmd == 'email_smtp': + cfg = load_config() + cfg["email"]["smtp_server"] = val + save_config(cfg) + print_status(f"SMTP server updated to: {val}") + elif subcmd == 'cal_creds': + cfg = load_config() + cfg["google_calendar"]["credentials_path"] = val + save_config(cfg) + print_status(f"Calendar credentials path updated to: {val}") + elif subcmd == 'cal_token': + cfg = load_config() + cfg["google_calendar"]["token_path"] = val + save_config(cfg) + print_status(f"Calendar token 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) @@ -262,8 +317,18 @@ You can call multiple tools in one response by repeating the block above.""" 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: + # Prefer config default, then llama3.2, then first available + pref_model = self.config.get("default_model", "llama3.2") + found_pref = False + for am in self.available_models: + if pref_model in am: + self.current_model = am + found_pref = True + break + + if not found_pref: 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}") @@ -285,6 +350,15 @@ You can call multiple tools in one response by repeating the block above.""" continue self.messages.append({"role": "user", "content": user_input}) + + # Auto-select best model for the task (Reasoning model) + # This happens before the chat cycle, so the first response is from the right model + if not self.current_model.startswith("llama3.2-vision"): # Only auto-switch away from vision model if it's currently selected + task_model = self.client.select_best_model(user_input, self.available_models, self.current_model) + if task_model != self.current_model: + print_status(f"Switching to [bold green]{task_model}[/bold green] for reasoning...") + self.current_model = task_model + self.process_chat_cycle() def process_chat_cycle(self): @@ -314,6 +388,29 @@ You can call multiple tools in one response by repeating the block above.""" if call_key in executed_tool_calls: print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]") continue + + # Specific prevention for vision model loops: same tool + same file + already called once + if name == "analyze_image": + img_path = params.get("image_path") + if img_path: + vision_key = f"vision_path:{img_path}" + if vision_key in executed_tool_calls: + # We've already queried this image in this cycle. + # Check if the prompt is suspiciously similar to previous content + prompt = params.get("prompt", "").lower() + + # If prompt is long or contains parts of the previous messages, block it + is_suspicious = len(prompt) > 30 + for msg in self.messages: + if msg["role"] == "assistant" and prompt in msg["content"].lower(): + is_suspicious = True + break + + if is_suspicious: + print_status(f"Blocking suspicious vision loop for: [dim]{img_path}[/dim]") + continue + executed_tool_calls.add(vision_key) + executed_tool_calls.add(call_key) result = self.execute_tool(name, params) @@ -326,7 +423,8 @@ You can call multiple tools in one response by repeating the block above.""" 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."}) + feedback = "\n\n".join(results) + "\n\n[SYSTEM]: Tool execution is complete. Use the results above to provide your final response to the user. DO NOT call any tools again to process these results or to summarize them. If you have the information needed, answer the user now." + self.messages.append({"role": "user", "content": feedback}) # If we made it here, loop again to let the model process results @@ -343,9 +441,16 @@ You can call multiple tools in one response by repeating the block above.""" if tc_blocks: for block in tc_blocks: if not block.strip(): continue + name = None # 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 + if name_match: + name = name_match.group(1).strip() + else: + # Fallback: tool name might be naked after + first_line = block.strip().split('\n')[0].strip() + if first_line in registry.tools: + name = first_line # Try to find parameters tag (handles unclosed) params_match = re.search(r'(.*?)(?:|$)', block, re.DOTALL) @@ -367,12 +472,13 @@ You can call multiple tools in one response by repeating the block above.""" if not calls: for tool_name in registry.tools.keys(): # Handle or - attr_pattern = fr'<{tool_name}\s+(.*?)[\s/]*>' + 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: + # Find all pairs of key="value" or key='value' + kv_pairs = re.findall(r'(\w+)\s*=\s*(["\'])(.*?)\2', attr_str) # capture the quote type + for k, _, v in kv_pairs: # k, _, v to ignore the quote type attrs[k] = v if attrs: calls.append((tool_name, attrs)) @@ -396,6 +502,46 @@ You can call multiple tools in one response by repeating the block above.""" calls.append((tool_name, json.loads(json_str.strip()))) except: pass + + # 4. Super-fallback: Line-by-line heuristic for "naked" calls (e.g. llama3.2:1b style) + if not calls: + lines = text.strip().split('\n') + current_tool = None + current_params = {} + for line in lines: + line = line.strip() + if not line: continue + + # Look for a tool name at the start of the line (e.g. 'analyze_image path=...') + # We use a word-boundary check to ensure it's the full tool name + clean_line = line.lower().replace('tool_call', '').strip() + match = re.search(r'^(\w+)', clean_line) + t_name = match.group(1) if match else None + + if t_name in registry.tools: + # If we already had a tool, save it before starting new one + if current_tool and current_params: + calls.append((current_tool, current_params)) + current_params = {} + current_tool = t_name + # Fall through to check for params on the same line + + # Look for key="value" or key='value' or key: value + kv_match = re.findall(r'(\w+)\s*[=:]\s*(["\'])(.*?)\2', line) + if kv_match and current_tool: + for k, _, v in kv_match: + # Map common misnamed parameters + if k == 'path' and current_tool == 'analyze_image': k = 'image_path' + current_params[k] = v + elif current_tool: + # Try simplified key=value without quotes + kv_simple = re.findall(r'(\w+)\s*=\s*([^\s]+)', line) + for k, v in kv_simple: + if k == 'path' and current_tool == 'analyze_image': k = 'image_path' + current_params[k] = v + + if current_tool and current_params: + calls.append((current_tool, current_params)) return calls diff --git a/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/filesystem.cpython-312.pyc index 06db093..1520c17 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 index 9b0c8fa..3d5b16b 100644 Binary files a/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc and b/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc differ diff --git a/src/ollama_cli/tools/calendar.py b/src/ollama_cli/tools/calendar.py new file mode 100644 index 0000000..b83975b --- /dev/null +++ b/src/ollama_cli/tools/calendar.py @@ -0,0 +1,170 @@ +import os +import datetime +import pickle +from googleapiclient.discovery import build +from google_auth_oauthlib.flow import InstalledAppFlow +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request +from .base import tool +from ..core.config import load_config, save_config + +# If modifying these SCOPES, delete the file token.json. +SCOPES = ['https://www.googleapis.com/auth/calendar'] + +def get_calendar_service(): + """Gets an authorized Google Calendar API service instance.""" + config = load_config() + cal_config = config.get("google_calendar", {}) + + creds_path = os.path.expanduser(cal_config.get("credentials_path", "~/credentials.json")) + token_path = os.path.expanduser(cal_config.get("token_path", "~/.ollama-cli-calendar-token.json")) + + creds = None + # The file token.json stores the user's access and refresh tokens, and is + # created automatically when the authorization flow completes for the first + # time. + if os.path.exists(token_path): + creds = Credentials.from_authorized_user_file(token_path, SCOPES) + + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + if not os.path.exists(creds_path): + raise FileNotFoundError(f"Google Calendar credentials not found at {creds_path}. Please download them from Google Cloud Console.") + + flow = InstalledAppFlow.from_client_secrets_file(creds_path, SCOPES) + creds = flow.run_local_server(port=0) + + # Save the credentials for the next run + with open(token_path, 'w') as token: + token.write(creds.to_json()) + + return build('calendar', 'v3', credentials=creds) + +@tool( + name="list_calendar_events", + description="List upcoming events from the user's Google Calendar.", + parameters={ + "max_results": {"type": "integer", "description": "Number of events to retrieve", "default": 10}, + "time_min": {"type": "string", "description": "ISO format start time (e.g. 2026-02-28T00:00:00Z). Defaults to now.", "default": ""}, + "time_max": {"type": "string", "description": "Optional: ISO format end time (e.g. 2026-02-28T23:59:59Z) to filter for a specific day.", "default": ""} + } +) +def list_calendar_events(max_results: int = 10, time_min: str = "", time_max: str = "") -> str: + try: + service = get_calendar_service() + + # Basic natural language parsing for 'today' and 'tomorrow' + now = datetime.datetime.utcnow() + if time_min.lower() == "today": + time_min = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + 'Z' + if not time_max: + time_max = now.replace(hour=23, minute=59, second=59, microsecond=999999).isoformat() + 'Z' + elif time_min.lower() == "tomorrow": + tomorrow = now + datetime.timedelta(days=1) + time_min = tomorrow.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + 'Z' + if not time_max: + time_max = tomorrow.replace(hour=23, minute=59, second=59, microsecond=999999).isoformat() + 'Z' + + if not time_min: + time_min = now.isoformat() + 'Z' + + kwargs = { + 'calendarId': 'primary', + 'timeMin': time_min, + 'maxResults': max_results, + 'singleEvents': True, + 'orderBy': 'startTime' + } + if time_max: + kwargs['timeMax'] = time_max + + events_result = service.events().list(**kwargs).execute() + events = events_result.get('items', []) + + if not events: + return "No upcoming events found." + + res = [] + for event in events: + start = event['start'].get('dateTime', event['start'].get('date')) + res.append(f"- {start}: {event['summary']} (ID: {event['id']})") + + return "\n".join(res) + except Exception as e: + return f"Error listing calendar events: {str(e)}" + +@tool( + name="create_calendar_event", + description="Create a new event in the user's Google Calendar.", + parameters={ + "summary": {"type": "string", "description": "The title of the event"}, + "start_time": {"type": "string", "description": "Start time in ISO format (e.g., 2024-03-01T10:00:00Z)"}, + "end_time": {"type": "string", "description": "End time in ISO format (e.g., 2024-03-01T11:00:00Z)"}, + "description": {"type": "string", "description": "Optional: Description of the event", "default": ""}, + "location": {"type": "string", "description": "Optional: Location of the event", "default": ""} + } +) +def create_calendar_event(summary: str, start_time: str, end_time: str, description: str = "", location: str = "") -> str: + try: + service = get_calendar_service() + + event = { + 'summary': summary, + 'location': location, + 'description': description, + 'start': {'dateTime': start_time, 'timeZone': 'UTC'}, + 'end': {'dateTime': end_time, 'timeZone': 'UTC'}, + } + + event = service.events().insert(calendarId='primary', body=event).execute() + return f"Event created: {event.get('htmlLink')}" + except Exception as e: + return f"Error creating calendar event: {str(e)}" + +@tool( + name="delete_calendar_event", + description="Delete an event from the user's Google Calendar.", + parameters={ + "event_id": {"type": "string", "description": "The ID of the event to delete"} + } +) +def delete_calendar_event(event_id: str) -> str: + try: + service = get_calendar_service() + service.events().delete(calendarId='primary', eventId=event_id).execute() + return f"Event {event_id} deleted successfully." + except Exception as e: + return f"Error deleting calendar event: {str(e)}" + +@tool( + name="update_calendar_event", + description="Update an existing calendar event.", + parameters={ + "event_id": {"type": "string", "description": "The ID of the event to update"}, + "summary": {"type": "string", "description": "Optional: New title for the event", "default": ""}, + "start_time": {"type": "string", "description": "Optional: New start time in ISO format", "default": ""}, + "end_time": {"type": "string", "description": "Optional: New end time in ISO format", "default": ""}, + "description": {"type": "string", "description": "Optional: New description", "default": ""}, + "location": {"type": "string", "description": "Optional: New location", "default": ""} + } +) +def update_calendar_event(event_id: str, summary: str = "", start_time: str = "", end_time: str = "", description: str = "", location: str = "") -> str: + try: + service = get_calendar_service() + # First, fetch the existing event + event = service.events().get(calendarId='primary', eventId=event_id).execute() + + # Update fields if provided + if summary: event['summary'] = summary + if description: event['description'] = description + if location: event['location'] = location + if start_time: event['start'] = {'dateTime': start_time, 'timeZone': 'UTC'} + if end_time: event['end'] = {'dateTime': end_time, 'timeZone': 'UTC'} + + updated_event = service.events().update(calendarId='primary', eventId=event_id, body=event).execute() + return f"Event updated: {updated_event.get('htmlLink')}" + except Exception as e: + return f"Error updating calendar event: {str(e)}" diff --git a/src/ollama_cli/tools/email.py b/src/ollama_cli/tools/email.py new file mode 100644 index 0000000..64625cb --- /dev/null +++ b/src/ollama_cli/tools/email.py @@ -0,0 +1,313 @@ +import imaplib +import smtplib +import email +from email.header import decode_header +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import html.parser +from .base import tool +from ..core.config import load_config + +# HTML stripper for cleaner reading +class MLStripper(html.parser.HTMLParser): + def __init__(self): + super().__init__() + self.reset() + self.strict = False + self.convert_charrefs = True + self.text = [] + def handle_data(self, d): + self.text.append(d) + def get_data(self): + return "".join(self.text) + +def strip_tags(html_content): + s = MLStripper() + s.feed(html_content) + return s.get_data() + +def get_email_config(): + config = load_config() + return config.get("email", {}) + +@tool( + name="check_inbox", + description="List recent emails from the inbox. Returns ID, Sender, and Subject.", + parameters={ + "limit": {"type": "integer", "description": "Number of emails to list (max 10)", "default": 5}, + "unread_only": {"type": "boolean", "description": "Only list unread emails", "default": False} + } +) +def check_inbox(limit: int = 5, unread_only: bool = False) -> str: + conf = get_email_config() + username = conf.get("username") + password = conf.get("password") + imap_server = conf.get("imap_server") + imap_port = conf.get("imap_port", 993) + + if not (username and password and imap_server): + return "Error: Email not configured. Please set 'email' in ~/.ollama-cli-config.json" + + try: + mail = imaplib.IMAP4_SSL(imap_server, imap_port) + mail.login(username, password) + mail.select("inbox") + + search_crit = "UNSEEN" if unread_only else "ALL" + status, messages = mail.search(None, search_crit) + + if status != "OK": + return "No emails found." + + email_ids = messages[0].split() + if not email_ids: + return "No emails found." + + # Get latest N + limit = min(limit, 10) + latest_ids = email_ids[-limit:] + + results = [] + for e_id in reversed(latest_ids): + # Fetch headers only + _, msg_data = mail.fetch(e_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") + for response_part in msg_data: + if isinstance(response_part, tuple): + msg = email.message_from_bytes(response_part[1]) + subject_header = msg["Subject"] + subject = "(No Subject)" + if subject_header: + decoded_list = decode_header(subject_header) + subject_parts = [] + for s, enc in decoded_list: + if isinstance(s, bytes): + try: + subject_parts.append(s.decode(enc or 'utf-8', errors='replace')) + except: + subject_parts.append(s.decode('utf-8', errors='replace')) + else: + subject_parts.append(str(s)) + subject = "".join(subject_parts) + + from_ = msg.get("From") + results.append(f"[ID: {e_id.decode()}] From: {from_} | Subject: {subject}") + + mail.logout() + return "\n".join(results) + except Exception as e: + return f"Error checking email: {str(e)}" + +@tool( + name="read_email", + description="Read the full content of a specific email by ID.", + parameters={ + "email_id": {"type": "string", "description": "The ID of the email to read (from check_inbox)"} + } +) +def read_email(email_id: str) -> str: + conf = get_email_config() + username = conf.get("username") + password = conf.get("password") + imap_server = conf.get("imap_server") + imap_port = conf.get("imap_port", 993) + + if not (username and password): + return "Error: Email credentials missing." + + try: + mail = imaplib.IMAP4_SSL(imap_server, imap_port) + mail.login(username, password) + mail.select("inbox") + + _, msg_data = mail.fetch(email_id, "(RFC822)") + if not msg_data or not msg_data[0]: + return "Error: Could not fetch email." + + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + + body = "" + if msg.is_multipart(): + for part in msg.walk(): + ctype = part.get_content_type() + cdispo = str(part.get("Content-Disposition")) + + if ctype == "text/plain" and "attachment" not in cdispo: + try: + body = part.get_payload(decode=True).decode(errors='replace') + except: + pass + break # Prefer plain text + elif ctype == "text/html" and "attachment" not in cdispo: + try: + html_body = part.get_payload(decode=True).decode(errors='replace') + body = strip_tags(html_body) + except: + pass + else: + try: + body = msg.get_payload(decode=True).decode(errors='replace') + except: + body = "(Could not decode body)" + + mail.logout() + return f"Subject: {msg['Subject']}\nFrom: {msg['From']}\n\n{body[:2000]}" # Limit length + except Exception as e: + if "BAD" in str(e): + return f"Error: Invalid Email ID '{email_id}'. Please use an ID from 'check_inbox'." + return f"Error reading email {email_id}: {str(e)}" + +@tool( + name="mark_as_read", + description="Mark an email as read (seen).", + parameters={ + "email_id": {"type": "string", "description": "The ID of the email to mark as read"} + } +) +def mark_as_read(email_id: str) -> str: + conf = get_email_config() + username = conf.get("username") + password = conf.get("password") + imap_server = conf.get("imap_server") + imap_port = conf.get("imap_port", 993) + + if not (username and password): + return "Error: Email credentials missing." + + try: + mail = imaplib.IMAP4_SSL(imap_server, imap_port) + mail.login(username, password) + mail.select("inbox") + + mail.store(email_id, '+FLAGS', '\\Seen') + + mail.logout() + return f"Email {email_id} marked as read." + except Exception as e: + return f"Error marking email {email_id} as read: {str(e)}" + +@tool( + name="search_emails", + description="Search for emails in the inbox using criteria like sender, subject, or keyword.", + parameters={ + "query": {"type": "string", "description": "Keyword to search for in all fields"}, + "sender": {"type": "string", "description": "Optional: Filter by sender email address", "default": ""}, + "subject": {"type": "string", "description": "Optional: Filter by subject keyword", "default": ""}, + "unread_only": {"type": "boolean", "description": "Optional: Only search unread emails", "default": False}, + "limit": {"type": "integer", "description": "Max results to return", "default": 5} + } +) +def search_emails(query: str = "", sender: str = "", subject: str = "", unread_only: bool = False, limit: int = 5) -> str: + conf = get_email_config() + username = conf.get("username") + password = conf.get("password") + imap_server = conf.get("imap_server") + imap_port = conf.get("imap_port", 993) + + if not (username and password): + return "Error: Email credentials missing." + + try: + mail = imaplib.IMAP4_SSL(imap_server, imap_port) + mail.login(username, password) + mail.select("inbox") + + # Build search criteria + criteria = [] + if unread_only: criteria.append("UNSEEN") + if query: criteria.append(f'TEXT "{query}"') + if sender: criteria.append(f'FROM "{sender}"') + if subject: criteria.append(f'SUBJECT "{subject}"') + + search_str = " ".join(criteria) if criteria else "ALL" + status, messages = mail.search(None, search_str) + + if status != "OK": + return "Search failed." + + email_ids = messages[0].split() + if not email_ids: + return f"No emails found matching: {search_str}" + + latest_ids = email_ids[-limit:] + results = [] + for e_id in reversed(latest_ids): + _, msg_data = mail.fetch(e_id, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])") + for response_part in msg_data: + if isinstance(response_part, tuple): + msg = email.message_from_bytes(response_part[1]) + sub = msg.get("Subject", "(No Subject)") + frm = msg.get("From", "(No Sender)") + results.append(f"[ID: {e_id.decode()}] From: {frm} | Subject: {sub}") + + mail.logout() + return "\n".join(results) if results else "No matches found." + except Exception as e: + return f"Error searching emails: {str(e)}" + +@tool( + name="delete_email", + description="Delete an email from the inbox by ID.", + parameters={ + "email_id": {"type": "string", "description": "The ID of the email to delete"} + } +) +def delete_email(email_id: str) -> str: + conf = get_email_config() + username = conf.get("username") + password = conf.get("password") + imap_server = conf.get("imap_server") + imap_port = conf.get("imap_port", 993) + + if not (username and password): + return "Error: Email credentials missing." + + try: + mail = imaplib.IMAP4_SSL(imap_server, imap_port) + mail.login(username, password) + mail.select("inbox") + + # Mark as deleted + mail.store(email_id, '+FLAGS', '\\Deleted') + mail.expunge() + + mail.logout() + return f"Email {email_id} deleted successfully." + except Exception as e: + return f"Error deleting email {email_id}: {str(e)}" + +@tool( + name="send_email", + description="Send an email.", + parameters={ + "to_email": {"type": "string", "description": "Recipient address"}, + "subject": {"type": "string", "description": "Subject line"}, + "body": {"type": "string", "description": "Body text"} + } +) +def send_email(to_email: str, subject: str, body: str) -> str: + conf = get_email_config() + username = conf.get("username") + password = conf.get("password") + smtp_server = conf.get("smtp_server") + smtp_port = conf.get("smtp_port", 587) + + if not (username and password and smtp_server): + return "Error: Email config missing. Please set 'email' in config." + + try: + msg = MIMEMultipart() + msg['From'] = username + msg['To'] = to_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + server = smtplib.SMTP(smtp_server, smtp_port) + server.starttls() + server.login(username, password) + server.send_message(msg) + server.quit() + + return f"Email sent to {to_email}" + except Exception as e: + return f"Error sending email: {str(e)}" diff --git a/src/ollama_cli/tools/media.py b/src/ollama_cli/tools/media.py index 6a59e04..fb36b04 100644 --- a/src/ollama_cli/tools/media.py +++ b/src/ollama_cli/tools/media.py @@ -202,6 +202,25 @@ def generate_image(prompt: str) -> str: except Exception as e: return f"Error generating image: {str(e)}" +@tool( + name="analyze_image", + description="Analyze or describe a local image file using AI vision", + parameters={ + "image_path": {"type": "string", "description": "Path to the image file"}, + "prompt": {"type": "string", "description": "What to ask about the image", "default": "Describe this image in detail."} + } +) +def analyze_image(image_path: str, prompt: str = "Describe this image in detail.") -> str: + """Invokes the Ollama vision API""" + config = load_config() + vision_model = config.get("vision_model", "llama3.2-vision") + + from ..core.ollama import OllamaClient + client = OllamaClient(config.get("ollama_url")) + + print_status(f"Analyzing image with {vision_model}...") + return client.describe_image(image_path, prompt, model=vision_model) + @tool( name="speak_text", description="Convert text to speech using Piper TTS (high quality)", @@ -290,4 +309,4 @@ else: except Exception as e: return f"Error using 'say': {e}" - return "No compatible TTS engine found. Please run the installer." + return "TTS engine not found. Please run the installer to set up Piper or Kokoro."