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:
2026-02-18 20:31:32 -07:00
parent 0271dea551
commit fe7c146dc6
29 changed files with 5678 additions and 2287 deletions

258
tools.py
View File

@@ -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