From 6aec914d77ce665e6cd019b4fbb668b112180a0f Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sat, 28 Feb 2026 20:07:54 +0000 Subject: [PATCH] Add Email and Calendar --- install.sh | 2 + .../__pycache__/main.cpython-312.pyc | Bin 22039 -> 28070 bytes .../core/__pycache__/config.cpython-312.pyc | Bin 2352 -> 2663 bytes .../core/__pycache__/ollama.cpython-312.pyc | Bin 5017 -> 7875 bytes src/ollama_cli/core/config.py | 15 +- src/ollama_cli/core/ollama.py | 77 ++++- src/ollama_cli/main.py | 170 +++++++++- .../__pycache__/filesystem.cpython-312.pyc | Bin 4503 -> 4530 bytes .../tools/__pycache__/media.cpython-312.pyc | Bin 10578 -> 12765 bytes src/ollama_cli/tools/calendar.py | 170 ++++++++++ src/ollama_cli/tools/email.py | 313 ++++++++++++++++++ src/ollama_cli/tools/media.py | 21 +- 12 files changed, 743 insertions(+), 25 deletions(-) create mode 100644 src/ollama_cli/tools/calendar.py create mode 100644 src/ollama_cli/tools/email.py 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 f22a8d00c1b7a86daa8085f6f8f9e9a9ba181a18..bfe51d1c79db1d2554600d8b0576f54b67b918f2 100644 GIT binary patch delta 10318 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 MlMz#r5~9w30pH6QWdHyG delta 5342 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 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 06db093b2e487c4dfd3b3625552da29d0f86008e..1520c1783236b7b5697523ee8d75db6f8e40ef59 100644 GIT binary patch delta 571 zcmYk1&ubGw6vt#svVX!Tx#yU$pY1DtZv~ zq~#qV3euBA1VMB3(&j3PpgkA_@edH96+}4&cDn%uN}uE;`!Bi z)L41pd{CrC3N`&0m0(Wa4O|-DDLte31|iqwRhehwe~FMK9WLn}mEjhfWXh_tTeZ5&fABk|8r^UQJyTTiB#697BSfHSC6@3(&PIPs- z-U_2|VYwbJ_H}V>0f+Dr(S_&s4*032jE5rJB!eLXQd)M*8);Y9s%?MGUqAZZE8o#S zTHYr+b+2}}_CW6(+_LwBubr#p1D9-$4+V<6S+#}t6c#vI&(P3aBPh$gQ70(Ly$On5 zVnMmPrG2617;cA{>~0wIRNaets++T}Q7iFe{V8~CPnbpQA*Q=;ZAz&R@0=gSGM*kl zR1k*{(*gzd_t$+qIE**~pVgc>gNir>ne^@Pys(1AvGCG=K8tQ~7tgEkD!r?itcsuS z%Z*l?jCe}6MJ7Be@LQj0UJO?*M6>(^DDI6tCf#@c%z%ipUP(%)@*UH5Mw6t0E9_ei Lp15C?{a-^(IKJ3_8cq)@ zh?O10=HYbGWj-Xz>!iVaNQn`|)?jtg-TZ<5AtRR}$YKzoy7@L|5F?uoh_AcZn7f*h z8_Z?{3ryb0qsFBLVuA>h%`bTv85!*+zvlhoYXA~81QA9c!URN^0trplBCuXNkbotK zumTZqPbz>wkvK@q8b-KI*5PkeRRVG~nTkMEkv0Ruz!*%eF#ZJMeHLVA UV)`HeWPKEwd{E#mi!;y&0NGb}p#T5? diff --git a/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc b/src/ollama_cli/tools/__pycache__/media.cpython-312.pyc index 9b0c8faccd93c6da6bf093fc505c0371e588fbda..3d5b16b00054566e36d35e0a7228d9680ff10b32 100644 GIT binary patch delta 5563 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 delta 3424 zcmZuyYit|G5#Bq#Ns*#-lt@vplVm+;QIA-OtjLlb$4+cZj%wIeQmYAMihHEbI`Zhf zqh+zjFlbyv=>xHG4J5-hA_GCAzy=b)Md9{OoBnBn^oL?4hN|X!;2j^JPKmMJIh3x?$VIP?&=pAUVqCy=``$$O2l+h|F^p)YFO3Tj9_@M zpu~xoQAIf&u^85LEF};R*ct9jjKD?}h@yYWyre(Lc5;L!wkURBArZ?;>h%lkO$+nx zb;+J%m~#DV$4{$$REYxBR{q2w^a^^&U!QXZS-r_qZU2K6q1QNlhv!#z7jhxhrT@;_ za1^_)xv#Qd0YA7XuLkL+>4yyXHFObOX6Yp)SPCE z6o3&bIW|*Si|cSbm^Og3MiY4xn@nuJ0!|9FXwHBA=(ofl{Y97PeO2OydYGBiOwSJ|7(+_(&{crwYp$|V^(z*)yroW$X zb6kSKPn>n;JuK2Zr3%ZJ;Rf6{pvho=EL%Yj_mkv4Lqdz;4X$QTaF;S zQ=|8?OLGH0^*RT*`?3T0Pt$uTo%D!;d=E&r>gB#rqJ|F#cMz+D8*eHYma=~W^k;x#x zM@}VX#}Dv|Ku!yU@5sFv;bWPM5F-k&%KVhTV|gas%^#A4m?H3s8Y3z%s(eBuiptA` zkIU(Vn9LG^S5FB1_~C=azyj?(MF}Xt3H(r&NIS28&DAqDLi<{`R%P{xsf>D;z9JEp&|)Js6v7nB0SrerJ)MSN^}E5=p*V0h ze|A`L@PF`IfH#?Wgc*z^lt&smxzQk-y0FkMwQTEnoyF{TyC~0SjKa*qBUMt@7*K93 zltxU64fL9&WPJohtYhX!=gMSBilt(caY>xq8et5}v@jd78`h*GPZ_MN7}iWoJz-c+ z%3|8Eq+(|bXHrn(s+bZY7BUHL4O>E#1jX>kSv8YYC#k+tpks>XUm;yoqXMb{Po{+# zK~X2gR4ggvB1s`FkeDhI_uWI19FIxL&hGzFeFHowy8ym<1+DpOS3`Agxh}ZgZhoiz zO8Yy}E77GN-WXeH9$g6?JZHNVtXn*KI~ZPwJn{C_JIO1_&)fPxY3u(qxP95b{Q>xZ zdD8%BxS+~15=;-Q`$8`iIu6jF_>Gug*6^pYLKX^G$;RV?q9n2s)Hkqm-fs6H^WOOb zdk6Vkzxz2kEvhn|TRcq)<{=GHek!Y))n|Szubhyx65gm{UIaIGUXkT=cP?NSeSuHN zBvq<>aBJba_*}@8Z3YDgnBE5YNQlt-A@pq|=I7X;#{@#?gJ{@EERE%q;h`vIQZUD(VN)bQ$mq9gng#}_O$&uB0Fi*% z1EiHQ=$lXI`$2Y47y>Yyn~lFa%*6~VjzO2wmLN$g_KA=)F(z%41R<2^PRLoP5?V|K zGij7TW2;$ZGJZmcPaD2?mJlJWmMrKCMiKxDJzoDZ%!QvaYmGRlgaQpjse?NFMlY{@ zsczkxU~GNsR*P$#y`&aji3<}gOe1EyJP5)zETVci1 zw#wDc51${_cg^jkRp-3mT&!IZS3(1G2Oio{ZT(yA7ur7$M?VQim!5xb;@yemfzjpX zCRe(SuY_ZBqYv#?@4&q>)X=k1-#a(d73)tdUbk=s?_g>Cw=`^e*MILCa|*z>+? zrE&0F*(%qt82yCnz7q(|j}*?2ES&nqbGKUiS6T9?Vkk(zS>}n zyzgFa+3{&$Xtie7@7*`F<@^` z?b`!4^Fe)Ze=qvr@n^aH!{|e&hhm_XVy|=mpyk5>hOP%K6o*|08Y~~#y$5P6A8`y_ z*Vq8{KR1&QEzHfQht>`;OZDt^vE?@oW?FCQ9A{$sY-b}gs$b}Q!SfpElR1FgR(*FT zr~9HUcFM~|%fhWJ zMdSqK(&PW>97@SJD+xQvJZ$L{Qo;;sLhBZWVb)N^8gi{6_Zli+L!LF{{Q^~gfm*+0 zZ2Ed{t?ixWGmFZ`;zs;_k@KL!!Ls-Krqqn~KeS$Tng9R* 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."