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>
This commit is contained in:
258
tools.py
258
tools.py
@@ -340,7 +340,12 @@ TOOL_DEFINITIONS = [
|
||||
|
||||
|
||||
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."""
|
||||
"""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
|
||||
|
||||
@@ -348,71 +353,9 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# MCP tools (zettelkasten + web_fetch) - route to mcp_tools.py
|
||||
MCP_TOOLS = {
|
||||
"web_fetch", "fleeting_note", "daily_note", "literature_note",
|
||||
"permanent_note", "search_vault", "search_by_tags"
|
||||
}
|
||||
|
||||
if tool_name in MCP_TOOLS:
|
||||
# Route to MCP tool handlers
|
||||
import anyio
|
||||
from mcp_tools import (
|
||||
web_fetch_tool, fleeting_note_tool, daily_note_tool,
|
||||
literature_note_tool, permanent_note_tool,
|
||||
search_vault_tool, search_by_tags_tool
|
||||
)
|
||||
|
||||
# Map tool names to their handlers
|
||||
mcp_handlers = {
|
||||
"web_fetch": web_fetch_tool,
|
||||
"fleeting_note": fleeting_note_tool,
|
||||
"daily_note": daily_note_tool,
|
||||
"literature_note": literature_note_tool,
|
||||
"permanent_note": permanent_note_tool,
|
||||
"search_vault": search_vault_tool,
|
||||
"search_by_tags": search_by_tags_tool,
|
||||
}
|
||||
|
||||
# Execute MCP tool asynchronously
|
||||
handler = mcp_handlers[tool_name]
|
||||
result = anyio.run(handler, tool_input)
|
||||
|
||||
# Convert result to string if needed
|
||||
if isinstance(result, dict):
|
||||
if "error" in result:
|
||||
error_msg = f"Error: {result['error']}"
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
logger.log_tool_call(
|
||||
tool_name=tool_name,
|
||||
inputs=tool_input,
|
||||
success=False,
|
||||
error=error_msg,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
return error_msg
|
||||
elif "content" in result:
|
||||
result_str = result["content"]
|
||||
else:
|
||||
result_str = str(result)
|
||||
else:
|
||||
result_str = str(result)
|
||||
|
||||
# Log successful execution
|
||||
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
|
||||
|
||||
# File tools (traditional handlers - kept for backward compatibility)
|
||||
# Execute traditional tool and capture result
|
||||
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":
|
||||
@@ -424,16 +367,31 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
tool_input["new_text"],
|
||||
)
|
||||
elif tool_name == "list_directory":
|
||||
path = tool_input.get("path", ".")
|
||||
result_str = _list_directory(path)
|
||||
result_str = _list_directory(tool_input.get("path", "."))
|
||||
elif tool_name == "run_command":
|
||||
command = tool_input["command"]
|
||||
working_dir = tool_input.get("working_dir", ".")
|
||||
result_str = _run_command(command, working_dir)
|
||||
result_str = _run_command(
|
||||
tool_input["command"],
|
||||
tool_input.get("working_dir", "."),
|
||||
)
|
||||
|
||||
# --- Weather tool (sync handler) ---
|
||||
elif tool_name == "get_weather":
|
||||
location = tool_input.get("location", "Phoenix, US")
|
||||
result_str = _get_weather(location)
|
||||
# Gmail tools
|
||||
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"],
|
||||
@@ -453,7 +411,6 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
message_id=tool_input["message_id"],
|
||||
format_type=tool_input.get("format", "text"),
|
||||
)
|
||||
# Calendar tools
|
||||
elif tool_name == "read_calendar":
|
||||
result_str = _read_calendar(
|
||||
days_ahead=tool_input.get("days_ahead", 7),
|
||||
@@ -474,7 +431,6 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
query=tool_input["query"],
|
||||
calendar_id=tool_input.get("calendar_id", "primary"),
|
||||
)
|
||||
# Contacts tools
|
||||
elif tool_name == "create_contact":
|
||||
result_str = _create_contact(
|
||||
given_name=tool_input["given_name"],
|
||||
@@ -493,7 +449,16 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
resource_name=tool_input["resource_name"],
|
||||
)
|
||||
|
||||
# Log successful traditional tool execution
|
||||
# --- 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(
|
||||
@@ -501,7 +466,7 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
inputs=tool_input,
|
||||
success=True,
|
||||
result=result_str,
|
||||
duration_ms=duration_ms
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return result_str
|
||||
else:
|
||||
@@ -512,9 +477,10 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
inputs=tool_input,
|
||||
success=False,
|
||||
error=error_msg,
|
||||
duration_ms=duration_ms
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return error_msg
|
||||
|
||||
except Exception as e:
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
error_msg = str(e)
|
||||
@@ -539,6 +505,61 @@ def execute_tool(tool_name: str, tool_input: Dict[str, Any], healing_system: Any
|
||||
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
|
||||
|
||||
@@ -1001,3 +1022,86 @@ def _get_contact(resource_name: str) -> str:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user