Add Email and Calendar

This commit is contained in:
2026-02-28 20:07:54 +00:00
parent 0fdf75f3e9
commit 6aec914d77
12 changed files with 743 additions and 25 deletions

View File

@@ -48,6 +48,8 @@ else
if [[ $REPLY =~ ^[Yy]$ ]]; then if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "[*] Installing Ollama..." echo "[*] Installing Ollama..."
curl -fsSL https://ollama.com/install.sh | sh curl -fsSL https://ollama.com/install.sh | sh
echo "[*] Pulling vision model (llama3.2-vision)..."
ollama pull llama3.2-vision
echo "✅ Ollama installed." echo "✅ Ollama installed."
else else
echo "Skipping Ollama installation." echo "Skipping Ollama installation."

View File

@@ -29,7 +29,20 @@ DEFAULT_CONFIG = {
"topic": "", "topic": "",
"server": "https://ntfy.sh" "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(): def load_config():

View File

@@ -2,6 +2,7 @@ import json
import requests import requests
import subprocess import subprocess
import sys import sys
import os
from typing import List, Dict, Optional, Generator from typing import List, Dict, Optional, Generator
class OllamaClient: class OllamaClient:
@@ -16,21 +17,26 @@ class OllamaClient:
self.chat_url = f"{self.base_url}/api/chat" self.chat_url = f"{self.base_url}/api/chat"
self.list_url = f"{self.base_url}/api/tags" 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""" """Get list of models available in Ollama"""
try: try:
# Try API first # Try API first
response = requests.get(self.list_url, timeout=5) response = requests.get(self.list_url, timeout=5)
if response.status_code == 200: if response.status_code == 200:
data = response.json() 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 # Fallback to CLI
result = subprocess.run("ollama list", shell=True, capture_output=True, text=True, timeout=5) result = subprocess.run("ollama list", shell=True, capture_output=True, text=True, timeout=5)
models = [] models = []
for line in result.stdout.split('\n')[1:]: # Skip header for line in result.stdout.split('\n')[1:]: # Skip header
if line.strip(): 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 return models
except Exception: except Exception:
return [] return []
@@ -42,7 +48,7 @@ class OllamaClient:
"model": model, "model": model,
"messages": messages, "messages": messages,
"stream": stream "stream": stream
}, stream=stream, timeout=60) }, stream=stream, timeout=600)
response.raise_for_status() response.raise_for_status()
full_response = "" full_response = ""
@@ -65,23 +71,72 @@ class OllamaClient:
raise Exception(f"Error calling Ollama: {str(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: 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: if not available_models:
return default_model return default_model
# Model preferences # Model preferences for REASONING (prefer larger/smarter models)
code_models = ["qwen2.5-coder", "mistral-nemo", "codellama", "deepseek-coder"] 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'] 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 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 target in target_list:
for available in available_models: for available in reasoning_pool:
if target in available.lower(): 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
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)}"

View File

@@ -39,6 +39,8 @@ import ollama_cli.tools.knowledge
import ollama_cli.tools.memory import ollama_cli.tools.memory
import ollama_cli.tools.execution import ollama_cli.tools.execution
import ollama_cli.tools.media 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.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.lsp import start_lsp_server, lsp_goto_definition
from ollama_cli.integrations.notify import start_notify_thread, send_notification from ollama_cli.integrations.notify import start_notify_thread, send_notification
@@ -57,15 +59,23 @@ class OllamaCLI:
self.max_tool_iterations = 5 self.max_tool_iterations = 5
def _build_system_prompt(self) -> str: 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. You have access to a variety of tools to help you complete tasks.
CRITICAL RULES: CRITICAL RULES:
1. When you need to use a tool, you MUST use the EXACT format below. 1. When you need to use a tool, you MUST use the EXACT XML format below.
2. Output ONLY the tool call. DO NOT explain what you are doing. 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. 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. 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 <tool_call> tags.
REQUIRED TOOL FORMAT:
<tool_call> <tool_call>
<tool_name>name_of_tool</tool_name> <tool_name>name_of_tool</tool_name>
<parameters>{"param": "value"}</parameters> <parameters>{"param": "value"}</parameters>
@@ -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]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]Code & Python:[/blue] run_python, replace_text, code_analyze_file, run_linter
[blue]Web:[/blue] web_search, read_url [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 [blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory
[bold]Integrations:[/bold] [bold]Integrations:[/bold]
@@ -129,6 +139,12 @@ You can call multiple tools in one response by repeating the block above."""
/config comfy_output <path> - Set ComfyUI output folder path /config comfy_output <path> - Set ComfyUI output folder path
/config piper_path <path> - Set path to Piper binary /config piper_path <path> - Set path to Piper binary
/config piper_model <path> - Set path to Piper voice model /config piper_model <path> - Set path to Piper voice model
/config email_user <user> - Set email username (e.g., Gmail)
/config email_pass <pass> - Set email password (use App Password)
/config email_imap <host> - Set IMAP server (default: imap.gmail.com)
/config email_smtp <host> - Set SMTP server (default: smtp.gmail.com)
/config cal_creds <path> - Set path to Google Calendar credentials.json
/config cal_token <path> - Set path to Google Calendar token.json
""" """
console.print(help_text) console.print(help_text)
elif cmd == '/config': 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]ComfyUI URL:[/blue] {cfg.get('comfy_url')}
[blue]Comfy Output:[/blue] {cfg.get('comfy_output_path')} [blue]Comfy Output:[/blue] {cfg.get('comfy_output_path')}
[blue]Image Model:[/blue] {cfg.get('image_model')} [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 Path:[/blue] {cfg.get('piper', {}).get('path')}
[blue]Piper Model:[/blue] {cfg.get('piper', {}).get('model')} [blue]Piper Model:[/blue] {cfg.get('piper', {}).get('model')}
[blue]Default Model:[/blue] {cfg.get('default_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) console.print(config_display)
elif len(parts) > 2: elif len(parts) > 2:
@@ -155,6 +177,9 @@ You can call multiple tools in one response by repeating the block above."""
elif subcmd == 'comfy': elif subcmd == 'comfy':
update_config("comfy_url", val) update_config("comfy_url", val)
print_status(f"ComfyUI URL updated to: {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': elif subcmd == 'comfy_output':
update_config("comfy_output_path", val) update_config("comfy_output_path", val)
print_status(f"ComfyUI output path updated to: {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 cfg["piper"]["model"] = val
save_config(cfg) save_config(cfg)
print_status(f"Piper model path updated to: {val}") 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: else:
print_error("Usage: /config <ollama|comfy|comfy_output|piper_path|piper_model> <value>") print_error("Usage: /config <ollama|comfy|email_user|email_pass|email_imap|email_smtp|cal_creds|cal_token> <value>")
else: else:
print_error("Usage: /config <ollama|comfy|comfy_output|piper_path|piper_model> <value>") print_error("Usage: /config <ollama|comfy|email_user|email_pass|email_imap|email_smtp|cal_creds|cal_token> <value>")
elif cmd == '/quit': elif cmd == '/quit':
print_status("Goodbye!") print_status("Goodbye!")
sys.exit(0) 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: if not self.available_models:
print_error("No models found. Make sure Ollama is running.") print_error("No models found. Make sure Ollama is running.")
else: 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] self.current_model = self.available_models[0]
print_status(f"Using model: [bold green]{self.current_model}[/bold green]") print_status(f"Using model: [bold green]{self.current_model}[/bold green]")
except Exception as e: except Exception as e:
print_error(f"Could not connect to Ollama: {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 continue
self.messages.append({"role": "user", "content": user_input}) 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() self.process_chat_cycle()
def process_chat_cycle(self): 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: if call_key in executed_tool_calls:
print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]") print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]")
continue 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) executed_tool_calls.add(call_key)
result = self.execute_tool(name, params) result = self.execute_tool(name, params)
@@ -326,7 +423,8 @@ You can call multiple tools in one response by repeating the block above."""
break break
# Add all results to conversation # 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 # 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: if tc_blocks:
for block in tc_blocks: for block in tc_blocks:
if not block.strip(): continue if not block.strip(): continue
name = None
# Try to find tool_name tag (handles unclosed) # Try to find tool_name tag (handles unclosed)
name_match = re.search(r'<tool_name>(.*?)(?:</tool_name>|$)', block, re.DOTALL) name_match = re.search(r'<tool_name>(.*?)(?:</tool_name>|$)', 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 <tool_call>
first_line = block.strip().split('\n')[0].strip()
if first_line in registry.tools:
name = first_line
# Try to find parameters tag (handles unclosed) # Try to find parameters tag (handles unclosed)
params_match = re.search(r'<parameters>(.*?)(?:</parameters>|$)', block, re.DOTALL) params_match = re.search(r'<parameters>(.*?)(?:</parameters>|$)', block, re.DOTALL)
@@ -367,12 +472,13 @@ You can call multiple tools in one response by repeating the block above."""
if not calls: if not calls:
for tool_name in registry.tools.keys(): for tool_name in registry.tools.keys():
# Handle <tool_name ... /> or <tool_name ... > # Handle <tool_name ... /> or <tool_name ... >
attr_pattern = fr'<{tool_name}\s+(.*?)[\s/]*>' attr_pattern = fr'<{tool_name}\s+([^>]*?)[\s/]*>'
attr_matches = re.findall(attr_pattern, text, re.DOTALL) attr_matches = re.findall(attr_pattern, text, re.DOTALL)
for attr_str in attr_matches: for attr_str in attr_matches:
attrs = {} attrs = {}
kv_pairs = re.findall(r'(\w+)\s*=\s*["\'](.*?)["\']', attr_str) # Find all pairs of key="value" or key='value'
for k, v in kv_pairs: 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 attrs[k] = v
if attrs: if attrs:
calls.append((tool_name, 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()))) calls.append((tool_name, json.loads(json_str.strip())))
except: except:
pass 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 return calls

View File

@@ -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)}"

View File

@@ -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)}"

View File

@@ -202,6 +202,25 @@ def generate_image(prompt: str) -> str:
except Exception as e: except Exception as e:
return f"Error generating image: {str(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( @tool(
name="speak_text", name="speak_text",
description="Convert text to speech using Piper TTS (high quality)", description="Convert text to speech using Piper TTS (high quality)",
@@ -290,4 +309,4 @@ else:
except Exception as e: except Exception as e:
return f"Error using 'say': {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."