#!/usr/bin/env python3
import requests
import json
import subprocess
import os
import re
import sys
import threading
import queue
from pathlib import Path
OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "llama3.2" # Default model
AVAILABLE_MODELS = [] # Cache available models
CONFIG_FILE = os.path.expanduser("~/.ollama-cli-config.json")
SESSION_DIR = os.path.expanduser("~/.ollama-cli-sessions")
CURRENT_SESSION = None
SESSION_CONTEXT = {
"created_files": [],
"last_output": "",
"context_files": []
}
MCP_SERVERS = {} # Connected MCP servers
MCP_TOOLS = {} # Tools from MCP servers
LSP_SERVERS = {} # Language servers
KNOWLEDGE_BASE = {} # Simple knowledge store
SUBAGENTS = [] # Running subagents
NTFY_TOPIC = None # ntfy.sh topic for notifications
def load_config():
"""Load config file"""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
except:
pass
return {
"excluded_models": [],
"git": {
"remote_url": "",
"username": "",
"password": ""
},
"gitea": {
"url": "",
"token": ""
},
"auto_save_sessions": True,
"mcp_servers": {},
"ollama_url": "http://localhost:11434/api/chat",
"ntfy": {
"enabled": False,
"topic": "",
"server": "https://ntfy.sh"
}
}
def send_notification(title, message, priority="default"):
"""Send notification via ntfy.sh"""
config = load_config()
ntfy_config = config.get("ntfy", {})
if not ntfy_config.get("enabled") or not ntfy_config.get("topic"):
return
try:
server = ntfy_config.get("server", "https://ntfy.sh")
topic = ntfy_config["topic"]
url = f"{server}/{topic}"
requests.post(url,
data=message.encode('utf-8'),
headers={
"Title": title,
"Priority": priority
},
timeout=5
)
except:
pass # Silently fail
def ntfy_listener(messages_list):
"""Listen for ntfy notifications and process them"""
config = load_config()
ntfy_config = config.get("ntfy", {})
if not ntfy_config.get("enabled") or not ntfy_config.get("topic"):
return
server = ntfy_config.get("server", "https://ntfy.sh")
topic = ntfy_config["topic"]
url = f"{server}/{topic}/json"
try:
response = requests.get(url, stream=True, timeout=None)
for line in response.iter_lines():
if line:
data = json.loads(line)
if data.get("event") == "message":
title = data.get("title", "")
message = data.get("message", "")
# Skip messages sent by this script to avoid feedback loop
if title in ["Response", "Ollama CLI", "File Created", "Command Complete", "Test"]:
continue
# Process notification as a user request
print(f"\n[📱 Notification: {title}]")
print(f"> {message}\n")
messages_list.append({"role": "user", "content": message})
# Get response
selected_model = select_best_model(message, "initial")
response_text = call_ollama(messages_list, selected_model)
messages_list.append({"role": "assistant", "content": response_text})
# Extract clean output - remove tool calls and metadata
clean_output = re.sub(r'.*?', '', response_text, flags=re.DOTALL)
clean_output = '\n'.join([
line for line in clean_output.split('\n')
if line.strip() and not any(line.strip().startswith(p) for p in
['Result:', 'Output:', 'Exit code:', '[Executing', '[Result:', '[Using'])
])
# Send response back as notification
send_notification("Response", clean_output.strip()[:1000] if clean_output.strip() else response_text[:1000])
except:
pass
def save_config(config):
"""Save config file"""
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
def save_session(messages, session_name=None):
"""Save conversation session"""
os.makedirs(SESSION_DIR, exist_ok=True)
if not session_name:
from datetime import datetime
session_name = datetime.now().strftime("%Y%m%d_%H%M%S")
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
with open(session_file, 'w') as f:
json.dump({"messages": messages, "timestamp": session_name}, f, indent=2)
return session_file
def load_session(session_name):
"""Load conversation session"""
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
if os.path.exists(session_file):
with open(session_file, 'r') as f:
data = json.load(f)
return data.get("messages", [])
return None
def list_sessions():
"""List available sessions"""
if not os.path.exists(SESSION_DIR):
return []
sessions = [f.replace('.json', '') for f in os.listdir(SESSION_DIR) if f.endswith('.json')]
return sorted(sessions, reverse=True)
def connect_mcp_server(name, command, args=None):
"""Connect to an MCP server via stdio"""
try:
import subprocess
cmd = [command] + (args or [])
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
# Send initialize request
init_request = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "ollama-cli", "version": "2.0"}
}
}
process.stdin.write(json.dumps(init_request) + "\n")
process.stdin.flush()
# Read response
response = process.stdout.readline()
if response:
data = json.loads(response)
if "result" in data:
MCP_SERVERS[name] = process
# List tools
tools_request = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}
process.stdin.write(json.dumps(tools_request) + "\n")
process.stdin.flush()
tools_response = process.stdout.readline()
if tools_response:
tools_data = json.loads(tools_response)
if "result" in tools_data and "tools" in tools_data["result"]:
for tool in tools_data["result"]["tools"]:
tool_name = f"mcp_{name}_{tool['name']}"
MCP_TOOLS[tool_name] = {
"server": name,
"name": tool["name"],
"description": tool.get("description", ""),
"schema": tool.get("inputSchema", {})
}
return f"Connected to MCP server '{name}' with {len([t for t in MCP_TOOLS if t.startswith(f'mcp_{name}_')])} tools"
return f"Failed to initialize MCP server '{name}'"
except Exception as e:
return f"Error connecting to MCP server: {str(e)}"
def call_mcp_tool(server_name, tool_name, arguments):
"""Call a tool on an MCP server"""
try:
if server_name not in MCP_SERVERS:
return f"Error: MCP server '{server_name}' not connected"
process = MCP_SERVERS[server_name]
request = {
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
}
}
process.stdin.write(json.dumps(request) + "\n")
process.stdin.flush()
response = process.stdout.readline()
if response:
data = json.loads(response)
if "result" in data:
return json.dumps(data["result"], indent=2)
elif "error" in data:
return f"MCP Error: {data['error']}"
return "No response from MCP server"
except Exception as e:
return f"Error calling MCP tool: {str(e)}"
# === LSP SUPPORT ===
def start_lsp_server(language, root_path="."):
"""Start a language server"""
lsp_commands = {
"python": ["pyright-langserver", "--stdio"],
"javascript": ["typescript-language-server", "--stdio"],
"typescript": ["typescript-language-server", "--stdio"],
"rust": ["rust-analyzer"],
"go": ["gopls"],
}
if language not in lsp_commands:
return f"No LSP server configured for {language}"
try:
process = subprocess.Popen(
lsp_commands[language],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=os.path.expanduser(root_path)
)
# Initialize
init_request = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": os.getpid(),
"rootUri": f"file://{os.path.abspath(os.path.expanduser(root_path))}",
"capabilities": {}
}
}
msg = json.dumps(init_request)
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
process.stdin.flush()
LSP_SERVERS[language] = process
return f"Started LSP server for {language}"
except Exception as e:
return f"Error starting LSP: {str(e)}"
def lsp_goto_definition(language, file_path, line, character):
"""Get definition location"""
if language not in LSP_SERVERS:
return "LSP server not started"
try:
process = LSP_SERVERS[language]
request = {
"jsonrpc": "2.0",
"id": 2,
"method": "textDocument/definition",
"params": {
"textDocument": {"uri": f"file://{os.path.abspath(file_path)}"},
"position": {"line": line, "character": character}
}
}
msg = json.dumps(request)
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
process.stdin.flush()
# Read response (simplified)
return "Definition lookup sent (response parsing not implemented)"
except Exception as e:
return f"LSP error: {str(e)}"
def lsp_find_references(language, file_path, line, character):
"""Find all references to symbol"""
if language not in LSP_SERVERS:
return "LSP server not started"
try:
process = LSP_SERVERS[language]
request = {
"jsonrpc": "2.0",
"id": 3,
"method": "textDocument/references",
"params": {
"textDocument": {"uri": f"file://{os.path.abspath(file_path)}"},
"position": {"line": line, "character": character},
"context": {"includeDeclaration": True}
}
}
msg = json.dumps(request)
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
process.stdin.flush()
return "References lookup sent"
except Exception as e:
return f"LSP error: {str(e)}"
# === WEB SEARCH (Enhanced) ===
def web_search_enhanced(query):
"""Enhanced web search with DuckDuckGo"""
try:
import urllib.parse
encoded = urllib.parse.quote(query)
# Use DuckDuckGo Instant Answer API
api_url = f"https://api.duckduckgo.com/?q={encoded}&format=json"
response = requests.get(api_url, timeout=10)
data = response.json()
results = []
if data.get("AbstractText"):
results.append(f"Summary: {data['AbstractText']}")
for topic in data.get("RelatedTopics", [])[:5]:
if isinstance(topic, dict) and "Text" in topic:
results.append(f"- {topic['Text']}")
return "\n".join(results) if results else "No results found"
except Exception as e:
return f"Search error: {str(e)}"
# === KNOWLEDGE BASE ===
def kb_add(key, content):
"""Add to knowledge base"""
KNOWLEDGE_BASE[key] = {
"content": content,
"timestamp": subprocess.run("date", capture_output=True, text=True).stdout.strip()
}
return f"Added '{key}' to knowledge base"
def kb_search(query):
"""Search knowledge base (simple keyword match)"""
results = []
query_lower = query.lower()
for key, data in KNOWLEDGE_BASE.items():
if query_lower in key.lower() or query_lower in data["content"].lower():
results.append(f"{key}: {data['content'][:200]}...")
return "\n".join(results) if results else "No matches found"
def kb_get(key):
"""Get from knowledge base"""
if key in KNOWLEDGE_BASE:
return KNOWLEDGE_BASE[key]["content"]
return f"Key '{key}' not found"
# === SUBAGENTS ===
def spawn_subagent(task, model=None):
"""Spawn a subagent to handle a task"""
try:
agent_id = len(SUBAGENTS) + 1
# Create a simple prompt for the subagent
messages = [
{"role": "system", "content": "You are a helpful assistant. Complete the task concisely."},
{"role": "user", "content": task}
]
# Run in thread
result_queue = queue.Queue()
def run_agent():
try:
response = call_ollama(messages, model)
result_queue.put({"success": True, "result": response})
except Exception as e:
result_queue.put({"success": False, "error": str(e)})
thread = threading.Thread(target=run_agent, daemon=True)
thread.start()
agent = {
"id": agent_id,
"task": task,
"thread": thread,
"queue": result_queue,
"status": "running"
}
SUBAGENTS.append(agent)
return f"Spawned subagent #{agent_id} for task: {task[:50]}..."
except Exception as e:
return f"Error spawning subagent: {str(e)}"
def check_subagents():
"""Check status of subagents"""
results = []
for agent in SUBAGENTS:
if not agent["queue"].empty():
result = agent["queue"].get()
agent["status"] = "completed"
agent["result"] = result
results.append(f"Agent #{agent['id']}: {result.get('result', result.get('error'))[:200]}")
elif agent["thread"].is_alive():
results.append(f"Agent #{agent['id']}: Still running...")
else:
results.append(f"Agent #{agent['id']}: Completed")
return "\n".join(results) if results else "No active subagents"
# === ADVANCED CODE TOOLS ===
def code_search_symbol(symbol_name, path="."):
"""Search for symbol definitions"""
try:
# Simple grep-based search
result = subprocess.run(
f'grep -rn "def {symbol_name}\\|class {symbol_name}\\|function {symbol_name}" {path}',
shell=True, capture_output=True, text=True
)
return result.stdout if result.stdout else f"Symbol '{symbol_name}' not found"
except Exception as e:
return f"Error: {str(e)}"
def code_analyze_file(file_path):
"""Analyze code structure and suggest improvements"""
try:
with open(os.path.expanduser(file_path), 'r') as f:
content = f.read()
lines = content.split('\n')
functions = [l for l in lines if 'def ' in l]
classes = [l for l in lines if 'class ' in l]
# Analyze issues
issues = []
if len(lines) > 500:
issues.append(f"Large file ({len(lines)} lines) - consider splitting into modules")
if len(functions) > 30:
issues.append(f"Many functions ({len(functions)}) - consider organizing into classes")
bare_excepts = sum(1 for l in lines if l.strip() == 'except:')
if bare_excepts > 0:
issues.append(f"{bare_excepts} bare except clauses - should catch specific exceptions")
todos = sum(1 for l in lines if 'TODO' in l or 'FIXME' in l)
if todos > 0:
issues.append(f"{todos} TODO/FIXME comments")
long_funcs = []
in_func = False
func_lines = 0
func_name = ""
for line in lines:
if line.strip().startswith('def '):
if in_func and func_lines > 50:
long_funcs.append(f"{func_name} ({func_lines} lines)")
in_func = True
func_lines = 0
func_name = line.strip().split('(')[0].replace('def ', '')
elif in_func:
func_lines += 1
if long_funcs:
issues.append(f"Long functions: {', '.join(long_funcs[:3])}")
return f"""File: {file_path}
Lines: {len(lines)} | Functions: {len(functions)} | Classes: {len(classes)}
Issues found:
{chr(10).join(f'- {i}' for i in issues) if issues else '- No major issues detected'}
Suggestions:
- Add type hints for better code clarity
- Add docstrings to undocumented functions
- Consider using logging instead of print statements
- Run a linter (pylint/flake8) for detailed analysis"""
except Exception as e:
return f"Error: {str(e)}"
# === DOCUMENTATION GENERATION ===
def generate_readme(path="."):
"""Generate README.md from project structure"""
try:
project_name = os.path.basename(os.path.abspath(path))
# Analyze project
files = []
for root, dirs, filenames in os.walk(path):
# Skip common ignore dirs
dirs[:] = [d for d in dirs if d not in ['.git', 'node_modules', '__pycache__', 'venv']]
for f in filenames:
if f.endswith(('.py', '.js', '.ts', '.go', '.rs')):
files.append(os.path.join(root, f))
# Detect language
extensions = [os.path.splitext(f)[1] for f in files]
main_lang = max(set(extensions), key=extensions.count) if extensions else '.txt'
lang_map = {'.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.go': 'Go', '.rs': 'Rust'}
language = lang_map.get(main_lang, 'Unknown')
readme = f"""# {project_name}
## Description
A {language} project with {len(files)} source files.
## Project Structure
```
{chr(10).join([f'- {os.path.relpath(f, path)}' for f in files[:10]])}
{'...' if len(files) > 10 else ''}
```
## Installation
```bash
# Add installation instructions here
```
## Usage
```bash
# Add usage examples here
```
## License
MIT
"""
readme_path = os.path.join(path, "README.md")
with open(readme_path, 'w') as f:
f.write(readme)
return f"Generated README.md at {readme_path}"
except Exception as e:
return f"Error generating README: {str(e)}"
def generate_docstrings(file_path):
"""Generate docstrings for functions in a file"""
try:
with open(os.path.expanduser(file_path), 'r') as f:
lines = f.readlines()
new_lines = []
i = 0
while i < len(lines):
line = lines[i]
# Detect function definition
if line.strip().startswith('def ') and ':' in line:
# Check if next line is already a docstring
if i + 1 < len(lines) and '"""' not in lines[i + 1]:
# Extract function name
func_name = line.strip().split('(')[0].replace('def ', '')
indent = len(line) - len(line.lstrip())
docstring = f'{" " * (indent + 4)}"""TODO: Document {func_name}"""\n'
new_lines.append(line)
new_lines.append(docstring)
i += 1
continue
new_lines.append(line)
i += 1
# Write back
with open(os.path.expanduser(file_path), 'w') as f:
f.writelines(new_lines)
return f"Added docstring placeholders to {file_path}"
except Exception as e:
return f"Error: {str(e)}"
# === CODE QUALITY TOOLS ===
def run_linter(file_path, linter="auto"):
"""Run linter on file"""
try:
file_path = os.path.expanduser(file_path)
ext = os.path.splitext(file_path)[1]
# Auto-detect linter
if linter == "auto":
linter_map = {
'.py': 'pylint',
'.js': 'eslint',
'.ts': 'eslint',
'.go': 'golint',
'.rs': 'cargo clippy'
}
linter = linter_map.get(ext, 'pylint')
# Run linter
result = subprocess.run(
f"{linter} {file_path}",
shell=True, capture_output=True, text=True, timeout=30
)
return result.stdout + result.stderr
except Exception as e:
return f"Error running linter: {str(e)}"
def format_code(file_path, formatter="auto"):
"""Format code file"""
try:
file_path = os.path.expanduser(file_path)
ext = os.path.splitext(file_path)[1]
# Auto-detect formatter
if formatter == "auto":
formatter_map = {
'.py': 'black',
'.js': 'prettier --write',
'.ts': 'prettier --write',
'.go': 'gofmt -w',
'.rs': 'rustfmt'
}
formatter = formatter_map.get(ext, 'cat')
# Run formatter
result = subprocess.run(
f"{formatter} {file_path}",
shell=True, capture_output=True, text=True, timeout=30
)
return f"Formatted {file_path}\n{result.stdout + result.stderr}"
except Exception as e:
return f"Error formatting: {str(e)}"
def check_security(path="."):
"""Run security checks"""
try:
# Try bandit for Python
result = subprocess.run(
f"bandit -r {path} 2>&1 || echo 'Install: pip install bandit'",
shell=True, capture_output=True, text=True, timeout=60
)
return result.stdout
except Exception as e:
return f"Error: {str(e)}"
# === PROJECT TEMPLATES ===
def create_project(name, template="python"):
"""Create new project from template"""
try:
project_path = os.path.expanduser(f"./{name}")
os.makedirs(project_path, exist_ok=True)
templates = {
"python": {
"main.py": "#!/usr/bin/env python3\n\ndef main():\n print('Hello, World!')\n\nif __name__ == '__main__':\n main()\n",
"requirements.txt": "# Add dependencies here\n",
"README.md": f"# {name}\n\nA Python project.\n",
".gitignore": "__pycache__/\n*.pyc\nvenv/\n.env\n"
},
"javascript": {
"index.js": "console.log('Hello, World!');\n",
"package.json": f'{{"name": "{name}", "version": "1.0.0", "main": "index.js"}}',
"README.md": f"# {name}\n\nA JavaScript project.\n",
".gitignore": "node_modules/\n.env\n"
},
"flask": {
"app.py": "from flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/')\ndef hello():\n return 'Hello, World!'\n\nif __name__ == '__main__':\n app.run(debug=True)\n",
"requirements.txt": "flask\n",
"README.md": f"# {name}\n\nA Flask web application.\n",
".gitignore": "__pycache__/\nvenv/\n*.pyc\n"
},
"express": {
"server.js": "const express = require('express');\nconst app = express();\n\napp.get('/', (req, res) => {\n res.send('Hello, World!');\n});\n\napp.listen(3000, () => console.log('Server running on port 3000'));\n",
"package.json": f'{{"name": "{name}", "version": "1.0.0", "main": "server.js", "dependencies": {{"express": "^4.18.0"}}}}',
"README.md": f"# {name}\n\nAn Express.js application.\n",
".gitignore": "node_modules/\n.env\n"
}
}
if template not in templates:
return f"Unknown template: {template}. Available: {', '.join(templates.keys())}"
# Create files
for filename, content in templates[template].items():
filepath = os.path.join(project_path, filename)
with open(filepath, 'w') as f:
f.write(content)
# Initialize git
subprocess.run("git init", shell=True, cwd=project_path, capture_output=True)
return f"Created {template} project at {project_path}\nFiles: {', '.join(templates[template].keys())}"
except Exception as e:
return f"Error creating project: {str(e)}"
# Model selection based on task type
MODEL_PREFERENCES = {
"code": ["qwen3-coder:30b", "mistral:latest", "llama3:latest", "deepseek-coder-v2:16b", "granite-code:latest"],
"general": ["mistral:latest", "llama3:latest", "llama3.2:latest"],
"fast": ["llama3.2:3b", "llama3.2:latest"],
}
def get_available_models():
"""Get list of models available in Ollama"""
config = load_config()
excluded = config.get("excluded_models", [])
try:
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():
model_name = line.split()[0]
# Skip base models, tiny models, and excluded models
if ('base' not in model_name.lower() and
':1b' not in model_name.lower() and
model_name not in excluded):
models.append(model_name)
return models
except:
return []
def select_best_model(user_input, task_type="initial"):
"""Select best model based on user input and task type"""
if not AVAILABLE_MODELS:
return MODEL
# For tool execution, prefer fast general models
if task_type == "tool_response":
for model in MODEL_PREFERENCES["general"]:
if any(model in m for m in AVAILABLE_MODELS):
return next(m for m in AVAILABLE_MODELS if model in m)
# Detect task type from user input
code_keywords = ['code', 'script', 'program', 'function', 'debug', 'python', 'javascript', 'write a', 'create a']
if any(keyword in user_input.lower() for keyword in code_keywords):
# Prefer code models
for model in MODEL_PREFERENCES["code"]:
if any(model in m for m in AVAILABLE_MODELS):
return next(m for m in AVAILABLE_MODELS if model in m)
# Default to general models
for model in MODEL_PREFERENCES["general"]:
if any(model in m for m in AVAILABLE_MODELS):
return next(m for m in AVAILABLE_MODELS if model in m)
# Fallback to first available
return AVAILABLE_MODELS[0] if AVAILABLE_MODELS else MODEL
def execute_bash(command):
"""Execute bash command and return output"""
# Check for dangerous commands
dangerous = ['rm -rf', 'dd if=', 'mkfs', '> /dev/', 'format', 'sudo rm', ':(){:|:&};:']
if any(d in command.lower() for d in dangerous):
return f"⚠️ DANGEROUS COMMAND BLOCKED: {command}\nThis command could cause data loss. If you really need to run it, do so manually."
try:
result = subprocess.run(command, shell=True, capture_output=True, text=True,
cwd=os.getcwd(), timeout=30)
output = result.stdout + result.stderr
return f"Exit code: {result.returncode}\n{output}" if output else f"Exit code: {result.returncode} (no output)"
except subprocess.TimeoutExpired:
return "Error: Command timed out after 30 seconds"
except Exception as e:
return f"Error: {str(e)}"
def read_file(path):
"""Read file contents"""
try:
with open(os.path.expanduser(path), 'r') as f:
return f.read()
except Exception as e:
return f"Error reading file: {str(e)}"
def write_file(path, content):
"""Write content to file"""
try:
expanded_path = os.path.expanduser(path)
# For Python files, try to validate and fix basic issues
if path.endswith('.py'):
# Try to compile to check for syntax errors
try:
compile(content, expanded_path, 'exec')
except SyntaxError as e:
return f"Error: Generated Python code has syntax error at line {e.lineno}: {e.msg}"
os.makedirs(os.path.dirname(expanded_path), exist_ok=True)
with open(expanded_path, 'w') as f:
f.write(content)
# Track created file
SESSION_CONTEXT["created_files"].append(expanded_path)
return f"Successfully written to {path}"
except Exception as e:
return f"Error writing file: {str(e)}"
def edit_file(path, old_str, new_str):
"""Edit file by replacing old_str with new_str"""
try:
expanded_path = os.path.expanduser(path)
with open(expanded_path, 'r') as f:
content = f.read()
if old_str not in content:
return f"Error: Could not find the specified text in {path}"
new_content = content.replace(old_str, new_str, 1)
# Validate Python syntax if applicable
if path.endswith('.py'):
try:
compile(new_content, expanded_path, 'exec')
except SyntaxError as e:
return f"Error: Edited code has syntax error at line {e.lineno}: {e.msg}"
with open(expanded_path, 'w') as f:
f.write(new_content)
return f"Successfully edited {path}"
except Exception as e:
return f"Error editing file: {str(e)}"
def append_file(path, content):
"""Append content to file"""
try:
expanded_path = os.path.expanduser(path)
with open(expanded_path, 'a') as f:
f.write(content)
return f"Successfully appended to {path}"
except Exception as e:
return f"Error appending to file: {str(e)}"
def search_files(pattern, path="."):
"""Search for pattern in files"""
try:
result = subprocess.run(
f'grep -r "{pattern}" {path}',
shell=True, capture_output=True, text=True, timeout=10
)
return result.stdout if result.stdout else "No matches found"
except Exception as e:
return f"Error searching: {str(e)}"
def git_operation(operation, message=None, **kwargs):
"""Perform git operations"""
config = load_config()
git_config = config.get("git", {})
gitea_config = config.get("gitea", {})
try:
if operation == "status":
result = subprocess.run("git status", shell=True, capture_output=True, text=True)
elif operation == "diff":
result = subprocess.run("git diff", shell=True, capture_output=True, text=True)
elif operation == "commit":
msg = message or kwargs.get("message", "Auto commit")
result = subprocess.run(f'git add -A && git commit -m "{msg}"',
shell=True, capture_output=True, text=True)
elif operation == "push":
remote = git_config.get("remote_url", "origin")
username = git_config.get("username", "")
password = git_config.get("password", "")
if username and password:
# Inject credentials into URL
result = subprocess.run(f"git push {remote}",
shell=True, capture_output=True, text=True)
else:
result = subprocess.run("git push", shell=True, capture_output=True, text=True)
elif operation == "pull":
result = subprocess.run("git pull", shell=True, capture_output=True, text=True)
elif operation == "list":
# Check if Gitea is configured
if gitea_config.get("url") and gitea_config.get("token"):
# Query Gitea API
gitea_url = gitea_config["url"].rstrip("/")
token = gitea_config["token"]
try:
response = requests.get(
f"{gitea_url}/api/v1/user/repos",
headers={"Authorization": f"token {token}"},
timeout=10,
verify=False # Skip SSL verification for localhost
)
if response.status_code == 200:
repos = response.json()
result_text = "Repositories on Gitea server:\n"
for repo in repos:
result_text += f"- {repo['full_name']} ({repo['clone_url']})\n"
return result_text
else:
return f"Gitea API error: {response.status_code}"
except Exception as e:
return f"Error querying Gitea: {str(e)}"
else:
# Fallback to local search
result = subprocess.run("find . -maxdepth 2 -name '.git' -type d | sed 's|/.git||'",
shell=True, capture_output=True, text=True)
else:
return f"Unknown git operation: {operation}"
return result.stdout + result.stderr
except Exception as e:
return f"Git error: {str(e)}"
def install_package(package, manager="pip"):
"""Install package using pip or npm"""
try:
if manager == "pip":
result = subprocess.run(f"pip install {package}",
shell=True, capture_output=True, text=True, timeout=60)
elif manager == "npm":
result = subprocess.run(f"npm install {package}",
shell=True, capture_output=True, text=True, timeout=60)
else:
return f"Unknown package manager: {manager}"
return result.stdout + result.stderr
except Exception as e:
return f"Error installing package: {str(e)}"
def web_search(query):
"""Simple web search using curl"""
try:
import urllib.parse
encoded_query = urllib.parse.quote(query)
result = subprocess.run(
f'curl -s "https://html.duckduckgo.com/html/?q={encoded_query}"',
shell=True, capture_output=True, text=True, timeout=10
)
# Extract snippets (very basic)
content = result.stdout[:3000]
return f"Search results for '{query}':\n{content}"
except Exception as e:
return f"Error searching: {str(e)}"
def list_directory(path="."):
"""List directory contents"""
try:
expanded = os.path.expanduser(path)
files = os.listdir(expanded)
return "\n".join(files) if files else "Directory is empty"
except Exception as e:
return f"Error listing directory: {str(e)}"
def get_file_info(path):
"""Get file metadata"""
try:
p = os.path.expanduser(path)
stat = os.stat(p)
import time
return f"Path: {p}\nSize: {stat.st_size} bytes\nModified: {time.ctime(stat.st_mtime)}\nPermissions: {oct(stat.st_mode)}"
except Exception as e:
return f"Error getting file info: {str(e)}"
TOOLS = {
"execute_bash": execute_bash,
"read_file": read_file,
"write_file": write_file,
"edit_file": edit_file,
"append_file": append_file,
"web_search": web_search,
"web_search_enhanced": web_search_enhanced,
"list_directory": list_directory,
"get_file_info": get_file_info,
"search_files": search_files,
"git_operation": git_operation,
"install_package": install_package,
"lsp_goto_definition": lsp_goto_definition,
"lsp_find_references": lsp_find_references,
"kb_add": kb_add,
"kb_search": kb_search,
"kb_get": kb_get,
"spawn_subagent": spawn_subagent,
"check_subagents": check_subagents,
"code_search_symbol": code_search_symbol,
"code_analyze_file": code_analyze_file,
"generate_readme": generate_readme,
"generate_docstrings": generate_docstrings,
"run_linter": run_linter,
"format_code": format_code,
"check_security": check_security,
"create_project": create_project,
}
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.
WHEN TO USE TOOLS:
- User says "create a FILE/SCRIPT" → use write_file
- User says "edit/modify FILE" → use edit_file
- User says "add to FILE" → use append_file
- User asks to READ a file → use read_file
- User asks to RUN/EXECUTE a command → use execute_bash
- User asks to LIST files/directories → use list_directory
- User asks to LIST GIT PROJECTS/REPOS → use git_operation with operation="list"
- User asks to SEARCH in files → use search_files
- User asks about GIT (status, commit, push, pull) → use git_operation
- User asks to INSTALL package → use install_package
- User asks to SEARCH the web → use web_search_enhanced
- User asks about code definitions/references → use lsp_goto_definition, lsp_find_references
- User wants to save/retrieve knowledge → use kb_add, kb_search, kb_get
- User has complex multi-step task → use spawn_subagent
- User wants code analysis → use code_search_symbol, code_analyze_file
- User wants documentation → use generate_readme, generate_docstrings
- User wants code quality checks → use run_linter, format_code, check_security
- User wants to create new project → use create_project
WHEN NOT TO USE TOOLS (just answer normally):
- "how do I make FOOD" → answer with recipe
- "how to do X" → answer with instructions
- "what is X" → answer with explanation
- General questions about cooking, advice, information
Available tools:
- write_file, edit_file, append_file, read_file
- execute_bash, list_directory, get_file_info, search_files
- git_operation, install_package
- web_search_enhanced: Better web search with API
- lsp_goto_definition: Find symbol definition. Params: {"language": "python", "file_path": "file.py", "line": 0, "character": 0}
- lsp_find_references: Find all references. Params: {"language": "python", "file_path": "file.py", "line": 0, "character": 0}
- kb_add: Add to knowledge base. Params: {"key": "string", "content": "string"}
- kb_search: Search knowledge base. Params: {"query": "string"}
- kb_get: Get from knowledge base. Params: {"key": "string"}
- spawn_subagent: Spawn parallel task. Params: {"task": "string", "model": "optional"}
- check_subagents: Check subagent status
- code_search_symbol: Find symbol in code. Params: {"symbol_name": "string", "path": "."}
- code_analyze_file: Analyze code structure. Params: {"file_path": "string"}
- generate_readme: Generate README.md. Params: {"path": "."}
- generate_docstrings: Add docstrings to file. Params: {"file_path": "string"}
- run_linter: Run linter. Params: {"file_path": "string", "linter": "auto|pylint|eslint"}
- format_code: Format code. Params: {"file_path": "string", "formatter": "auto|black|prettier"}
- check_security: Security scan. Params: {"path": "."}
- create_project: Create project from template. Params: {"name": "string", "template": "python|javascript|flask|express"}
MCP Tools: {mcp_tools_list}
Tool call format:
write_file
{"path": "~/file.py", "content": "code here"}
Output ONLY the tool call when using a tool. Use \\n for newlines, proper indentation (4 spaces for Python)."""
def extract_code_blocks(text):
"""Extract code blocks from markdown"""
pattern = r'```(\w+)?\n(.*?)```'
matches = re.findall(pattern, text, re.DOTALL)
return [(lang, code.strip()) for lang, code in matches]
def auto_save_code(response, user_input):
"""Automatically save code blocks if user asked to create a file"""
create_keywords = ['create', 'write', 'make', 'generate', 'build']
file_keywords = ['script', 'file', 'program', '.py', '.js', '.html', '.sh']
should_save = any(k in user_input.lower() for k in create_keywords) and \
any(k in user_input.lower() for k in file_keywords)
if not should_save:
return None
code_blocks = extract_code_blocks(response)
if not code_blocks:
return None
# Get the largest code block (likely the main code)
lang, code = max(code_blocks, key=lambda x: len(x[1]))
if not code:
return None
# Determine filename from language or user input
extensions = {
'python': '.py',
'javascript': '.js',
'html': '.html',
'bash': '.sh',
'sh': '.sh',
}
# Try to extract filename from user input
words = user_input.lower().split()
filename = None
for i, word in enumerate(words):
if any(ext in word for ext in ['.py', '.js', '.html', '.sh']):
filename = word
break
if word in ['called', 'named'] and i + 1 < len(words):
filename = words[i + 1]
break
if not filename:
# Generate filename based on language
ext = extensions.get(lang.lower(), '.txt')
if 'calculator' in user_input.lower():
filename = f'calculator{ext}'
elif 'script' in user_input.lower():
filename = f'script{ext}'
else:
filename = f'output{ext}'
if not filename:
return None
return filename, code
def parse_tool_calls(text):
"""Extract tool calls from model response"""
calls = []
# Try standard format first
pattern = r'\s*(.*?)\s*(.*?)\s*'
matches = re.findall(pattern, text, re.DOTALL)
# Try DeepSeek format: <|tool▁call▁begin|>function<|tool▁sep|>TOOL_NAME\n```json\n{...}\n```
if not matches:
ds_pattern = r'<|tool▁call▁begin|>function<|tool▁sep|>(\w+)\s*```json\s*(\{.*?\})\s*```'
ds_matches = re.findall(ds_pattern, text, re.DOTALL)
if ds_matches:
matches = ds_matches
for name, params in matches:
params_str = params.strip()
name = name.strip()
# Try JSON first
try:
calls.append((name, json.loads(params_str)))
continue
except json.JSONDecodeError:
pass
# Try ast.literal_eval (handles Python literals)
try:
import ast
calls.append((name, ast.literal_eval(params_str)))
continue
except:
pass
# Try fixing common issues: literal newlines in JSON strings
try:
# Replace literal newlines with \n escape sequence
fixed = params_str.replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t')
calls.append((name, json.loads(fixed)))
continue
except:
pass
print(f"Warning: Failed to parse parameters for {name}")
print(f"Raw params: {params_str[:100]}...")
return calls
def call_ollama(messages, model=None):
"""Call Ollama API with streaming"""
config = load_config()
ollama_url = config.get("ollama_url", OLLAMA_URL)
selected_model = model or MODEL
try:
response = requests.post(ollama_url, json={
"model": selected_model,
"messages": messages,
"stream": True
}, stream=True, timeout=60)
response.raise_for_status()
full_response = ""
for line in response.iter_lines():
if line:
chunk = json.loads(line)
if "message" in chunk and "content" in chunk["message"]:
content = chunk["message"]["content"]
print(content, end="", flush=True)
full_response += content
print() # Newline after streaming
return full_response
except requests.exceptions.ConnectionError:
print("\nError: Cannot connect to Ollama. Make sure it's running with 'ollama serve'")
sys.exit(1)
except Exception as e:
return f"Error calling Ollama: {str(e)}"
def trim_messages(messages, max_messages=20):
"""Keep only recent messages to avoid context overflow"""
if len(messages) > max_messages:
# Keep system prompt + last (max_messages - 1) messages
return [messages[0]] + messages[-(max_messages-1):]
return messages
def main():
global AVAILABLE_MODELS, CURRENT_SESSION, MODEL
AVAILABLE_MODELS = get_available_models()
print(f"Ollama CLI v2.0 - Default model: {MODEL}")
config = load_config()
ollama_url = config.get("ollama_url", OLLAMA_URL)
print(f"Server: {ollama_url}")
if AVAILABLE_MODELS:
print(f"Available models: {', '.join(AVAILABLE_MODELS)}")
else:
print("Warning: Could not detect available models, using default")
config = load_config()
if config.get("excluded_models"):
print(f"Excluded models: {', '.join(config['excluded_models'])}")
print(f"Current directory: {os.getcwd()}")
print(f"Home directory: {os.path.expanduser('~')}")
print("\nCommands:")
print(" /quit - Exit")
print(" /clear - Clear history")
print(" /save [name] - Save session")
print(" /load - Load session")
print(" /sessions - List sessions")
print(" /model - Switch model")
print(" /models - List models")
print(" /exclude - Exclude model")
print(" /retry - Retry last request")
print(" /run - Execute file")
print(" /config git - Configure git")
print(" /config gitea - Configure Gitea server")
print(" /export - Export to markdown")
print(" /mcp connect [args] - Connect MCP server")
print(" /mcp list - List MCP servers and tools")
print(" /mcp disconnect - Disconnect MCP server")
print(" /context - Add file to context")
print(" /context list - List context files")
print(" /context clear - Clear context files")
print(" /lsp start [path] - Start LSP server")
print(" /kb add - Add to knowledge base")
print(" /kb search - Search knowledge base")
print(" /agents - Check subagent status")
print(" /server - Set Ollama server URL")
print(" /notify setup - Enable ntfy.sh notifications")
print(" /notify off - Disable notifications")
print(" /notify test - Send test notification")
print("\nWhen notifications are enabled, send messages to your topic to interact remotely!\n")
# Add system context to initial prompt
system_context = f"""Operating System: {sys.platform}
Current Directory: {os.getcwd()}
Home Directory: {os.path.expanduser('~')}
User: {os.environ.get('USER', 'unknown')}
{SYSTEM_PROMPT}"""
# Add MCP tools to system prompt
if MCP_TOOLS:
mcp_tools_desc = "\n".join([f"- {name}: {tool['description']}" for name, tool in MCP_TOOLS.items()])
system_context = system_context.replace("{mcp_tools_list}", mcp_tools_desc)
else:
system_context = system_context.replace("{mcp_tools_list}", "None connected")
messages = [{"role": "system", "content": system_context}]
# Auto-connect MCP servers from config
config = load_config()
for name, server_config in config.get("mcp_servers", {}).items():
result = connect_mcp_server(name, server_config["command"], server_config.get("args"))
print(f"[{result}]")
# Start ntfy listener if enabled
if config.get("ntfy", {}).get("enabled"):
topic = config["ntfy"]["topic"]
print(f"[Starting ntfy listener for topic: {topic}]")
listener_thread = threading.Thread(target=ntfy_listener, args=(messages,), daemon=True)
listener_thread.start()
while True:
try:
user_input = input("\n> ").strip()
except (EOFError, KeyboardInterrupt):
print("\nGoodbye!")
break
if not user_input:
continue
if user_input.lower() in ['/quit', 'exit']:
print("Goodbye!")
break
if user_input.lower() == '/clear':
system_context = f"""Operating System: {sys.platform}
Current Directory: {os.getcwd()}
Home Directory: {os.path.expanduser('~')}
User: {os.environ.get('USER', 'unknown')}
{SYSTEM_PROMPT}"""
messages = [{"role": "system", "content": system_context}]
print("History cleared.")
continue
if user_input.lower() == '/save':
try:
with open('chat_history.json', 'w') as f:
json.dump(messages, f, indent=2)
print("History saved to chat_history.json")
except Exception as e:
print(f"Error saving history: {e}")
continue
if user_input.lower().startswith('/exclude '):
model_to_exclude = user_input[9:].strip()
config = load_config()
if model_to_exclude not in config["excluded_models"]:
config["excluded_models"].append(model_to_exclude)
save_config(config)
print(f"Excluded {model_to_exclude}. Restart to apply changes.")
else:
print(f"{model_to_exclude} is already excluded.")
continue
if user_input.lower() == '/list-excluded':
config = load_config()
excluded = config.get("excluded_models", [])
if excluded:
print(f"Excluded models: {', '.join(excluded)}")
else:
print("No models excluded.")
continue
if user_input.lower().startswith('/save'):
parts = user_input.split(maxsplit=1)
session_name = parts[1] if len(parts) > 1 else None
session_file = save_session(messages, session_name)
print(f"Session saved to {session_file}")
continue
if user_input.lower().startswith('/load '):
session_name = user_input[6:].strip()
loaded = load_session(session_name)
if loaded:
messages = loaded
print(f"Session '{session_name}' loaded.")
else:
print(f"Session '{session_name}' not found.")
continue
if user_input.lower() == '/sessions':
sessions = list_sessions()
if sessions:
print("Available sessions:")
for s in sessions[:10]: # Show last 10
print(f" {s}")
else:
print("No saved sessions.")
continue
if user_input.lower().startswith('/model '):
new_model = user_input[7:].strip()
if new_model in AVAILABLE_MODELS:
MODEL = new_model
print(f"Switched to model: {MODEL}")
else:
print(f"Model '{new_model}' not available.")
continue
if user_input.lower() == '/models':
print("Available models:")
for m in AVAILABLE_MODELS:
print(f" {m}")
continue
if user_input.lower() == '/retry':
if len(messages) > 1:
# Remove last assistant response and retry
if messages[-1]["role"] == "assistant":
messages.pop()
user_input = messages[-1]["content"] if messages[-1]["role"] == "user" else ""
if not user_input:
print("Nothing to retry.")
continue
messages.pop() # Remove user message, will be re-added
else:
print("Nothing to retry.")
continue
if user_input.lower().startswith('/run '):
filepath = user_input[5:].strip()
result = execute_bash(f"python3 {filepath}" if filepath.endswith('.py') else filepath)
print(result)
continue
if user_input.lower().startswith('/config git '):
parts = user_input[12:].split()
if len(parts) >= 3:
config = load_config()
config["git"] = {
"remote_url": parts[0],
"username": parts[1],
"password": parts[2]
}
save_config(config)
print("Git configuration saved.")
else:
print("Usage: /config git ")
continue
if user_input.lower().startswith('/config gitea '):
parts = user_input[14:].split()
if len(parts) >= 2:
config = load_config()
config["gitea"] = {
"url": parts[0],
"token": parts[1]
}
save_config(config)
print("Gitea configuration saved.")
else:
print("Usage: /config gitea ")
continue
if user_input.lower().startswith('/export '):
filename = user_input[8:].strip()
try:
with open(filename, 'w') as f:
f.write("# Ollama CLI Conversation\n\n")
for msg in messages[1:]: # Skip system prompt
role = msg["role"].upper()
content = msg["content"]
f.write(f"## {role}\n\n{content}\n\n")
print(f"Conversation exported to {filename}")
except Exception as e:
print(f"Error exporting: {e}")
continue
if user_input.lower().startswith('/mcp connect '):
parts = user_input[13:].split()
if len(parts) >= 2:
name = parts[0]
command = parts[1]
args = parts[2:] if len(parts) > 2 else None
result = connect_mcp_server(name, command, args)
print(result)
# Save to config
config = load_config()
config["mcp_servers"][name] = {"command": command, "args": args}
save_config(config)
else:
print("Usage: /mcp connect [args...]")
continue
if user_input.lower() == '/mcp list':
if MCP_SERVERS:
print("Connected MCP servers:")
for server in MCP_SERVERS:
tools = [t for t in MCP_TOOLS if t.startswith(f"mcp_{server}_")]
print(f" {server}: {len(tools)} tools")
for tool_key in tools[:5]: # Show first 5
tool = MCP_TOOLS[tool_key]
print(f" - {tool['name']}: {tool['description'][:60]}")
else:
print("No MCP servers connected.")
continue
if user_input.lower().startswith('/mcp disconnect '):
name = user_input[16:].strip()
if name in MCP_SERVERS:
MCP_SERVERS[name].terminate()
del MCP_SERVERS[name]
# Remove tools
MCP_TOOLS.clear()
for key in list(MCP_TOOLS.keys()):
if key.startswith(f"mcp_{name}_"):
del MCP_TOOLS[key]
print(f"Disconnected from MCP server '{name}'")
else:
print(f"MCP server '{name}' not connected.")
continue
if user_input.lower().startswith('/context '):
arg = user_input[9:].strip()
if arg == 'list':
if SESSION_CONTEXT["context_files"]:
print("Context files:")
for cf in SESSION_CONTEXT["context_files"]:
print(f" {cf['path']} ({len(cf['content'])} chars)")
else:
print("No context files loaded.")
continue
if arg == 'clear':
SESSION_CONTEXT["context_files"].clear()
print("Context files cleared.")
continue
# Load file into context
filepath = os.path.expanduser(arg)
try:
with open(filepath, 'r') as f:
content = f.read()
SESSION_CONTEXT["context_files"].append({
"path": filepath,
"content": content
})
print(f"Added {filepath} to context ({len(content)} chars)")
except Exception as e:
print(f"Error loading context file: {e}")
continue
if user_input.lower().startswith('/lsp start '):
parts = user_input[11:].split()
if len(parts) >= 1:
language = parts[0]
path = parts[1] if len(parts) > 1 else "."
result = start_lsp_server(language, path)
print(result)
else:
print("Usage: /lsp start [path]")
continue
if user_input.lower().startswith('/kb add '):
parts = user_input[8:].split(maxsplit=1)
if len(parts) == 2:
key, content = parts
result = kb_add(key, content)
print(result)
else:
print("Usage: /kb add ")
continue
if user_input.lower().startswith('/kb search '):
query = user_input[11:].strip()
result = kb_search(query)
print(result)
continue
if user_input.lower() == '/agents':
result = check_subagents()
print(result)
continue
if user_input.lower().startswith('/server '):
url = user_input[8:].strip()
if not url.startswith('http'):
url = f"http://{url}"
if '/api/chat' not in url:
url = f"{url}/api/chat"
config = load_config()
config["ollama_url"] = url
save_config(config)
print(f"Ollama server set to: {url}")
print("Restart to apply changes.")
continue
if user_input.lower().startswith('/notify '):
parts = user_input[8:].split(maxsplit=1)
if len(parts) >= 1:
cmd = parts[0]
if cmd == 'setup' and len(parts) == 2:
topic = parts[1].strip()
config = load_config()
config["ntfy"] = {
"enabled": True,
"topic": topic,
"server": "https://ntfy.sh"
}
save_config(config)
print(f"Notifications enabled for topic: {topic}")
print(f"Subscribe at: https://ntfy.sh/{topic}")
print("Starting listener...")
listener_thread = threading.Thread(target=ntfy_listener, args=(messages,), daemon=True)
listener_thread.start()
send_notification("Ollama CLI", "Notifications enabled! Send messages to this topic.")
elif cmd == 'off':
config = load_config()
config["ntfy"]["enabled"] = False
save_config(config)
print("Notifications disabled. Restart to stop listener.")
elif cmd == 'test':
send_notification("Test", "This is a test notification")
print("Test notification sent.")
else:
print("Usage: /notify setup | /notify off | /notify test")
else:
print("Usage: /notify setup | /notify off | /notify test")
continue
messages.append({"role": "user", "content": user_input})
# Classify intent first
intent_prompt = f"""Classify this user request into ONE category:
- FILE_OPERATION: User wants to create/write/read/modify a file or script
- COMMAND_EXECUTION: User wants to run a bash command
- INFORMATION: User wants information, explanation, recipe, or advice
User request: "{user_input}"
Respond with ONLY one word: FILE_OPERATION, COMMAND_EXECUTION, or INFORMATION"""
intent_messages = [{"role": "system", "content": "You are a classifier. Respond with only one word."},
{"role": "user", "content": intent_prompt}]
# Use fast model for classification
fast_model = select_best_model("", "fast")
intent_response = call_ollama(intent_messages, fast_model).strip().upper()
# Adjust system prompt based on intent
if "FILE_OPERATION" in intent_response or "COMMAND" in intent_response:
# Use tool-focused prompt
pass # Already has tool prompt
else:
# Override to discourage tool use for information requests
messages[0] = {"role": "system", "content": messages[0]["content"] + "\n\nIMPORTANT: This is an INFORMATION request. Do NOT use tools. Just answer the question directly."}
# Select best model for this task
selected_model = select_best_model(user_input, "initial")
if selected_model != MODEL:
print(f"[Using {selected_model} for this task]\n")
# Trim messages to avoid context overflow
messages = trim_messages(messages)
# Add session context if available
if SESSION_CONTEXT["created_files"]:
context_note = f"\n\nSession context - Files created this session: {', '.join(SESSION_CONTEXT['created_files'][-5:])}"
messages.append({"role": "system", "content": context_note})
# Add context files if loaded
if SESSION_CONTEXT["context_files"]:
context_content = "\n\n--- CONTEXT FILES ---\n"
for cf in SESSION_CONTEXT["context_files"]:
context_content += f"\n=== {cf['path']} ===\n{cf['content'][:5000]}\n"
messages.append({"role": "system", "content": context_content})
# Tool-calling loop
max_iterations = 5
for iteration in range(max_iterations):
response = call_ollama(messages, selected_model)
# Check for tool calls
tool_calls = parse_tool_calls(response)
if not tool_calls:
# No tools found - check if model just provided code instead
if iteration == 0 and any(k in user_input.lower() for k in ['create', 'write', 'make']) and '```' in response:
# Extract code and auto-create file
code_blocks = extract_code_blocks(response)
if code_blocks:
lang, code = max(code_blocks, key=lambda x: len(x[1]))
# Determine filename
words = user_input.lower().split()
filename = None
for word in words:
if any(ext in word for ext in ['.py', '.js', '.html', '.sh']):
filename = word
break
if not filename:
ext = {'.py': 'python', '.js': 'javascript', '.sh': 'bash'}.get(lang, '.txt')
for key, val in {'.py': 'python', '.js': 'javascript', '.sh': 'bash'}.items():
if val == lang:
ext = key
break
if 'calculator' in user_input.lower():
filename = f'calculator{ext}'
else:
filename = f'script{ext}'
print(f"\n[Model didn't use tool - auto-creating {filename}]")
result = write_file(filename, code)
print(f"[{result}]")
messages.append({"role": "assistant", "content": response})
messages.append({"role": "user", "content": f"Tool: write_file\nResult:\n{result}"})
continue
# No tools needed, show response
if '' in response or any(tool in response for tool in TOOLS.keys()):
print(f"\n[Warning: Model attempted tool use but format was incorrect]")
messages.append({"role": "assistant", "content": response})
# Remove the old auto-save logic since we handle it above now
break
# Execute tools
print() # Newline before tool execution
tool_results = []
for tool_name, params in tool_calls:
# Check if it's an MCP tool
if tool_name.startswith("mcp_") and tool_name in MCP_TOOLS:
mcp_tool = MCP_TOOLS[tool_name]
server_name = mcp_tool["server"]
actual_tool_name = mcp_tool["name"]
print(f"[Executing MCP tool: {actual_tool_name} on {server_name}]")
result = call_mcp_tool(server_name, actual_tool_name, params)
print(f"[Result: {result[:200]}{'...' if len(result) > 200 else ''}]")
tool_results.append(f"Tool: {tool_name}\nResult:\n{result}")
elif tool_name in TOOLS:
print(f"[Executing: {tool_name}({json.dumps(params)[:100]}...)]")
result = TOOLS[tool_name](**params)
# Show more for git list operations
display_limit = 1000 if tool_name == "git_operation" else 200
print(f"[Result: {result[:display_limit]}{'...' if len(result) > display_limit else ''}]")
tool_results.append(f"Tool: {tool_name}\nResult:\n{result}")
# Send notifications for key events
if tool_name == "write_file" and "Successfully" in result:
send_notification("File Created", f"Created: {params.get('path', 'file')}")
elif tool_name == "execute_bash" and "Exit code: 0" in result:
send_notification("Command Complete", f"Executed: {params.get('command', 'command')[:50]}")
else:
tool_results.append(f"Error: Unknown tool '{tool_name}'")
# Add assistant message and tool results
messages.append({"role": "assistant", "content": response})
# Check if there was a syntax error
has_syntax_error = any('syntax error' in r.lower() for r in tool_results)
# For successful file creation, confirm and stop
if any('write_file' in r and 'Successfully' in r for r in tool_results):
print("\nFile created successfully.")
# Check if it's an executable script and offer to run it
created_files = [params.get('path') for name, params in tool_calls if name == 'write_file']
for filepath in created_files:
if filepath and (filepath.endswith('.py') or filepath.endswith('.sh')):
try:
response = input(f"\nRun {filepath}? (y/n): ").strip().lower()
if response == 'y':
print(f"\n[Running {filepath}...]")
result = execute_bash(f"python3 {filepath}" if filepath.endswith('.py') else f"bash {filepath}")
print(result)
except:
pass
messages.append({"role": "user", "content": "\n\n".join(tool_results)})
break
# For other successful tool executions, show result and stop (don't loop)
if not has_syntax_error and iteration == 0:
print() # Newline after tool results
messages.append({"role": "user", "content": "\n\n".join(tool_results)})
break
# If syntax error, let model see it and try to fix
if has_syntax_error:
print("\n[Syntax error detected - asking model to fix]")
messages.append({"role": "user", "content": "\n\n".join(tool_results) + "\n\nPlease fix the syntax error and try again."})
else:
messages.append({"role": "user", "content": "\n\n".join(tool_results)})
# Switch to a general model for interpreting tool results
response_model = select_best_model("", "tool_response")
if response_model != selected_model:
print(f"[Switching to {response_model} for response]\n")
selected_model = response_model
# If last iteration, get final response
if iteration == max_iterations - 1:
final_response = call_ollama(messages, selected_model)
messages.append({"role": "assistant", "content": final_response})
# Auto-save session after each interaction
config = load_config()
if config.get("auto_save_sessions", True):
save_session(messages, "autosave")
if __name__ == "__main__":
main()