Files
ajarbot/tools.py
Jordan Ramos fe7c146dc6 feat: Add Gitea MCP integration and project cleanup
## New Features
- **Gitea MCP Tools** (zero API cost):
  - gitea_read_file: Read files from homelab repo
  - gitea_list_files: Browse directories
  - gitea_search_code: Search by filename
  - gitea_get_tree: Get directory tree
- **Gitea Client** (gitea_tools/client.py): REST API wrapper with OAuth
- **Proxmox SSH Scripts** (scripts/): Homelab data collection utilities
- **Obsidian MCP Support** (obsidian_mcp.py): Advanced vault operations
- **Voice Integration Plan** (JARVIS_VOICE_INTEGRATION_PLAN.md)

## Improvements
- **Increased timeout**: 5min → 10min for complex tasks (llm_interface.py)
- **Removed Direct API fallback**: Gitea tools are MCP-only (zero cost)
- **Updated .env.example**: Added Obsidian MCP configuration
- **Enhanced .gitignore**: Protect personal memory files (SOUL.md, MEMORY.md)

## Cleanup
- Deleted 24 obsolete files (temp/test/experimental scripts, outdated docs)
- Untracked personal memory files (SOUL.md, MEMORY.md now in .gitignore)
- Removed: AGENT_SDK_IMPLEMENTATION.md, HYBRID_SEARCH_SUMMARY.md,
  IMPLEMENTATION_SUMMARY.md, MIGRATION.md, test_agent_sdk.py, etc.

## Configuration
- Added config/gitea_config.example.yaml (Gitea setup template)
- Added config/obsidian_mcp.example.yaml (Obsidian MCP template)
- Updated scheduled_tasks.yaml with new task examples

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 20:31:32 -07:00

1108 lines
39 KiB
Python

"""Tool definitions and execution for agent capabilities."""
import os
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional
# Google tools (lazy loaded when needed)
_gmail_client: Optional[Any] = None
_calendar_client: Optional[Any] = None
_people_client: Optional[Any] = None
# Tool definitions in Anthropic's tool use format
TOOL_DEFINITIONS = [
{
"name": "read_file",
"description": "Read the contents of a file. Use this to view configuration files, code, or any text file.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to read (relative or absolute)",
}
},
"required": ["file_path"],
},
},
{
"name": "write_file",
"description": "Write content to a file. Creates a new file or overwrites existing file completely.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to write",
},
"content": {
"type": "string",
"description": "Content to write to the file",
},
},
"required": ["file_path", "content"],
},
},
{
"name": "edit_file",
"description": "Edit a file by replacing specific text. Use this to make targeted changes without rewriting the entire file.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to edit",
},
"old_text": {
"type": "string",
"description": "Exact text to find and replace",
},
"new_text": {
"type": "string",
"description": "New text to replace with",
},
},
"required": ["file_path", "old_text", "new_text"],
},
},
{
"name": "list_directory",
"description": "List files and directories in a given path. Useful for exploring the file system.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list (defaults to current directory)",
"default": ".",
}
},
},
},
{
"name": "run_command",
"description": "Execute a shell command. Use for git operations, running scripts, installing packages, etc.",
"input_schema": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute",
},
"working_dir": {
"type": "string",
"description": "Working directory for command execution (defaults to current directory)",
"default": ".",
},
},
"required": ["command"],
},
},
{
"name": "get_weather",
"description": "Get current weather for a location using OpenWeatherMap API. Returns temperature, conditions, and brief summary.",
"input_schema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name or 'City, Country' (e.g., 'Phoenix, US' or 'London, GB'). Defaults to Phoenix, AZ if not specified.",
"default": "Phoenix, US",
}
},
"required": [],
},
},
# Gmail tools
{
"name": "send_email",
"description": "Send an email from the bot's Gmail account. Requires prior OAuth setup (--setup-google).",
"input_schema": {
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "Recipient email address",
},
"subject": {
"type": "string",
"description": "Email subject line",
},
"body": {
"type": "string",
"description": "Email body (plain text or HTML)",
},
"cc": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of CC recipients",
},
"reply_to_message_id": {
"type": "string",
"description": "Optional Gmail message ID to reply to (for threading)",
},
},
"required": ["to", "subject", "body"],
},
},
{
"name": "read_emails",
"description": "Search and read emails from the bot's Gmail account using Gmail search syntax (e.g., 'from:user@example.com after:2026/02/10').",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Gmail search query (supports from, to, subject, after, before, has:attachment, etc.)",
"default": "",
},
"max_results": {
"type": "integer",
"description": "Maximum number of emails to return (default: 10, max: 50)",
"default": 10,
},
"include_body": {
"type": "boolean",
"description": "Whether to include full email body (default: false, shows snippet only)",
"default": False,
},
},
},
},
{
"name": "get_email",
"description": "Get full content of a specific email by its Gmail message ID.",
"input_schema": {
"type": "object",
"properties": {
"message_id": {
"type": "string",
"description": "Gmail message ID",
},
"format": {
"type": "string",
"description": "Format type: 'text' or 'html' (default: text)",
"default": "text",
},
},
"required": ["message_id"],
},
},
# Calendar tools
{
"name": "read_calendar",
"description": "Read upcoming events from Google Calendar. Shows events from today onwards.",
"input_schema": {
"type": "object",
"properties": {
"days_ahead": {
"type": "integer",
"description": "Number of days ahead to look (default: 7, max: 30)",
"default": 7,
},
"calendar_id": {
"type": "string",
"description": "Calendar ID (default: 'primary' for main calendar)",
"default": "primary",
},
"max_results": {
"type": "integer",
"description": "Maximum number of events to return (default: 20)",
"default": 20,
},
},
},
},
{
"name": "create_calendar_event",
"description": "Create a new event in Google Calendar. Use ISO 8601 format for times (e.g., '2026-02-14T10:00:00Z').",
"input_schema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Event title/summary",
},
"start_time": {
"type": "string",
"description": "Event start time in ISO 8601 format (e.g., '2026-02-14T10:00:00Z')",
},
"end_time": {
"type": "string",
"description": "Event end time in ISO 8601 format",
},
"description": {
"type": "string",
"description": "Optional event description",
"default": "",
},
"location": {
"type": "string",
"description": "Optional event location",
"default": "",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID (default: 'primary')",
"default": "primary",
},
},
"required": ["summary", "start_time", "end_time"],
},
},
{
"name": "search_calendar",
"description": "Search calendar events by text query. Searches event titles and descriptions.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query text",
},
"calendar_id": {
"type": "string",
"description": "Calendar ID (default: 'primary')",
"default": "primary",
},
},
"required": ["query"],
},
},
# Contacts tools
{
"name": "create_contact",
"description": "Create a new Google contact. Requires prior OAuth setup (--setup-google).",
"input_schema": {
"type": "object",
"properties": {
"given_name": {
"type": "string",
"description": "Contact's first name",
},
"family_name": {
"type": "string",
"description": "Contact's last name",
"default": "",
},
"email": {
"type": "string",
"description": "Contact's email address",
"default": "",
},
"phone": {
"type": "string",
"description": "Contact's phone number",
},
"notes": {
"type": "string",
"description": "Optional notes about the contact",
},
},
"required": ["given_name"],
},
},
{
"name": "list_contacts",
"description": "List or search Google contacts. Without a query, lists all contacts sorted by last name.",
"input_schema": {
"type": "object",
"properties": {
"max_results": {
"type": "integer",
"description": "Maximum number of contacts to return (default: 100, max: 1000)",
"default": 100,
},
"query": {
"type": "string",
"description": "Optional search query to filter contacts by name, email, etc.",
},
},
},
},
{
"name": "get_contact",
"description": "Get full details of a specific Google contact by resource name.",
"input_schema": {
"type": "object",
"properties": {
"resource_name": {
"type": "string",
"description": "Contact resource name (e.g., 'people/c1234567890')",
},
},
"required": ["resource_name"],
},
},
]
def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any = None) -> str:
"""Execute a tool and return the result as a string.
This is used by the Direct API tool loop in agent.py.
In Agent SDK mode, tools are executed automatically via MCP servers
and this function is not called.
"""
import time
from logging_config import get_tool_logger
logger = get_tool_logger()
start_time = time.time()
try:
result_str = None
# --- File and system tools (sync handlers) ---
if tool_name == "read_file":
result_str = _read_file(tool_input["file_path"])
elif tool_name == "write_file":
result_str = _write_file(tool_input["file_path"], tool_input["content"])
elif tool_name == "edit_file":
result_str = _edit_file(
tool_input["file_path"],
tool_input["old_text"],
tool_input["new_text"],
)
elif tool_name == "list_directory":
result_str = _list_directory(tool_input.get("path", "."))
elif tool_name == "run_command":
result_str = _run_command(
tool_input["command"],
tool_input.get("working_dir", "."),
)
# --- Weather tool (sync handler) ---
elif tool_name == "get_weather":
result_str = _get_weather(tool_input.get("location", "Phoenix, US"))
# --- Async MCP tools (web, zettelkasten, gitea) ---
elif tool_name in {
"web_fetch", "fleeting_note", "daily_note", "literature_note",
"permanent_note", "search_vault", "search_by_tags",
"gitea_read_file", "gitea_list_files", "gitea_search_code", "gitea_get_tree",
}:
# Note: These tools should only execute via Agent SDK MCP servers.
# If you're seeing this message, the tool routing needs adjustment.
return (
f"[MCP Tool] '{tool_name}' should be dispatched by Agent SDK MCP server. "
f"Direct API fallback is disabled for this tool to ensure zero API cost."
)
# --- Google tools (sync handlers using traditional API clients) ---
elif tool_name == "send_email":
result_str = _send_email(
to=tool_input["to"],
subject=tool_input["subject"],
body=tool_input["body"],
cc=tool_input.get("cc"),
reply_to_message_id=tool_input.get("reply_to_message_id"),
)
elif tool_name == "read_emails":
result_str = _read_emails(
query=tool_input.get("query", ""),
max_results=tool_input.get("max_results", 10),
include_body=tool_input.get("include_body", False),
)
elif tool_name == "get_email":
result_str = _get_email(
message_id=tool_input["message_id"],
format_type=tool_input.get("format", "text"),
)
elif tool_name == "read_calendar":
result_str = _read_calendar(
days_ahead=tool_input.get("days_ahead", 7),
calendar_id=tool_input.get("calendar_id", "primary"),
max_results=tool_input.get("max_results", 20),
)
elif tool_name == "create_calendar_event":
result_str = _create_calendar_event(
summary=tool_input["summary"],
start_time=tool_input["start_time"],
end_time=tool_input["end_time"],
description=tool_input.get("description", ""),
location=tool_input.get("location", ""),
calendar_id=tool_input.get("calendar_id", "primary"),
)
elif tool_name == "search_calendar":
result_str = _search_calendar(
query=tool_input["query"],
calendar_id=tool_input.get("calendar_id", "primary"),
)
elif tool_name == "create_contact":
result_str = _create_contact(
given_name=tool_input["given_name"],
family_name=tool_input.get("family_name", ""),
email=tool_input.get("email", ""),
phone=tool_input.get("phone"),
notes=tool_input.get("notes"),
)
elif tool_name == "list_contacts":
result_str = _list_contacts(
max_results=tool_input.get("max_results", 100),
query=tool_input.get("query"),
)
elif tool_name == "get_contact":
result_str = _get_contact(
resource_name=tool_input["resource_name"],
)
# --- Obsidian MCP tools (external server with fallback) ---
elif tool_name in {
"obsidian_read_note", "obsidian_update_note",
"obsidian_search_replace", "obsidian_global_search",
"obsidian_list_notes", "obsidian_manage_frontmatter",
"obsidian_manage_tags", "obsidian_delete_note",
}:
result_str = _execute_obsidian_tool(tool_name, tool_input, logger, start_time)
# --- Unknown tool ---
if result_str is not None:
duration_ms = (time.time() - start_time) * 1000
logger.log_tool_call(
tool_name=tool_name,
inputs=tool_input,
success=True,
result=result_str,
duration_ms=duration_ms,
)
return result_str
else:
duration_ms = (time.time() - start_time) * 1000
error_msg = f"Error: Unknown tool '{tool_name}'"
logger.log_tool_call(
tool_name=tool_name,
inputs=tool_input,
success=False,
error=error_msg,
duration_ms=duration_ms,
)
return error_msg
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
error_msg = str(e)
# Log the error
logger.log_tool_call(
tool_name=tool_name,
inputs=tool_input,
success=False,
error=error_msg,
duration_ms=duration_ms
)
# Capture in healing system if available
if healing_system:
healing_system.capture_error(
error=e,
component=f"tools.py:{tool_name}",
intent=f"Executing {tool_name} tool",
context={"tool_name": tool_name, "input": tool_input},
)
return f"Error executing {tool_name}: {error_msg}"
def _extract_mcp_result(result: Any) -> str:
"""Convert an MCP tool result dict to a plain string."""
if isinstance(result, dict):
if "error" in result:
return f"Error: {result['error']}"
elif "content" in result:
content = result["content"]
if isinstance(content, list):
# Extract text from content blocks
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
return "\n".join(parts) if parts else str(content)
return str(content)
return str(result)
return str(result)
def _execute_obsidian_tool(
tool_name: str,
tool_input: Dict[str, Any],
logger: Any,
start_time: float,
) -> str:
"""Execute an Obsidian MCP tool with fallback to custom tools."""
try:
from obsidian_mcp import (
check_obsidian_health,
should_fallback_to_custom,
)
if check_obsidian_health():
return (
f"[Obsidian MCP] Tool '{tool_name}' should be dispatched "
f"by the Agent SDK MCP server. If you're seeing this, "
f"the tool call routing may need adjustment."
)
elif should_fallback_to_custom():
fallback_result = _obsidian_fallback(tool_name, tool_input)
if fallback_result is not None:
return fallback_result
return (
f"Error: Obsidian is not running and no fallback "
f"available for '{tool_name}'."
)
else:
return (
f"Error: Obsidian is not running and fallback is disabled. "
f"Please start Obsidian desktop app."
)
except ImportError:
return f"Error: obsidian_mcp module not found for tool '{tool_name}'"
# Maximum characters of tool output to return (prevents token explosion)
_MAX_TOOL_OUTPUT = 5000
def _read_file(file_path: str) -> str:
"""Read and return file contents."""
path = Path(file_path)
if not path.exists():
return f"Error: File not found: {file_path}"
try:
content = path.read_text(encoding="utf-8")
if len(content) > _MAX_TOOL_OUTPUT:
content = content[:_MAX_TOOL_OUTPUT] + "\n... (file truncated)"
return f"Content of {file_path}:\n\n{content}"
except Exception as e:
return f"Error reading file: {str(e)}"
def _write_file(file_path: str, content: str) -> str:
"""Write content to a file."""
path = Path(file_path)
try:
# Create parent directories if they don't exist
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return f"Successfully wrote to {file_path} ({len(content)} characters)"
except Exception as e:
return f"Error writing file: {str(e)}"
def _edit_file(file_path: str, old_text: str, new_text: str) -> str:
"""Edit file by replacing text."""
path = Path(file_path)
if not path.exists():
return f"Error: File not found: {file_path}"
try:
content = path.read_text(encoding="utf-8")
if old_text not in content:
return f"Error: Text not found in file. Could not find:\n{old_text[:100]}..."
new_content = content.replace(old_text, new_text, 1)
path.write_text(new_content, encoding="utf-8")
return f"Successfully edited {file_path}. Replaced 1 occurrence."
except Exception as e:
return f"Error editing file: {str(e)}"
def _list_directory(path: str) -> str:
"""List directory contents."""
dir_path = Path(path)
if not dir_path.exists():
return f"Error: Directory not found: {path}"
if not dir_path.is_dir():
return f"Error: Not a directory: {path}"
try:
items = []
for item in sorted(dir_path.iterdir()):
item_type = "DIR " if item.is_dir() else "FILE"
size = "" if item.is_dir() else f" ({item.stat().st_size} bytes)"
items.append(f" {item_type} {item.name}{size}")
if not items:
return f"Directory {path} is empty"
return f"Contents of {path}:\n" + "\n".join(items)
except Exception as e:
return f"Error listing directory: {str(e)}"
def _run_command(command: str, working_dir: str) -> str:
"""Execute a shell command."""
try:
result = subprocess.run(
command,
shell=True,
cwd=working_dir,
capture_output=True,
text=True,
timeout=30,
)
output = []
if result.stdout:
stdout = result.stdout
if len(stdout) > _MAX_TOOL_OUTPUT:
stdout = stdout[:_MAX_TOOL_OUTPUT] + "\n... (stdout truncated)"
output.append(f"STDOUT:\n{stdout}")
if result.stderr:
stderr = result.stderr
if len(stderr) > _MAX_TOOL_OUTPUT:
stderr = stderr[:_MAX_TOOL_OUTPUT] + "\n... (stderr truncated)"
output.append(f"STDERR:\n{stderr}")
status = f"Command exited with code {result.returncode}"
if not output:
return status
return status + "\n\n" + "\n\n".join(output)
except subprocess.TimeoutExpired:
return "Error: Command timed out after 30 seconds"
except Exception as e:
return f"Error running command: {str(e)}"
def _get_weather(location: str = "Phoenix, US") -> str:
"""Get current weather for a location using OpenWeatherMap API.
Args:
location: City name or 'City, Country' (e.g., 'Phoenix, US')
Returns:
Weather summary string
"""
import requests
api_key = os.getenv("OPENWEATHERMAP_API_KEY")
if not api_key:
return "Error: OPENWEATHERMAP_API_KEY not found in environment variables. Please add it to your .env file."
try:
# OpenWeatherMap API endpoint
base_url = "http://api.openweathermap.org/data/2.5/weather"
params = {
"q": location,
"appid": api_key,
"units": "imperial" # Fahrenheit
}
response = requests.get(base_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Extract weather data
temp = data["main"]["temp"]
feels_like = data["main"]["feels_like"]
description = data["weather"][0]["description"].capitalize()
humidity = data["main"]["humidity"]
wind_speed = data["wind"]["speed"]
city = data["name"]
# Format weather summary
summary = f"**{city} Weather:**\n"
summary += f"🌡️ {temp}°F (feels like {feels_like}°F)\n"
summary += f"☁️ {description}\n"
summary += f"💧 Humidity: {humidity}%\n"
summary += f"💨 Wind: {wind_speed} mph"
return summary
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return "Error: Invalid OpenWeatherMap API key. Please check your OPENWEATHERMAP_API_KEY in .env file."
elif e.response.status_code == 404:
return f"Error: Location '{location}' not found. Try format: 'City, Country' (e.g., 'Phoenix, US')"
else:
return f"Error: OpenWeatherMap API error: {e}"
except requests.exceptions.Timeout:
return "Error: Weather API request timed out. Please try again."
except Exception as e:
return f"Error getting weather: {str(e)}"
# Google Tools Handlers
def _initialize_google_clients() -> tuple[Optional[Any], Optional[Any], Optional[Any]]:
"""Lazy initialization of Google clients.
Returns:
Tuple of (gmail_client, calendar_client, people_client) or (None, None, None) if not authorized
"""
global _gmail_client, _calendar_client, _people_client
if _gmail_client is not None and _calendar_client is not None and _people_client is not None:
return _gmail_client, _calendar_client, _people_client
try:
from google_tools.oauth_manager import GoogleOAuthManager
from google_tools.gmail_client import GmailClient
from google_tools.calendar_client import CalendarClient
from google_tools.people_client import PeopleClient
oauth_manager = GoogleOAuthManager()
credentials = oauth_manager.get_credentials()
if not credentials:
return None, None, None
_gmail_client = GmailClient(oauth_manager)
_calendar_client = CalendarClient(oauth_manager)
_people_client = PeopleClient(oauth_manager)
return _gmail_client, _calendar_client, _people_client
except Exception as e:
print(f"[Google Tools] Failed to initialize: {e}")
return None, None, None
def _send_email(
to: str,
subject: str,
body: str,
cc: Optional[List[str]] = None,
reply_to_message_id: Optional[str] = None,
) -> str:
"""Send an email via Gmail API."""
gmail_client, _, _ = _initialize_google_clients()
if not gmail_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = gmail_client.send_email(
to=to,
subject=subject,
body=body,
cc=cc,
reply_to_message_id=reply_to_message_id,
)
if result["success"]:
msg_id = result.get("message_id", "unknown")
return f"✓ Email sent successfully to {to}\nMessage ID: {msg_id}\nSubject: {subject}"
else:
return f"Error sending email: {result.get('error', 'Unknown error')}"
def _read_emails(
query: str = "",
max_results: int = 10,
include_body: bool = False,
) -> str:
"""Search and read emails from Gmail."""
gmail_client, _, _ = _initialize_google_clients()
if not gmail_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = gmail_client.search_emails(
query=query,
max_results=max_results,
include_body=include_body,
)
if result["success"]:
summary = result.get("summary", "")
if len(summary) > _MAX_TOOL_OUTPUT:
summary = summary[:_MAX_TOOL_OUTPUT] + "\n... (results truncated)"
return summary
else:
return f"Error reading emails: {result.get('error', 'Unknown error')}"
def _get_email(message_id: str, format_type: str = "text") -> str:
"""Get full content of a specific email."""
gmail_client, _, _ = _initialize_google_clients()
if not gmail_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = gmail_client.get_email(message_id=message_id, format_type=format_type)
if result["success"]:
email_data = result.get("email", {})
output = []
output.append(f"From: {email_data.get('from', 'Unknown')}")
output.append(f"To: {email_data.get('to', 'Unknown')}")
if email_data.get("cc"):
output.append(f"CC: {email_data['cc']}")
output.append(f"Subject: {email_data.get('subject', 'No subject')}")
output.append(f"Date: {email_data.get('date', 'Unknown')}")
output.append(f"\n{email_data.get('body', '')}")
if email_data.get("attachments"):
output.append(f"\nAttachments: {', '.join(email_data['attachments'])}")
full_output = "\n".join(output)
if len(full_output) > _MAX_TOOL_OUTPUT:
full_output = full_output[:_MAX_TOOL_OUTPUT] + "\n... (email truncated)"
return full_output
else:
return f"Error getting email: {result.get('error', 'Unknown error')}"
def _read_calendar(
days_ahead: int = 7,
calendar_id: str = "primary",
max_results: int = 20,
) -> str:
"""Read upcoming calendar events."""
_, calendar_client, _ = _initialize_google_clients()
if not calendar_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = calendar_client.list_events(
days_ahead=days_ahead,
calendar_id=calendar_id,
max_results=max_results,
)
if result["success"]:
summary = result.get("summary", "No events found")
if len(summary) > _MAX_TOOL_OUTPUT:
summary = summary[:_MAX_TOOL_OUTPUT] + "\n... (results truncated)"
return f"Upcoming events (next {days_ahead} days):\n\n{summary}"
else:
return f"Error reading calendar: {result.get('error', 'Unknown error')}"
def _create_calendar_event(
summary: str,
start_time: str,
end_time: str,
description: str = "",
location: str = "",
calendar_id: str = "primary",
) -> str:
"""Create a new calendar event."""
_, calendar_client, _ = _initialize_google_clients()
if not calendar_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = calendar_client.create_event(
summary=summary,
start_time=start_time,
end_time=end_time,
description=description,
location=location,
calendar_id=calendar_id,
)
if result["success"]:
event_id = result.get("event_id", "unknown")
html_link = result.get("html_link", "")
start = result.get("start", start_time)
output = [
f"✓ Calendar event created successfully!",
f"Title: {summary}",
f"Start: {start}",
f"Event ID: {event_id}",
]
if html_link:
output.append(f"Link: {html_link}")
if location:
output.append(f"Location: {location}")
return "\n".join(output)
else:
return f"Error creating calendar event: {result.get('error', 'Unknown error')}"
def _search_calendar(query: str, calendar_id: str = "primary") -> str:
"""Search calendar events by text query."""
_, calendar_client, _ = _initialize_google_clients()
if not calendar_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = calendar_client.search_events(query=query, calendar_id=calendar_id)
if result["success"]:
summary = result.get("summary", "No events found")
if len(summary) > _MAX_TOOL_OUTPUT:
summary = summary[:_MAX_TOOL_OUTPUT] + "\n... (results truncated)"
return f'Search results for "{query}":\n\n{summary}'
else:
return f"Error searching calendar: {result.get('error', 'Unknown error')}"
# Contacts Tools Handlers
def _create_contact(
given_name: str,
family_name: str = "",
email: str = "",
phone: Optional[str] = None,
notes: Optional[str] = None,
) -> str:
"""Create a new Google contact."""
_, _, people_client = _initialize_google_clients()
if not people_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = people_client.create_contact(
given_name=given_name,
family_name=family_name,
email=email,
phone=phone,
notes=notes,
)
if result["success"]:
name = result.get("name", given_name)
resource = result.get("resource_name", "")
return f"Contact created: {name}\nResource: {resource}"
else:
return f"Error creating contact: {result.get('error', 'Unknown error')}"
def _list_contacts(
max_results: int = 100,
query: Optional[str] = None,
) -> str:
"""List or search Google contacts."""
_, _, people_client = _initialize_google_clients()
if not people_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = people_client.list_contacts(max_results=max_results, query=query)
if result["success"]:
summary = result.get("summary", "No contacts found.")
if len(summary) > _MAX_TOOL_OUTPUT:
summary = summary[:_MAX_TOOL_OUTPUT] + "\n... (results truncated)"
return f"Contacts ({result.get('count', 0)} found):\n\n{summary}"
else:
return f"Error listing contacts: {result.get('error', 'Unknown error')}"
def _get_contact(resource_name: str) -> str:
"""Get full details of a specific contact."""
_, _, people_client = _initialize_google_clients()
if not people_client:
return "Error: Google not authorized. Run: python bot_runner.py --setup-google"
result = people_client.get_contact(resource_name=resource_name)
if result["success"]:
c = result.get("contact", {})
output = []
name = c.get("display_name") or f"{c.get('given_name', '')} {c.get('family_name', '')}".strip()
output.append(f"Name: {name or '(no name)'}")
if c.get("email"):
output.append(f"Email: {c['email']}")
if c.get("phone"):
output.append(f"Phone: {c['phone']}")
if c.get("notes"):
output.append(f"Notes: {c['notes']}")
output.append(f"Resource: {c.get('resource_name', resource_name)}")
return "\n".join(output)
else:
return f"Error getting contact: {result.get('error', 'Unknown error')}"
def _obsidian_fallback(tool_name: str, tool_input: Dict[str, Any]) -> Optional[str]:
"""Map Obsidian MCP tools to custom zettelkasten/file tool equivalents.
Returns None if no fallback is possible for the given tool.
"""
from pathlib import Path
if tool_name == "obsidian_read_note":
# Map to read_file with vault-relative path
vault_path = Path("memory_workspace/obsidian")
file_path = str(vault_path / tool_input.get("filePath", ""))
return _read_file(file_path)
elif tool_name == "obsidian_global_search":
# Map to search_vault
import anyio
from mcp_tools import search_vault_tool
result = anyio.run(search_vault_tool, {
"query": tool_input.get("query", ""),
"limit": tool_input.get("pageSize", 10),
})
if isinstance(result, dict) and "content" in result:
return str(result["content"])
return str(result)
elif tool_name == "obsidian_list_notes":
# Map to list_directory
vault_path = Path("memory_workspace/obsidian")
dir_path = str(vault_path / tool_input.get("dirPath", ""))
return _list_directory(dir_path)
elif tool_name == "obsidian_update_note":
# Map to write_file or edit_file based on mode
vault_path = Path("memory_workspace/obsidian")
target = tool_input.get("targetIdentifier", "")
content = tool_input.get("content", "")
mode = tool_input.get("wholeFileMode", "overwrite")
file_path = str(vault_path / target)
if mode == "overwrite":
return _write_file(file_path, content)
elif mode == "append":
existing = Path(file_path).read_text(encoding="utf-8") if Path(file_path).exists() else ""
return _write_file(file_path, existing + "\n" + content)
elif mode == "prepend":
existing = Path(file_path).read_text(encoding="utf-8") if Path(file_path).exists() else ""
return _write_file(file_path, content + "\n" + existing)
elif tool_name == "obsidian_search_replace":
# Map to edit_file
vault_path = Path("memory_workspace/obsidian")
target = tool_input.get("targetIdentifier", "")
file_path = str(vault_path / target)
replacements = tool_input.get("replacements", [])
if replacements:
first = replacements[0]
return _edit_file(
file_path,
first.get("search", ""),
first.get("replace", ""),
)
elif tool_name == "obsidian_manage_tags":
# Map to search_by_tags (list operation only)
operation = tool_input.get("operation", "list")
if operation == "list":
tags = tool_input.get("tags", "")
if isinstance(tags, list):
tags = ",".join(tags)
import anyio
from mcp_tools import search_by_tags_tool
result = anyio.run(search_by_tags_tool, {"tags": tags})
if isinstance(result, dict) and "content" in result:
return str(result["content"])
return str(result)
# No fallback possible for:
# - obsidian_manage_frontmatter (new capability, no custom equivalent)
# - obsidian_delete_note (safety: deliberate no-fallback for destructive ops)
# - obsidian_manage_tags add/remove (requires YAML frontmatter parsing)
return None